DI: constructor injection, Riverpod and get_it compared
Dependency injection is a top-priority item in the official recommendations, and the reason is concrete: avoiding globally accessible objects. A global object (a singleton, a static field) creates two problems — it cannot be replaced in tests, and there is no way to trace who mutated it.
DI has two independent parts, and it matters not to conflate them:
1. Accepting dependencies (constructor injection). A class receives what it needs from outside and keeps it private:
ProductRepositoryRemote({required ProductApiClient apiClient}) : _apiClient = apiClient;
This does not depend on any DI tool — it is plain Dart. The official case study uses exactly this and adds a note: keep the dependencies private so the view cannot call them directly.
2. Wiring the graph. The place that decides who passes what to whom. This is where the tool choice starts: the official guide uses package:provider, this branch's stack is Riverpod, and community templates favour get_it plus injectable.
An important consequence: the first part never changes. When the tool changes, only the files that wire the graph change; repositories, services and use cases are untouched.
// ══ Bu hissə HƏR ÜÇ halda eynidir ══
class ProductApiClient {
ProductApiClient({required http.Client client}) : _client = client;
final http.Client _client; // private
}
class ProductRepositoryRemote implements ProductRepository {
ProductRepositoryRemote({required ProductApiClient apiClient})
: _apiClient = apiClient;
final ProductApiClient _apiClient; // private
}
// ══════ 1. RIVERPOD (bu branch-ın stack-i) ══════
// Qraf provider-lərin bir-birini `watch` etməsi ilə qurulur.
@riverpod
http.Client httpClient(Ref ref) {
final client = http.Client();
ref.onDispose(client.close); // təmizləmə də burada
return client;
}
@riverpod
ProductApiClient productApiClient(Ref ref) =>
ProductApiClient(client: ref.watch(httpClientProvider));
@riverpod
ProductRepository productRepository(Ref ref) =>
ProductRepositoryRemote(apiClient: ref.watch(productApiClientProvider));
// Testdə / demo rejimində:
// ProviderScope(overrides: [
// productRepositoryProvider.overrideWithValue(FakeProductRepository([])),
// ])
// ══════ 2. PROVIDER (rəsmi bələdçinin üsulu) ══════
// Qraf widget ağacının kökündə qurulur.
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (_) => http.Client()),
Provider(create: (context) =>
ProductApiClient(client: context.read())),
// Abstract tipə cast: istifadə yerləri müqaviləni görür.
Provider<ProductRepository>(
create: (context) =>
ProductRepositoryRemote(apiClient: context.read()),
),
],
child: const MyApp(),
),
);
}
// ══════ 3. GET_IT (service locator) ══════
// Qraf ayrı funksiyada, tətbiq başlamazdan əvvəl qurulur.
final getIt = GetIt.instance;
void configureDependencies() {
getIt.registerLazySingleton<http.Client>(() => http.Client());
getIt.registerLazySingleton<ProductApiClient>(
() => ProductApiClient(client: getIt<http.Client>()),
);
getIt.registerLazySingleton<ProductRepository>(
() => ProductRepositoryRemote(apiClient: getIt<ProductApiClient>()),
);
}
void main() {
configureDependencies();
runApp(const MyApp());
}
// İstifadə: getIt<ProductRepository>()
// Testdə: getIt.reset() + yenidən qeydiyyat
// ── Ən vacib müşahidə ──
// Yuxarıdaki üç blokdan hansını seçsən, `ProductRepositoryRemote`
// sinfinin kodu DƏYİŞMİR. Bu, DI-ın düzgün qurulduğunun əlamətidir.The same dependency graph with three tools. Note that the repository and service code is identical in all three — only the wiring differs.
| Criterion | Riverpod | provider (official) | get_it (+ injectable) |
|---|---|---|---|
| How a dependency is located | `ref.watch/read` — type-safe | `context.read` — needs the widget tree | `getIt<T>()` — from anywhere, no context needed |
| An unregistered type | A compile error (the provider must exist) | A runtime error | A runtime error |
| Lifetime management | `ref.onDispose`, autoDispose | Tied to the widget tree | Manual (`registerLazySingleton`, `reset`) |
| Replacing in tests | `overrides` — isolated per test | Swapping a provider in the widget tree | `getIt.reset()` — global state, risk of leaking between tests |
| Relation to state management | The same tool (providers do DI and state) | The same tool | Separate — you still need something for state |
| Dependence on the widget tree | None (works in plain Dart via `ProviderContainer`) | Yes | None |
Service locator vs injection: the real difference. get_it is a service locator: the class itself calls getIt<ProductRepository>() and finds its dependency. With constructor injection the dependency is given to the class.
The difference is practical:
- With a service locator the class's signature hides its dependencies: you cannot tell what it needs from the constructor; you have to hunt for
getItcalls in the body. - In tests a service locator is global state: if one test changes a registration, it leaks into the next. That is why
getIt.reset()discipline insetUpis required.
The best practice: even when using `get_it`, classes should accept dependencies in their constructor. getIt is called only where the graph is wired (the registration function), never inside a class. The injectable package automates exactly that — generating registration code from annotations.
A trap in Riverpod. Providers are global variables, which raises "isn't that global state?" The answer: the provider's declaration is global, but its value belongs to a ProviderContainer/ProviderScope. So each test creates its own container and isolation is guaranteed — that is the key difference from getIt.
When `get_it`? Two real scenarios: (1) the project uses BLoC for state management and needs a separate DI tool; (2) DI is needed outside Flutter too (a shared Dart package, a CLI). If you already use Riverpod, adding a second DI tool is extra complexity.
Keep dependencies private. The official case study states this explicitly: in the repository, final ProductApiClient _apiClient; — with the underscore. The reason: a view that has access to the view model must not be able to call the repository's methods directly. A public field punches a hole in the layering.
Practice. Gather your DI graph in one place: wire the httpClient → apiClient → repository chain with providers (or audit the existing setup). Then check two things:
1. Does every repository/service class accept its dependencies in the constructor and keep them private?
2. grep -rn "GetIt\|getIt<" lib — the results should point only at the registration file, never at class bodies.
Done means: overriding one provider in a test replaces the entire data layer, and no class calls getIt from inside itself.
📚 Sources and documentation
- Case study: dependency injectionofficialdocs.flutter.dev
The official approach: wiring the graph with `MultiProvider`, casting to the abstract type, and keeping dependencies private.
- Architecture recommendations: DIofficialdocs.flutter.dev
Why DI is a top-priority recommendation: avoiding globally accessible objects.
- The get_it packageofficialpub.dev
`registerSingleton`, `registerLazySingleton`, `registerFactory`, and the service locator concept.
- The injectable packageofficialpub.dev
Generating `get_it` registration code from annotations.