Dependency Injection in Flutter
Dependency injection (DI) makes your code easier to manage, scale and test by reducing tight coupling between modules. In this article, we'll explore how to set up and use DI in a Flutter application without any libraries.
Outline
- What is Dependency Injection
- How to Initialize App Dependencies
- Where to Launch Initialization
- Passing Dependencies Through BuildContext
- Dependency Injection vs Service Locator
What is Dependency Injection
Dependency Injection (DI) is a design pattern in which the dependencies of a class are created externally and injected into the class by its creator. First of all, let's examine what a class that doesn't apply DI looks like:
class Service {
final String data = 'Hello, Dependency Injection!';
}
// **BAD**: Tight Coupling
class ExampleWithoutDI {
final Service service = Service(); // Direct instantiation leads to tight coupling
void printData() {
print(service.data);
}
}In the snippet above, the ExampleWithoutDI class directly creates its service dependency within the class. This approach leads to several problems:
- Hard to test because
servicecan't be replaced with test implementation. - This class now has to manage dependencies lifecycle: initialization and disposal. Problems get even worse when it requires asynchronous computations.
- It's difficult to track where and how modules are initialized and used, increasing the risk of redundant initializations. This can lead to data races, corrupted data, and errors.
- Difficult to maintain and support. Modifying one part often requires changes in others, violating the Single Responsibility Principle (SRP) and Open/Closed Principle (OCP) from SOLID.
To solve the issues mentioned, we can move dependency creation outside the class and pass the necessary dependencies through the constructor. This approach is known as constructor injection:
// **GOOD**: Uses Dependency Injection
class ExampleWithDI {
final Service service;
// Service is injected via the constructor (constructor injection)
ExampleWithDI(this.service);
// Now, ExampleWithDI is not tightly coupled to a specific implementation of Service
// and can be easily mocked or replaced, enhancing testability and flexibility.
void printData() {
print(service.data);
}
}By using Dependency Injection (DI), the class is no longer tightly coupled to its dependencies. It doesn't manage their lifecycle, and it's possible to mock dependencies for testing. Here's a comparison in the tests:
test('ExampleWithoutDI', () {
// this instantiates a class that is tightly coupled to a specific implementation
// we can't test this class without also testing the Service class
final instance = ExampleWithoutDI();
instance.printData(); // prints 'Hello, Dependency Injection!'
});
test('ExampleWithDI', () {
final mockService = MockService();
// register mock with Mockito
when(mockService.data).thenReturn('Hello, Mocked Service!');
final instance = ExampleWithDI(mockService);
instance.printData(); // prints 'Hello, Mocked Service!'
});How to Initialize App Dependencies
One of the main benefits of Dependency Injection (DI) is that it promotes proper dependency initialization. For the beginning, I want to familiarize you with Pure DI and Composition Root originally introduced by Mark Seemann.
Pure DI is the practice of manually composing and injecting dependencies. This means you explicitly pass dependencies through constructors when creating instances of objects:
// This is the Pure DI:
final databaseConnection = DatabaseConnection.open();
final userDatasource = UserDatasource(conn: databaseConnection);
final userRepository = UserRepository(datasource: userDatasource);
final userBloc = UserBloc(repository: userRepository);This approach is pretty straightforward and it doesn’t require any frameworks or libraries – dependencies are injected in a clean and transparent way, via constructors.
Composition Root is a single place where the dependencies needed for the application are created. This makes dependency initialization more straightforward and clearer.
To store the dependencies, we will use the dependencies container (simply put, a class that contains the instances of initialized dependencies):
/// {@template dependencies_container}
/// Composed dependencies from the [CompositionRoot].
///
/// This class contains all the dependencies that are required for the application
/// to work.
///
/// {@macro composition_process}
/// {@endtemplate}
base class DependenciesContainer {
/// {@macro dependencies_container}
const DependenciesContainer({
required this.appSettingsBloc,
required this.errorTrackingManager,
});
/// [AppSettingsBloc] instance, used to manage theme and locale.
final AppSettingsBloc appSettingsBloc;
/// [ErrorTrackingManager] instance, used to report errors.
final ErrorTrackingManager errorTrackingManager;
}The goal of a composition root is to produce a composed dependency container with the dependencies ready to be used by the application. In my architectural template, Sizzle Starter, the Composition Root looks as follows:
/// {@template composition_root}
/// A place where all dependencies are initialized.
/// {@endtemplate}
///
/// {@template composition_process}
/// Composition of dependencies is a process of creating and configuring
/// instances of classes that are required for the application to work.
///
/// It is a good practice to keep all dependencies in one place to make it
/// easier to manage them and to ensure that they are initialized only once.
/// {@endtemplate}
final class CompositionRoot {
/// {@macro composition_root}
const CompositionRoot(this.config, this.logger);
/// Application configuration
final Config config;
/// Logger used to log information during composition process.
final RefinedLogger logger;
/// Composes dependencies and returns result of composition.
Future<CompositionResult> compose() async {
final stopwatch = clock.stopwatch()..start();
logger.info('Initializing dependencies...');
// initialize dependencies
final dependencies = await DependenciesFactory(config, logger).create();
logger.info('Dependencies initialized');
stopwatch.stop();
final result = CompositionResult(
dependencies: dependencies,
msSpent: stopwatch.elapsedMilliseconds,
);
return result;
}
}Dependencies are initialized using Factory and AsyncFactory contracts, which define a single create method that creates a dependency. Here's how it looks like:
/// {@template factory}
/// Factory that creates an instance of [T].
/// {@endtemplate}
abstract class Factory<T> {
/// Creates an instance of [T].
T create();
}
/// {@template async_factory}
/// Factory that creates an instance of [T] asynchronously.
/// {@endtemplate}
abstract class AsyncFactory<T> {
/// Creates an instance of [T].
Future<T> create();
}Below is an example of AsyncFactory, which creates an instance of ErrorTrackingManager from the Sizzle Starter:
/// {@template error_tracking_manager_factory}
/// Factory that creates an instance of [ErrorTrackingManager].
/// {@endtemplate}
class ErrorTrackingManagerFactory extends AsyncFactory<ErrorTrackingManager> {
/// {@macro error_tracking_manager_factory}
ErrorTrackingManagerFactory(this.config, this.logger);
/// Application configuration
final Config config;
/// Logger used to log information during composition process.
final RefinedLogger logger;
@override
Future<ErrorTrackingManager> create() async {
final errorTrackingManager = SentryTrackingManager(
logger,
sentryDsn: config.sentryDsn,
environment: config.environment.value,
);
if (config.enableSentry) {
await errorTrackingManager.enableReporting();
}
return errorTrackingManager;
}
}As you can see, the ErrorTrackingManagerFactory defines two dependencies - config and logger - which are passed via a constructor applying the DI. In the create method, it composes the dependency and potentially enables reporting.
This way, the AsyncFactory can be used to do asynchronous / synchronous work before the dependency can be used by the application. This is extremely useful for things like authentication, so that the user can be loaded from the cache, or for settings like theme and fonts:
/// {@template settings_bloc_factory}
/// Factory that creates an instance of [AppSettingsBloc].
/// {@endtemplate}
class SettingsBlocFactory extends AsyncFactory<AppSettingsBloc> {
/// {@macro settings_bloc_factory}
SettingsBlocFactory(this.sharedPreferences);
/// Shared preferences instance
final SharedPreferencesAsync sharedPreferences;
@override
Future<AppSettingsBloc> create() async {
final appSettingsRepository = AppSettingsRepositoryImpl(
datasource: AppSettingsDatasourceImpl(sharedPreferences: sharedPreferences),
);
final appSettings = await appSettingsRepository.getAppSettings();
final initialState = AppSettingsState.idle(appSettings: appSettings);
return AppSettingsBloc(
appSettingsRepository: appSettingsRepository,
initialState: initialState,
);
}
}SettingsBlocFactory initializes the dependencies needed for the bloc itself, then loads the data from the cache and instantiates a bloc with an initial state so that the application starts with the user settings immediately.
Factories are then used in the root factory, called DependenciesFactory, which composes them into a DependenciesContainer object:
/// {@template dependencies_factory}
/// Factory that creates an instance of [DependenciesContainer].
/// {@endtemplate}
class DependenciesFactory extends AsyncFactory<DependenciesContainer> {
/// {@macro dependencies_factory}
DependenciesFactory(this.config, this.logger);
/// Application configuration
final Config config;
/// Logger used to log information during composition process.
final RefinedLogger logger;
@override
Future<DependenciesContainer> create() async {
final sharedPreferences = SharedPreferencesAsync();
final errorTrackingManager = await ErrorTrackingManagerFactory(config, logger).create();
final settingsBloc = await SettingsBlocFactory(sharedPreferences).create();
return DependenciesContainer(
appSettingsBloc: settingsBloc,
errorTrackingManager: errorTrackingManager,
);
}
}Dependencies that logically belong to the same group (feature) can be organized together in a separate container. Then, instead of adding each dependency to the DependenciesContainer and cluttering it up, you can add only the FeatureContainer:
/// Separate container for feature-specific dependencies.
class FeatureContainer {
const FeatureContainer({
required this.jobsRepository,
required this.salariesRepository,
required this.jobDetailsRepository,
});
/// [JobsRepository] instance, used to fetch jobs.
final JobsRepository jobsRepository;
/// [SalariesRepository] instance, used to fetch salaries.
final SalariesRepository salariesRepository;
/// [JobDetailsRepository] instance, used to fetch job details.
final JobDetailsRepository jobDetailsRepository;
}A significant advantage of this initialization approach is its flexibility. It allows you to selectively initialize only the necessary dependencies or, when needed, the entire dependency graph independently from the composition root. It becomes particularly valuable in scenarios involving background processing (such as tasks managed by workmanager in Flutter) or when working with isolates.
Where to Launch Initialization
Now that we have the CompositionRoot created, we need to run the initialization. It needs to be called from the main function, before the runApp. This gives us the opportunity to init dependencies while the user sees a splash screen. I have created an AppRunner for this purpose:
/// {@template app_runner}
/// A class which is responsible for initialization and running the app.
/// {@endtemplate}
final class AppRunner {
/// {@macro app_runner}
const AppRunner();
/// Start the initialization and in case of success run application
Future<void> initializeAndRun() async {
final binding = WidgetsFlutterBinding.ensureInitialized();
// Preserve splash screen
binding.deferFirstFrame();
// Override logging
FlutterError.onError = logger.logFlutterError;
WidgetsBinding.instance.platformDispatcher.onError = logger.logPlatformDispatcherError;
// Setup bloc observer and transformer
Bloc.observer = AppBlocObserver(logger);
Bloc.transformer = bloc_concurrency.sequential();
const config = Config();
Future<void> initializeAndRun() async {
try {
final result = await CompositionRoot(config, logger).compose();
// Attach this widget to the root of the tree.
runApp(App(result: result));
} catch (e, stackTrace) {
logger.error('Initialization failed', error: e, stackTrace: stackTrace);
runApp(
InitializationFailedApp(
error: e,
stackTrace: stackTrace,
retryInitialization: initializeAndRun,
),
);
} finally {
// Allow rendering
binding.allowFirstFrame();
}
}
// Run the app
await initializeAndRun();
}
}AppRunner is responsible for setting up global listeners and callbacks like onError, observers (like bloc) and getting the config. And of course, it starts the initialization using the CompositionRoot module.
Passing Dependencies Through BuildContext
Once the dependencies are initialized and we have access to the DependenciesContainer, we should call runApp and expose the dependencies to the BuildContext using InheritedWidget:
/// {@template app}
/// [App] is an entry point to the application.
///
/// If a scope doesn't depend on any inherited widget returned by
/// [MaterialApp] or [WidgetsApp], like [Directionality] or [Theme],
/// and it should be available in the whole application, it can be
/// placed here.
/// {@endtemplate}
class App extends StatelessWidget {
/// {@macro app}
const App({required this.result, super.key});
/// The result from the [CompositionRoot].
final CompositionResult result;
@override
Widget build(BuildContext context) => DefaultAssetBundle(
bundle: SentryAssetBundle(),
child: DependenciesScope(
dependencies: result.dependencies, // <- HERE
child: const SettingsScope(
child: WindowSizeScope(
child: MaterialContext(),
),
),
),
);
}The App widget should be put in the root of the widget tree and provided as an argument to runApp. This structure ensures that all widgets within the application can access the initialized dependencies. To retrieve a dependency, use the following syntax:
late final AppSettingsBloc _appSettingsBloc;
@override
void initState() {
_appSettingsBloc = DependenciesScope.of(context).appSettingsBloc;
super.initState();
}As you can see, it is possible to get dependencies only from the BuildContext. This is intended and especially good for architecture, as you won't be able to violate the dependency injection principle (for example, using global variables or static access).
Dependency Injection vs Service Locator

