Flutter Dependency Injection: Managing Dependencies with get_it and Beyond
If you've been building Flutter apps for a while, you've probably noticed that as your app grows, managing dependencies between classes becomes increasingly complex. You might find yourself passing services through multiple constructors, creating singletons manually, or struggling to test your code because everything is tightly coupled.
This is where dependency injection (DI) comes in. Dependency injection is a design pattern that helps you write cleaner, more testable, and more maintainable code. In this article, we'll explore how to implement dependency injection in Flutter using the popular get_it package, and we'll also look at alternative approaches.
What is Dependency Injection?
At its core, dependency injection is about providing objects with their dependencies rather than having them create those dependencies themselves. Instead of a class creating its own dependencies (which creates tight coupling), dependencies are "injected" from the outside.
Let's look at a simple example to illustrate the problem:
class UserService {
final ApiClient apiClient = ApiClient(); // Tight coupling!
Future<User> getUser(String id) {
return apiClient.fetchUser(id);
}
}
In this example, UserService is tightly coupled to ApiClient. This makes testing difficult because you can't easily replace ApiClient with a mock version. It also means UserService is responsible for creating its dependencies, which violates the Single Responsibility Principle.
With dependency injection, we'd write it like this:
class UserService {
final ApiClient apiClient;
UserService(this.apiClient); // Dependency injected via constructor
Future<User> getUser(String id) {
return apiClient.fetchUser(id);
}
}
Now UserService receives its dependency from outside, making it much easier to test and maintain.
Why Use a Dependency Injection Container?
While constructor injection is great, manually passing dependencies through multiple layers can become tedious. Imagine you have a widget that needs a service, which needs another service, which needs yet another service. You'd end up with code like this:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final apiClient = ApiClient();
final authService = AuthService(apiClient);
final userService = UserService(apiClient, authService);
final orderService = OrderService(apiClient, userService);
return OrderScreen(orderService: orderService);
}
}
This is where a dependency injection container comes in handy. A DI container is a central registry that manages the creation and lifetime of your dependencies. You register your services once, and the container handles creating and providing them wherever they're needed.
Getting Started with get_it
get_it is one of the most popular dependency injection packages in Flutter. It's simple, lightweight, and doesn't require code generation. Let's see how to use it.
First, add get_it to your pubspec.yaml:
dependencies:
get_it: ^7.6.4
Now, let's set up a service locator. It's common practice to create a separate file for this:
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setupServiceLocator() {
// Register a singleton
getIt.registerSingleton<ApiClient>(ApiClient());
// Register a factory (creates a new instance each time)
getIt.registerFactory<UserService>(() => UserService(getIt()));
// Register a lazy singleton (created only when first accessed)
getIt.registerLazySingleton<AuthService>(() => AuthService(getIt()));
}
Call setupServiceLocator() in your main() function before running your app:
void main() {
setupServiceLocator();
runApp(MyApp());
}
Now you can access your dependencies anywhere in your app:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userService = getIt<UserService>();
return FutureBuilder<User>(
future: userService.getUser('123'),
builder: (context, snapshot) {
// Build your UI
},
);
}
}
Understanding Registration Types
get_it offers three main registration types, each serving different purposes:
1. Singleton
A singleton is created immediately when registered and the same instance is returned every time it's requested. Use this for services that should have a single instance throughout your app's lifetime:
getIt.registerSingleton<ApiClient>(ApiClient());
2. Lazy Singleton
A lazy singleton is created only when first accessed, but then the same instance is returned for all subsequent requests. This is useful for expensive services that might not always be needed:
getIt.registerLazySingleton<DatabaseService>(
() => DatabaseService.initialize(),
);
3. Factory
A factory creates a new instance every time it's requested. Use this for services that should have a new instance for each use:
getIt.registerFactory<OrderService>(
() => OrderService(getIt()),
);
Registering Dependencies with Parameters
Sometimes you need to register dependencies that require parameters. get_it supports this through factory functions:
getIt.registerFactoryParam<OrderService, String, void>(
(userId, _) => OrderService(getIt(), userId: userId),
);
// Later, when accessing:
final orderService = getIt<OrderService>(param1: 'user123');
For more complex scenarios, you can use registerFactoryAsync for asynchronous initialization:
getIt.registerFactoryAsync<DatabaseService>(
() async => await DatabaseService.initialize(),
);
// Access it asynchronously:
final dbService = await getIt.getAsync<DatabaseService>();
Dependency Injection in Practice
Let's build a complete example that demonstrates dependency injection in a real-world scenario. We'll create a simple app that fetches and displays user data:
// api_client.dart
class ApiClient {
Future<Map<String, dynamic>> get(String url) async {
// Simulated API call
await Future.delayed(Duration(seconds: 1));
return {'id': '123', 'name': 'John Doe', 'email': 'john@example.com'};
}
}
// user_repository.dart
class UserRepository {
final ApiClient apiClient;
UserRepository(this.apiClient);
Future<User> getUser(String id) async {
final data = await apiClient.get('/users/$id');
return User.fromJson(data);
}
}
// user_service.dart
class UserService {
final UserRepository userRepository;
UserService(this.userRepository);
Future<User> getUser(String id) {
return userRepository.getUser(id);
}
}
// service_locator.dart
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setupServiceLocator() {
getIt.registerLazySingleton<ApiClient>(() => ApiClient());
getIt.registerLazySingleton<UserRepository>(
() => UserRepository(getIt()),
);
getIt.registerLazySingleton<UserService>(
() => UserService(getIt()),
);
}
// user_screen.dart
class UserScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userService = getIt<UserService>();
return FutureBuilder<User>(
future: userService.getUser('123'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final user = snapshot.data!;
return Column(
children: [
Text('Name: ${user.name}'),
Text('Email: ${user.email}'),
],
);
},
);
}
}
Testing with Dependency Injection
One of the biggest advantages of dependency injection is how much easier it makes testing. You can easily replace real dependencies with mocks:
// test/user_service_test.dart
void main() {
late MockUserRepository mockRepository;
late UserService userService;
setUp(() {
mockRepository = MockUserRepository();
userService = UserService(mockRepository);
});
test('getUser returns user from repository', () async {
// Arrange
final expectedUser = User(id: '123', name: 'Test User');
when(mockRepository.getUser('123')).thenAnswer((_) async => expectedUser);
// Act
final result = await userService.getUser('123');
// Assert
expect(result, equals(expectedUser));
verify(mockRepository.getUser('123')).called(1);
});
}
You can also reset get_it between tests to ensure clean state:
void main() {
setUp(() {
getIt.reset();
setupServiceLocator();
});
tearDown(() {
getIt.reset();
});
// Your tests here
}
Alternative Approaches
While get_it is popular, there are other dependency injection solutions in Flutter:
1. InheritedWidget (Built-in)
Flutter's built-in InheritedWidget can be used for dependency injection, though it's more verbose:
class ServiceProvider extends InheritedWidget {
final UserService userService;
const ServiceProvider({
Key? key,
required this.userService,
required Widget child,
}) : super(key: key, child: child);
static UserService of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ServiceProvider>()!.userService;
}
@override
bool updateShouldNotify(ServiceProvider oldWidget) {
return userService != oldWidget.userService;
}
}
2. injectable (Code Generation)
If you prefer code generation, injectable works well with get_it and reduces boilerplate:
@injectable
class UserService {
final UserRepository repository;
UserService(this.repository);
}
@module
abstract class RegisterModule {
@lazySingleton
ApiClient get apiClient => ApiClient();
}
3. Provider
While Provider is primarily a state management solution, it can also be used for dependency injection:
MultiProvider(
providers: [
Provider<ApiClient>(create: (_) => ApiClient()),
ProxyProvider<ApiClient, UserService>(
create: (context) => UserService(context.read<ApiClient>()),
),
],
child: MyApp(),
)
Best Practices
Here are some tips to get the most out of dependency injection in Flutter:
- Register dependencies early: Set up your service locator in
main()before running your app. - Use lazy singletons for expensive operations: They're created only when needed, improving startup time.
- Keep registration logic organized: Group related registrations together or use modules.
- Prefer constructor injection: It makes dependencies explicit and easier to test.
- Don't overuse singletons: Only use singletons when you truly need a single instance.
- Reset in tests: Always reset your service locator between tests to avoid state leakage.
Common Pitfalls to Avoid
As you start using dependency injection, watch out for these common mistakes:
- Circular dependencies: If Service A depends on Service B, and Service B depends on Service A, you'll run into issues. Redesign your architecture to break the cycle.
- Registering too early: Don't register dependencies that depend on Flutter context before the app is fully initialized.
- Forgetting to reset in tests: Always reset your service locator in test teardown to ensure clean state.
- Over-injecting: Not everything needs to be injected. Simple value objects or utilities don't need DI.
Conclusion
Dependency injection is a powerful pattern that can significantly improve the quality of your Flutter code. By using get_it or similar solutions, you can write more testable, maintainable, and scalable applications. Start small, register your services gradually, and you'll soon see the benefits in your codebase.
Remember, the goal isn't to inject everything—it's to inject the right things at the right time. With practice, you'll develop a sense for when dependency injection adds value and when it's unnecessary complexity.
Happy coding, and may your dependencies always be properly injected!