Injectable – Flutter & Dart Equivalent to Dagger & Angular Dependency Injection

Dependency injection is necessary if you're not coding spaghetti 🍝 and you want to keep nice layers of separation in your Flutter app's codebase. The problem is that all of the libraries out there, such as get_it or kiwi, are just service locators with no support or a limited support for automating the registration of dependencies.

Dagger solves it elegantly for native Android and Angular is also known for its powerful dependency injection framework. Now, we Flutter developers can finally use something similar - the injectable package which is a code generator for get_it.

Starter project we all love

Yes, it's true. We're going to build yet another counter app BUT this one will be special. We won't always increment the counter by one (1), instead, based on whether we're in development environment or a production environment, the counter is going to get incremented by different values. Where are we going to get the increment values from, you ask? Well, from ICounterRepository, of course!

Starter project file structure

In order to keep our code testable, CounterChangeNotifier depends on an abstract class ICounterRepository which is then in turn implemented by CounterRepository (production) and DevCounterRepository.

counter_change_notifier.dart

class CounterChangeNotifier extends ChangeNotifier {
  final ICounterRepository _counterRepository;

  CounterChangeNotifier(this._counterRepository);

  int _value = 0;
  int get value => _value;

  void increment() {
    _value += _counterRepository.getIncrement();
    notifyListeners();
  }
}

The problem with the starter project is that we're doing all the injection manually inside main.dart. I mean, we don't even use get_it and instead we populate the constructors completely by ourselves. 

main.dart

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Material App',
      home: ChangeNotifierProvider(
        // Manually passing in the production repository
        create: (_) => CounterChangeNotifier(CounterRepository()),
        child: CounterPage(),
      ),
    );
  }
}

While using get_it in its plain form would help with maintainability, it surely wouldn't cut down on the boilerplate... That's precisely the reason why injectable is so awesome!

This tutorial assumes that you're at least a bit familiar with get_it or other service locators in general.

Setting up injectable

The first step is obviously adding all of the packages to pubspec.yaml. In addition to injectable and its generator, we're also going to add build_runner and get_it.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  provider: ^4.0.2
  injectable: ^0.1.0
  get_it: ^3.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner:
  injectable_generator: ^0.1.0

There's really not much actual setup code to write. Apart from annotating injectable classes, we're going to create only one setup function configureInjection which will call a function generated by the library called $initGetIt.

injection.dart

import 'package:injectable/injectable.dart';
import 'package:injectable_prep/injection.iconfig.dart';

@injectableInit
void configureInjection(String environment) =>
    $initGetIt(environment: environment);

abstract class Env {
  static const dev = 'dev';
  static const prod = 'prod';
}
The environment string is what allows us to easily manage between multiple implementations of ICounterRepository.  While can be any kind of a String, I find that creating constants is better than passing around String literals.
We created our own constants for the environment strings, injectable also comes with some predefined constants that you can use if you'd like to. Check out this file for more info.

The @injectableInit annotation is very important as this is what triggers code generation. In our case, the generated code will be located at injection.iconfig.dart. After running everyone's favorite command:

πŸ‘¨β€πŸ’» terminal

flutter pub run build_runner watch --delete-conflicting-outputs

...we can go to main.dart and call configureInjection(Env.prod) from the main function. Of course, this won't do anything meaningful yet as we haven't registered any classes with injectable.

main.dart

void main() {
  configureInjection(Env.prod);
  runApp(MyApp());
}
To change the injection environment in this simple app, you need to modify the single main function to pass in, for example, Env.dev.

Annotating the classes

This step is what you probably came here for - you don't want to register factories and singletons manually but instead let code gen do the job for you in a fraction of the time.

Let's start off at the ICounterRepository. We want to mark it as @injectable and also bind it to specific implementations based on the current environment with @Bind.toType.

i_counter_repository.dart

@Bind.toType(DevCounterRepository, env: Env.dev)
@Bind.toType(CounterRepository, env: Env.prod)
@injectable
abstract class ICounterRepository {
  int getIncrement();
}
In addition to @injectable which generates a factory, the library also provides @singleton and @lazySingleton annotations.

Our journey with the repository doesn't end here though. We also need to mark its concrete implementations with @injectable. Doing so will firstly add appropriate import statements to the generated file. Secondly, we'll also be able to resolve dependencies by their concrete types under any environment.

counter_repository.dart

@injectable
class CounterRepository implements ICounterRepository {
  @override
  int getIncrement() => 1;
}

dev_counter_repository.dart

@injectable
class DevCounterRepository implements ICounterRepository {
  @override
  int getIncrement() => 2;
}

