Dependency injection in Flutter: Keeping your architecture sane
After 94 days of building with Flutter, you've probably felt the pain of tightly coupled code—services hardcoded everywhere, tests that mock global singletons, and features that can't run in isolation. Dependency injection in Flutter solves this by letting you wire services explicitly, swap them out for tests, and keep your codebase maintainable as it grows. Today we'll explore three patterns and one ecosystem that make DI practical without turning your app into a circus of factories and service locators.
Why dependency injection matters at scale
In small prototypes, reaching for a global service or a static instance feels fine. You need to fetch user data, so you grab UserService.instance.getUser(). When you write tests, you hard-reset that singleton. When you add a second team member or a third product, suddenly you have invisible coupling everywhere—changing one service breaks five features you didn't think were connected.
Dependency injection flips this on its head: instead of services grabbing what they need, you hand them what they need. The payment processor doesn't create its own HTTP client; you give it one. Your login feature doesn't assume a single analytics service; you pass it in. This makes code testable, reusable, and honest about what it depends on.
get_it — A simple service locator
get_it is the most popular DI package in the Flutter ecosystem. It's a service locator: a central registry where you register services once, then retrieve them by type anywhere in your app.
Here's a realistic setup for an e-commerce app:
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
final getIt = GetIt.instance;
void setupServiceLocator() {
// Register a singleton HTTP client
getIt.registerSingleton(http.Client());
// Register repositories that depend on the HTTP client
getIt.registerSingleton(
ProductRepository(client: getIt()),
);
// Register use cases or services
getIt.registerSingleton(
GetProductsUseCase(repository: getIt()),
);
// Lazy singletons don't instantiate until first use
getIt.registerLazySingleton(
() => NotificationService(),
);
}
In your main function or app initialization:
void main() {
setupServiceLocator();
runApp(const MyApp());
}
Then access services anywhere:
class ProductListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final useCase = getIt();
return FutureBuilder(
future: useCase.execute(),
builder: (context, snapshot) {
// UI logic
},
);
}
}
The appeal is simplicity: one line to register, one line to retrieve. The downside is that it's still a global—your widget doesn't declare what it needs, and in tests you must remember to reset getIt between tests. For small to medium teams, it's a solid pragmatic choice. I've used it in ride-hailing and travel apps where the architecture stays fairly stable.
Reset between tests by calling getIt.reset() in your test teardown, or use setUp and tearDown hooks:
void main() {
group('ProductList', () {
setUp(() {
setupServiceLocator();
});
tearDown(() {
getIt.reset();
});
test('displays products on load', () async {
// Your test
});
});
}
Riverpod — DI and state management in one
Riverpod is the modern approach if you want DI as a first-class language feature. It treats dependencies as providers—reactive objects you declare once, then use everywhere. Riverpod handles caching, invalidation, and overrides transparently.
Here's the same e-commerce setup in Riverpod:
import 'package:riverpod/riverpod.dart';
import 'package:http/http.dart' as http;
// Provide an HTTP client
final httpClientProvider = Provider((ref) {
return http.Client();
});
// Provide a repository that depends on the HTTP client
final productRepositoryProvider = Provider((ref) {
final client = ref.watch(httpClientProvider);
return ProductRepository(client: client);
});
// Provide a use case
final getProductsProvider = FutureProvider>((ref) {
final repository = ref.watch(productRepositoryProvider);
return repository.getProducts();
});
In your UI, use ConsumerWidget or the Consumer builder:
class ProductListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(getProductsProvider);
return productsAsync.when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
return ProductTile(product: products[index]);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
);
}
}
Riverpod's superpower is test overrides. You don't reset globals; you override providers for specific tests:
test('displays products', (WidgetTester tester) async {
final mockRepository = MockProductRepository();
await tester.pumpWidget(
ProviderContainer(
overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
],
child: const MyApp(),
).listen(MyApp),
);
expect(find.byType(ProductTile), findsWidgets);
});
This is powerful because overrides are scoped to a single test run—no manual cleanup needed. Riverpod also caches results automatically, invalidates when dependencies change, and integrates seamlessly with Flutter's widget rebuilds. In SaaS platforms and complex trading systems I've built, Riverpod eliminates entire classes of bugs around stale state and dependency ordering.
Constructor injection — Pass dependencies explicitly where possible
Neither get_it nor Riverpod is required. Sometimes the simplest, most explicit approach is best: pass dependencies as constructor arguments.
class LoginService {
final AuthRepository authRepository;
final NotificationService notificationService;
LoginService({
required this.authRepository,
required this.notificationService,
});
Future login(String email, String password) async {
final user = await authRepository.authenticate(email, password);
await notificationService.notify('Welcome, ${user.name}');
}
}
class LoginScreen extends StatefulWidget {
final LoginService loginService;
const LoginScreen({required this.loginService});
@override
State createState() => _LoginScreenState();
}
class _LoginScreenState extends State {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => widget.loginService.login('user@example.com', 'pass'),
child: const Text('Log in'),
);
}
}
This approach has huge advantages: the dependency graph is visible in code, it's trivial to test (just pass a mock), and there are no hidden globals or provider resolution failures at runtime. The downside is that you wire up instances at the root of your app (or use a factory pattern), which can feel verbose for large hierarchies.
In practice, I use constructor injection for core domain classes (use cases, services, repositories) and Riverpod or get_it at the UI layer to avoid passing 10 dependencies down through 5 widget constructors. This hybrid approach scales well.
Test overrides — Replace services with fakes in tests
Good test coverage requires isolating the code you're testing from external dependencies. Here's a pattern that works across all three approaches:
// Fake implementation
class FakeAuthRepository implements AuthRepository {
@override
Future authenticate(String email, String password) async {
return User(id: '123', name: 'Test User', email: email);
}
}
// Using constructor injection
test('LoginService notifies on success', () async {
final authRepo = FakeAuthRepository();
final notificationService = MockNotificationService();
final loginService = LoginService(
authRepository: authRepo,
notificationService: notificationService,
);
await loginService.login('test@example.com', 'password');
verify(notificationService.notify(any)).called(1);
});
With Riverpod, override the entire dependency chain in one shot:
test('ProductList shows 10 items', (WidgetTester tester) async {
final fakeRepository = FakeProductRepository();
await tester.pumpWidget(
ProviderContainer(
overrides: [
productRepositoryProvider.overrideWithValue(fakeRepository),
],
child: const MyApp(),
).listen(MyApp),
);
await tester.pumpAndSettle();
expect(find.byType(ProductTile), findsNWidgets(10));
});
The key principle: inject a fake or mock that implements the same interface, not a real service. This isolates your test, makes it fast, and makes failures obvious.
Common mistakes
- Forgetting to reset get_it in tests. If one test registers
UserServiceas a singleton and the next test tries to register a different version, you'll get a "LateInitializationError" or mysterious behavior. Always reset or use Riverpod's override pattern. - Injecting context or BuildContext as a dependency. This couples your business logic to Flutter's widget framework and makes testing harder. Pass what you need explicitly (e.g., a string for the app name, not the context).
- Creating service instances inside widgets. If you instantiate
UserService()in a widget's build method, it'll recreate on every frame. Use a provider, service locator, or pass it as a final field. - Over-abstracting too early. Not every class needs an interface. If you have one concrete implementation and zero tests mocking it, a concrete class is fine. Add abstractions when you actually have multiple implementations or a test need.
- Circular dependencies in your DI graph. If
ServiceAdepends onServiceBandServiceBdepends onServiceA, your app will crash at startup or during Riverpod initialization. Refactor to break the cycle early.
Key takeaways
- Dependency injection decouples your code, makes testing trivial, and keeps your architecture honest about what depends on what.
- get_it is simple and pragmatic for small to medium codebases; reset it between tests or use lazy singletons.
- Riverpod is the modern choice if you want reactive DI, scoped overrides, and integrated state management; no manual cleanup required.
- Constructor injection is the most explicit and testable approach; use it for core logic, then wire up dependencies at your app's root.
- In tests, always provide fakes or mocks, not real services; this isolates your test and makes assertions meaningful.
- Avoid injecting framework objects like
BuildContext; inject domain data instead. - Choose one pattern per layer of your app and stick with it; mixing all three everywhere leads to confusion.
What's next? Day 95 will cover error handling patterns in Flutter. For now, audit one of your projects: are services global singletons or explicitly wired? Start with get_it or Riverpod, write a test that overrides a service, and feel the relief when your tests no longer interfere with each other.






Comments
Loading comments…