Dependency Injection in Flutter

When your app starts to grow, you'll feel the need for Dependency Injection for testing. My app has a few "services" that query information from APIs or Firestore. Each service is a Singleton, and my different Widgets will use one or more of these services. When I run the app, I pass the actual implementations of the services, and when I run my unit tests, I pass mocks or fakes. Dependency Injection makes it easy to swap these and feed them to my components.

InheritedWidget

For the longest time, I've used an InheritedWidget. I used the example code and turned this into a single class that contains each of the services I need.

InheritedWidget class - widgets library - Dart API
API docs for the InheritedWidget class from the widgets library, for the Dart programming language.

Something like this:

class ServiceInstances extends InheritedWidget {
  const ServiceInstances({
    Key? key,
    required this.authentication,
    required this.storage,
    required this.hanjaLookupService,
    required this.spellCheckService,
    required this.tokenService,
    required this.sharedPreferences,
    required Widget child,
  }) : super(key: key, child: child);

  final FirebaseAuth authentication;
  final FirebaseStorage storage;
  final HanjaLookupService hanjaLookupService;
  final SpellCheckService spellCheckService;
  final TokenService tokenService;
  final SharedPreferences sharedPreferences;

  static ServiceInstances of(BuildContext context) {
    final result =
        context.dependOnInheritedWidgetOfExactType<ServiceInstances>();
    assert(result != null, 'No ServiceInstances found in context');
    return result!;
  }

  static ServiceInstances ofInitStateContext(BuildContext context) {
    return context
        .getElementForInheritedWidgetOfExactType<ServiceInstances>()!
        .widget as ServiceInstances;
  }
}

In my App, I put this ServiceInstances in the root of the widgets.

class MyApp extends StatelessWidget {
  MyApp(this.sharedPreferences);

  final SharedPreferences sharedPreferences;

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    final FirebaseAuth auth = FirebaseAuth.instance;
    final storage = FirebaseStorage();
    final hanjaLookupService = HanjaLookupService(storageService: storage);
    final client = Client();
    final koreanLemmaService = KoreanLemmaService(client: client);
    final tokenService = TokenService(koreanLemmaService);
    return ServiceInstances(
        authentication: auth,
        storage: storage,
        hanjaLookupService: hanjaLookupService,
        spellCheckService: SpellCheckService(),
        tokenService: tokenService,
        sharedPreferences: sharedPreferences,
        child: MaterialApp(
          title: 'my app',
          theme: ThemeData(
            colorSchemeSeed: Colors.green,
            useMaterial3: true,
          ),
          darkTheme: ThemeData(
            colorSchemeSeed: Colors.green,
            brightness: Brightness.dark,
            useMaterial3: true,
          ),
          home: MyHomePage(),
          navigatorObservers: [
            FirebaseAnalyticsObserver(analytics: analytics),
          ],
        ));
  }
}

And it works relatively well. In components, usage looks like this:

@override
Widget build(BuildContext context) {
  final services = ServiceInstances.of(context);
  final storage = services.storage;
  final auth = services.auth;
  ...
}

The issue became apparent when migrating to null-safety. With null-safety, when I instantiate ServiceInstances, I have to pass an implementation of every single service. It's ok when running the actual app since it does need every service, but it becomes cumbersome in tests. I had to make some mocks and pass them just so that it compiles.

class EmptyMockHanjaLookupService extends Mock implements HanjaLookupService {}

class EmptyMockSpellCheckService extends Mock implements SpellCheckService {}

class EmptyMockTokenService extends Mock implements TokenService {}
Empty Mocks
await tester.pumpWidget(ServiceInstances(
    authentication: EmptyMockFirebaseAuth(),
    spellCheckService: EmptyMockSpellCheckService(),
    tokenService: EmptyMockTokenService(),
    sharedPreferences: EmptyMockSharedPreferences(),
    storage: storage,
    hanjaLookupService: hanjaLookupService,
    child: MaterialApp(
        home: Scaffold(
      body: widget,
    ))));
In this example, only storage and hanjaLookupService are actually used.

Another alternative would be to make ServiceInstances store nullable values. But then every component that uses the service they need will have to check for null.

Yet another alternative would be to create a separate InheritedWidget for each service, but that would multiply the boilerplate methods of and ofInitStateContext.

Provider

The Provider package is built on top of InheritedWidget but provides a much cleaner API. So I can now easily individually provide non-null service instances. On top of removing the boilerplate code in ServiceInstances, it simplifies the tests, since they can now provide only what the components need.

Now providing the instances for the main app looks like this:

return Provider(
    create: (_) => auth,
    child: Provider(
      create: (_) => tokenService,
      child: Provider(
        create: (_) => hanjaLookupService,
        child: Provider(
          create: (_) => sharedPreferences,
          child: Provider(
            create: (_) => SpellCheckService(),
            child: Provider(
                create: (_) => storage,
                child: MaterialApp(
                  title: 'my app',
                  theme: ThemeData(
                    colorSchemeSeed: Colors.green,
                    useMaterial3: true,
                  ),
                  darkTheme: ThemeData(
                    colorSchemeSeed: Colors.green,
                    brightness: Brightness.dark,
                    useMaterial3: true,
                  ),
                  home: MyHomePage(),
                  navigatorObservers: [
                    FirebaseAnalyticsObserver(analytics: analytics),
                  ],
                )),
          ),
        ),
      ),
    ),
  );
Pyramid hell, but the benefits are worth it.

In the components, usage now looks like this:

@override
Widget build(BuildContext context) {
  final auth = context.read<FirebaseAuth>();
  ...
}

The tests are greatly simplified as we don't need empty mocks anymore. We simply do not provide them:

await tester.pumpWidget(Provider<HanjaLookupService>(
  create: (_) => FakeHanjaLookupService(),
  child: Provider(
      create: (_) => storage,
      child: MaterialApp(
          home: Scaffold(
        body: widget,
      ))),
));
This widget in question only needs 2 provided services

One thing to note on the snippet above is that since I pass a FakeHanjaLookupService, if I provide it without specifying what I am providing, Provider will provide a value for the type FakeHanjaLookupService, but none for the type HanjaLookupService. So when the test runs, it will throw an exception saying that there was no provided value. Hence I have to explicitly pass the type which I am providing.