Dependency injection and testability
State management and DI are often conflated, yet they answer different questions:
- DI — who supplies an object's dependencies (how a repository reaches a view model).
- State management — how a change reaches the UI.
Flutter's official architecture case study takes a clear position on DI: in the Compass app, dependency injection is handled using `package:provider`; based on their experience building Flutter apps, teams at Google recommend using `package:provider` to implement dependency injection.
The core pattern is constructor injection: services are injected into repositories, repositories into view models, and the injected dependencies are kept private so the view cannot bypass the view model.
| Approach | How it works | Testing |
|---|---|---|
| `package:provider` (the official case study's choice) | `MultiProvider` at the root; passed into constructors with `context.read()` | The tree is rebuilt by hand in widget tests |
| Riverpod | Providers read each other with `ref.watch` | `overrides` replaces any dependency in one line |
| `RepositoryProvider` (flutter_bloc) | package:provider inside; injected into blocs with `context.read()` | A fake via `RepositoryProvider.value` in widget tests |
| `get_it` (service locator) | A global registry: `GetIt.I<Repo>()`; no `BuildContext` needed | The registry must be reset between tests or state leaks |
// Service repository-yə konstruktordan verilir.
class BookingRepository {
BookingRepository({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient; // private: kənardan görünmür
}
// View model repository-ni konstruktordan alır və private saxlayır,
// belə ki, view onun üzərindən keçib repository-yə müraciət edə bilmir.
class HomeViewModel extends ChangeNotifier {
HomeViewModel({required BookingRepository bookingRepository})
: _bookingRepository = bookingRepository;
final BookingRepository _bookingRepository;
}
// Kökdə qurulma (case study-nin nümunəsi ilə eyni struktur).
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
Provider(
create: (context) => BookingRepository(apiClient: context.read()),
),
],
child: const MainApp(),
),
);
}The official case study's style: constructor injection with private fields.
// Provider: ağacı testdə əl ilə qurmaq.
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<BookingRepository>.value(value: FakeBookingRepository()),
],
child: const MaterialApp(home: HomeScreen()),
),
);
// Riverpod: bir sətirlik override.
await tester.pumpWidget(
ProviderScope(
overrides: [
bookingRepositoryProvider.overrideWithValue(FakeBookingRepository()),
],
child: const MaterialApp(home: HomeScreen()),
),
);
// flutter_bloc: repository fake, bloc real.
await tester.pumpWidget(
RepositoryProvider<BookingRepository>.value(
value: FakeBookingRepository(),
child: MaterialApp(
home: BlocProvider(
create: (context) => BookingBloc(context.read()),
child: const HomeScreen(),
),
),
),
);Replacing the same dependency in a test — three libraries, three ways.
The main risk of a service locator such as get_it is state leaking between tests: the registry is global, so every test must call reset, and forgetting it makes failures depend on test order. The second risk is that dependencies become invisible: you cannot tell what a class needs from its constructor, because the dependency is fetched inside a method with GetIt.I<T>(). So I use a locator only where BuildContext is not available (a background isolate, an entry point) and keep constructor injection in the main flow.
📚 Sources and documentation
- Case study: dependency injectionofficialdocs.flutter.dev
The Google teams' package:provider recommendation and the constructor-injection examples.
- Flutter architecture guideofficialdocs.flutter.dev
- Riverpod: overridesofficialriverpod.dev
- package:get_itofficialpub.dev
The service locator approach — for cases that need access without a BuildContext.