Lastly, let's annotate the CounterChangeNotifier. In contrast with the repository, it contains a non-empty constructor. This is where the injectable package shines the most! It will figure out to pass in an argument on its own.

counter_change_notifier.dart

@injectable
class CounterChangeNotifier extends ChangeNotifier {
  final ICounterRepository _counterRepository;

  CounterChangeNotifier(this._counterRepository);

  ...
}

A look at the generated file

Boom πŸ’₯ We literally added a few annotations to our classes and our work is done! Opening up injection.iconfig.dart reveals an image familiar to developers used to get_it. The addition of automatic environment resolving for abstract classes is πŸ”₯πŸ”₯πŸ”₯

injection.iconfig.dart

final getIt = GetIt.instance;
void $initGetIt({String environment}) {
  getIt
    ..registerFactory<CounterChangeNotifier>(
        () => CounterChangeNotifier(getIt<ICounterRepository>()))
    ..registerFactory<CounterRepository>(() => CounterRepository())
    ..registerFactory<DevCounterRepository>(() => DevCounterRepository());
  if (environment == 'dev') {
    _registerDevDependencies();
  }
  if (environment == 'prod') {
    _registerProdDependencies();
  }
}

void _registerDevDependencies() {
  getIt..registerFactory<ICounterRepository>(() => DevCounterRepository());
}

void _registerProdDependencies() {
  getIt..registerFactory<ICounterRepository>(() => CounterRepository());
}

Using get_it to resolve dependencies

Having everything else in place, we just need to get rid of the manual instantiation in main.dart and use the provided GetIt instance instead.

main.dart

void main() {
  configureInjection(Env.prod);
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Material App',
      home: ChangeNotifierProvider(
        create: (_) => getIt<CounterChangeNotifier>(),
        child: CounterPage(),
      ),
    );
  }
}

When we run the app, the counter will be incremented by 1 since we're running under the production environment, as specified by Env.prod. Changing it to Env.dev...

... and then hot restarting will make the counter be incremented by 2.

I know this is just a silly counter app example but you can probably imagine that the production environment would communicate with production APIs containing actual user data while the development environment would operate with something where it's safe to do silly things like deleting everything from the database without having a backup 😎

Bonus: Test environment

Doing test-driven development or just testing after the code has been written (😬) highly benefits from mocks. If you've ever mocked something, you know how time-consuming it is to create mock classes manually. Thankfully injectable and its environments can help even with testing. First, let's add mockito into pubspec.yaml.

pubspec.yaml

dev_dependencies:
  ...
  mockito: ^4.1.1

Add an additional test environment to the Env abstract class.

injection.dart

abstract class Env {
  static const test = 'test';
  static const dev = 'dev';
  static const prod = 'prod';
}

Create a mock_counter_repository.dart file next to all of the other repository implementation files and mark it as @injectable.

mock_counter_repository.dart

@injectable
class MockCounterRepository extends Mock implements ICounterRepository {}

Lastly, bind the abstract ICounterRepository to the MockCounterRepository "implementation" under the test environment.

i_counter_repository.dart

@Bind.toType(MockCounterRepository, env: Env.test)
@Bind.toType(DevCounterRepository, env: Env.dev)
@Bind.toType(CounterRepository, env: Env.prod)
@injectable
abstract class ICounterRepository {
  int getIncrement();
}

Gone are the days of constantly defining mocks throughout multiple test files! Now it's enough to call configureInjection(Env.test); from within the setUpAll callback and you can resolve dependencies just like in your regular non-test code.

example_test.dart

void main() {
  setUpAll(() {
    // Just like in the regular main() function inside main.dart
    configureInjection(Env.test);
  });

  test(
    "should do something",
    () async {
      // arrange
      final mockCounterRepository = getIt<ICounterRepository>();
      when(mockCounterRepository.getIncrement()).thenReturn(123);
      // act
      // TODO: Some action here
      // assert
      verify(mockCounterRepository.getIncrement()).called(10);
    },
  );
}

The amount of boilerplate and maintenance nightmare that's prevented by using the injectable package is totally incredible. You're literally one or two simple annotations away from having a robust dependency injection system working with the battle-tested get_it package in place. Use it and save your time!

About the author 

Matt ReΕ‘etΓ‘r

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time πŸ˜… Yet he still manages to squeeze in tough workouts πŸ’ͺ and guitar 🎸

You may also like

Flutter Firebase & DDD Course [5] – Sign-In Form Logic

Dio Connectivity Retry Interceptor – Flutter Tutorial

  • This helps so much. In one of my app, the function to register services to GetIt is so long that it is becoming difficult to add new or update existing services.

    Reply

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >