1

Flutter TDD Clean Architecture Course [13] – Dependency Injection

We have all of the individual pieces of the app architecture in place. Before we can utilize them by building a UI though, we have to connect them together. Since every class is decoupled from its dependencies by accepting them through the constructor, we somehow have to pass them in.

We've been doing this all along in tests with the mocked classes. Now, however, comes the time to pass in real production classes using a service locator.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

Injecting Dependencies

Almost every class we created in this course until now has some dependencies. Even in a small app like the Number Trivia App we're building, there's quite a lot to configure. There are multiple service locators suitable for Flutter and as you may remember from the 2nd part, we're using get_it.

We will now go over all the classes from the top of the "call flow" downward. In our case, this means starting from the NumberTriviaBloc and ending with the external dependencies like the SharedPreferences.

Setting up a service locator is the easiest when going over the classes following the call flow.

Let's set everything up in a new file called injection_container.dart located directly inside the root lib folder. In essence, we're going to fill in the constructors with appropriate arguments. The basic structure of the file is the following:

injection_container.dart

final sl = GetIt.instance;

void init() {
  //! Features - Number Trivia

  //! Core

  //! External

}
If your app has multiple features, you might want to create smaller injection_container files with init() functions per every feature just to keep things organized.
You'd then call these feature-specific init() functions from within the main one.

The init() function will be called immediately when the app starts from main.dart. It will be inside that function where all the classes and contracts will be registered and subseqently also injected using the singleton instance of GetIt stored inside sl (that's short for a service locator).

The get_it package supports creating singletons and instance factories. Since we're not holding any state inside any of the classes, we're going to register everything as a singleton, which means that only one instance of a class will be created per the app's lifetime. There will be only one exception to this rule - the NumberTriviaBloc which, following the "call flow", we're going to register first.

Registering a Factory

The registration process is very straightforward.  Just instantiate the class as usual and pass in sl() into every constructor parameter. As you can see, the GetIt class has the call() method to make for an easier syntax, very much like our use cases have a call() method too.

injection_container.dart

//! Features - Number Trivia
//Bloc
sl.registerFactory(
  () => NumberTriviaBloc(
    concrete: sl(),
    random: sl(),
    inputConverter: sl(),
  ),
);
Presentation logic holders such as Bloc shouldn't be registered as singletons. They are very close to the UI and if your app has multiple pages between which you navigate, you probably want to do some cleanup (like closing Streams of a Bloc) from the dispose() method of a StatefulWidget.

Having a singleton for classes with this kind of a disposal would lead to trying to use a presentation logic holder (such as Bloc) with closed Streams, instead of creating a new instance with opened Streams whenever you'd try to get an object of that type from GetIt.

Using type inference, the call to sl() will determine which object it should pass as the given constructor argument. Of course, this is only possible when the type in question is also registered. It's apparent that we now need to register the GetConcreteNumberTrivia and GetRandomNumberTrivia use cases and also the InputConverter. These will not be registered as factories, instead, they will be singletons.​​​​

Registering Singletons

GetIt gives us two options when it comes to singletons. We can either registerSingleton or registerLazySingleton. The only difference between them is that a non-lazy singleton is always registered immediately after the app starts, while a lazy singleton is registered only when it's requested as a dependency for some other class.

In our case, choosing between lazy and regular registration doesn't make a difference, since the Number Trivia App will have only one page with one Bloc and one "dependency tree", meaning that even the lazy singletons will be registered immediately. We're going to opt in for registerLazySingleton.

Bloc Dependencies

To keep registrations organized, everything goes under a special comment. Creating separate functions is also possible, but I feel like that can actually worsen the readability of the code if you have just a few registrations as we do.

injection_container.dart

//! Features - Number Trivia
...
// Use cases
sl.registerLazySingleton(() => GetConcreteNumberTrivia(sl()));
sl.registerLazySingleton(() => GetRandomNumberTrivia(sl()));

//! Core
sl.registerLazySingleton(() => InputConverter());

Repository Registration

While InputConverter is a stand-alone class, both of the use cases require a NumberTriviaRepository. Notice that they depend on the contract and not on the concrete implementation. However, we cannot instantiate a contract (which is an abstract class). Instead, we have to instantiate the implementation of the repository. This is possible by specifying a type parameter on the registerLazySingleton method.

injection_container.dart

//! Features - Number Trivia
...
// Repository
sl.registerLazySingleton<NumberTriviaRepository>(
  () => NumberTriviaRepositoryImpl(
    remoteDataSource: sl(),
    localDataSource: sl(),
    networkInfo: sl(),
  ),
);
This nicely demonstrates the usefulness of loose coupling. Depending on abstractions instead of implementations not only allows for testing (we were passing around mocks in tests all along!), but it also allows for painlessly swapping the NumberTriviaRepository's underlying implementation for a different one without any changes to the dependent classes.

Data Sources & NetworkInfo

Repository also depends on contracts, so we're again going to specify a type parameter manually.​​

injection_container.dart

//! Features - Number Trivia
...
// Data sources
sl.registerLazySingleton<NumberTriviaRemoteDataSource>(
  () => NumberTriviaRemoteDataSourceImpl(client: sl()),
);

sl.registerLazySingleton<NumberTriviaLocalDataSource>(
  () => NumberTriviaLocalDataSourceImpl(sharedPreferences: sl()),
);

//! Core
...
sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl(sl()));

External Dependencies

We've moved all the way down the call chain into the realm of 3rd party libraries. We need to register a http.Client, DataConnectionChecker and also SharedPreferences. The last one is a little tricky.

Unlike all of the other classes, SharedPreferences cannot be simply instantiated with a regular constructor call. Instead, we have to call SharedPreferences.getInstance() which is an asynchronous method! You might think that we can simply do this:

sl.registerLazySingleton(() async => await SharedPreferences.getInstance());

The higher-order function, however, would in this case return a Future<SharedPreferences>, which is not what we want. We want to register a simple instance of SharedPreferences instead.

For that, we need to await the call to getInstance() outside of the registration. This will require us to change the init() method signature

injection_container.dart

Future<void> init() async {
  ...
  //! External
  final sharedPreferences = await SharedPreferences.getInstance();
  sl.registerLazySingleton(() => sharedPreferences);
  sl.registerLazySingleton(() => http.Client());
  sl.registerLazySingleton(() => DataConnectionChecker());
}

Initializing

The init() method won't just get magically called by itself. It's our responsibility to invoke it and the best place for this service locator initialization is inside the main() function.

main.dart

import 'injection_container.dart' as di;

void main() async {
  await di.init();
  runApp(MyApp());
}
It's important to await the Future even though it only contains void. We definitely don't want the UI to be built up before any of the dependencies had a chance to be registered.​​

Coming Up Next

Dependency injection was the missing link between the production code and test code, where we sort of injected the dependencies manually with mocked classes. Now that we've implemented the service locator, nothing can stop us from writing Flutter widgets which will utilize every bit of code we've written until now by showing a fully functional UI to the user.

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • mzaink says:

    I think there is a small issue in this approach.
    In my understanding, unless your next set of lines doesn’t depend on objects returned from your await statement, those lines are simply executed. This would mean the runApp() is called as is without worrying about the await statement (which is what you don’t want!).

  • >