How to Manage Dependencies in Flutter Without a Framework

When you start a new Flutter project everything is simple. You have a few widgets and maybe one or two classes that talk to an API. But as the app grows a problem emerges. How do your widgets get access to your services? The easy answer often leads to a mess.

The common solutions are global singletons or static classes. They seem convenient at first. Any widget anywhere can call ApiService.instance.fetchData(). But this convenience comes at a high price. It creates tight coupling. Your UI code is now permanently tied to a specific concrete implementation of your ApiService. This makes your code rigid and hard to test.

There is a better way that doesn’t require a complex dependency injection framework. You can solve this with a simple pattern called a Service Locator.

The Problem with Direct Access

Imagine a widget that needs to fetch user data. The common approach looks like this.

// api_service.dart
class ApiService {
  // A classic singleton implementation
  static final instance = ApiService._();
  ApiService._();

  Future<String> fetchUserData() async {
    // Imagine a real network call here
    await Future.delayed(const Duration(seconds: 1));
    return 'User Data';
  }
}

// user_profile.dart
class UserProfile extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: ApiService.instance.fetchUserData(), // Direct dependency
      builder: (context, snapshot) {
        // ... build UI based on snapshot
        return Text(snapshot.data ?? 'Loading...');
      },
    );
  }
}

This code works but it has a hidden flaw. How do you test UserProfile without making a real network call? You can’t. The widget is permanently wired to the real ApiService. You can’t easily swap it with a fake or mock version for a test.

A Simple Service Locator

A service locator is just a central registry for your application’s services. Instead of creating instances directly or using static singletons your code asks the locator for an instance. This breaks the hard link between the consumer and the concrete implementation.

Here is a complete implementation. It’s surprisingly small.

// service_locator.dart
class ServiceLocator {
  // A private constructor to prevent external instantiation.
  ServiceLocator._();

  // The single instance of the locator.
  static final instance = ServiceLocator._();

  final _services = <Type, dynamic>{};

  // Method to register a service instance.
  void register<T>(T service) {
    _services[T] = service;
  }

  // Method to retrieve a registered service.
  T get<T>() {
    final service = _services[T];
    if (service == null) {
      throw Exception('Service of type $T not registered.');
    }
    return service as T;
  }
}

That’s it. It’s a singleton whose only job is to hold and provide other objects. It doesn’t contain any business logic itself.

Using The Locator

First you need to register your services when your application starts. The best place for this is in your main function.

// main.dart
void main() {
  // Register services before running the app.
  ServiceLocator.instance.register<ApiService>(ApiService());

  runApp(const MyApp());
}

Now you can refactor the UserProfile widget to use the locator. Instead of calling the singleton directly it asks the locator for the service it needs.

// user_profile.dart (refactored)
class UserProfile extends StatelessWidget {
  // We can grab the service instance here.
  final apiService = ServiceLocator.instance.get<ApiService>();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: apiService.fetchUserData(), // The dependency is now provided.
      builder: (context, snapshot) {
        return Text(snapshot.data ?? 'Loading...');
      },
    );
  }
}

Notice the change. The UserProfile widget no longer knows how to create an ApiService. It only knows it needs something that fits the ApiService type and it asks the locator for it. This is called inversion of control.

The Payoff Testability

The real benefit comes when you write tests. You can now register a mock version of your service before each test runs. This gives you full control over the widget’s dependencies.

// user_profile_test.dart
class MockApiService implements ApiService {
  @override
  Future<String> fetchUserData() async {
    return 'Mock Data';
  }
}

void main() {
  testWidgets('UserProfile shows mock data', (WidgetTester tester) async {
    // Register the mock service before the test.
    ServiceLocator.instance.register<ApiService>(MockApiService());

    await tester.pumpWidget(MaterialApp(home: UserProfile()));

    // Let the FutureBuilder resolve.
    await tester.pumpAndSettle();

    // Verify that the mock data is displayed.
    expect(find.text('Mock Data'), findsOneWidget);
  });
}

Now your tests are fast reliable and completely independent of external services like a real network. You have broken the tight coupling.

Some might argue that a service locator is just a glorified global variable. They are not entirely wrong. The locator itself is globally accessible. But it is a structural pattern that solves a specific problem. It decouples the what from the how. What service you need is decoupled from how it is created. This is a pragmatic choice that gives you most of the benefits of dependency injection with almost none of the complexity.

This simple pattern can bring order to a chaotic project. It separates concerns makes your code easier to reason about and most importantly makes it testable.

Think about the messiest part of your app’s logic. Take a moment to describe it.

— Rishi Banerjee
September 2025