The reason I decided to compare DI to Service Locator is that it is the most popular anti-pattern in the Flutter community (get_it, riverpod). DI and Service Locator are both approaches to managing dependencies, but they differ significantly in their implementation and impacts on code quality. DI generally offers more advantages in terms of maintainability, testability, and adherence to SOLID principles.
DI promotes loose coupling by having dependencies provided to classes rather than created internally. This separation of concerns makes the code more flexible and easier to modify, as dependencies can be swapped without altering the dependent classes. In contrast, service locator often relies on global state, which can make the system harder to understand and maintain.
Testability is a key strength of DI. Dependencies can be easily mocked or stubbed in unit tests, allowing for more isolated and comprehensive testing. Service locator, on the other hand, resolves dependencies at runtime, making it more challenging to set up unit tests with mocked dependencies.
DI improves code readability by explicitly declaring dependencies, usually through constructor parameters or setter methods. This clarity makes it easier to understand what a class needs to function. Service locator, however, often hides dependencies, as they are requested internally rather than being provided explicitly. This can make the code less transparent and harder to reason about.
While both approaches aim to manage dependencies, DI adheres more closely to the Inversion of Control principle. With DI, classes are provided with their dependencies, whereas service locator requires classes to request their dependencies, potentially violating this principle.
One imaginary advantage of service locator is its simplicity in small projects or for developers new to dependency management. However, as projects grow, the benefits of DI become more pronounced. DI provides better compile-time safety, as dependency mismatches are often caught earlier, whereas service locator might only reveal such issues at runtime.
EOF ¯\_(ツ)_/¯