Why does calling GetIt.I<PaymentService>() inside a Flutter view model make it harder to test and reason about than constructor injection, and where does a service locator still fit?
answer
- the constructor stops telling the truth
- missing registration fails at runtime
- global state shared by tests
- locate only at the composition root
- factories that call constructors
basics
~20 sA lookup inside the class hides the dependency, fails only at runtime when nothing is registered, and forces tests to manage a global registry. Keep get_it at the composition root, where registrations call constructors, and inject everything else.
solid answer
~40 sWhen `ParkingSessionViewModel` calls `GetIt.I<PaymentService>()` in a method, its constructor no longer lists what it needs: a reader or test finds out only by running it, and a missing registration shows up as a get_it `StateError` at that call. Tests must register a fake in the shared registry and reset or scope it afterwards, and a test can pass or fail depending on what earlier tests left behind. With constructor injection the dependency is part of the type's signature and a test passes the fake directly. A locator still earns its place at the **composition root**: `registerFactory(() => ParkingSessionViewModel(payments: getIt()))` and route builders that call `getIt<ParkingSessionViewModel>()` keep get_it in a few wiring files while every class stays injectable.
code
dart · 30 linesimport 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
abstract class PaymentService {
Future<void> charge({required int cents});
}
// Before: hidden dependency, found only at runtime.
class ParkingSessionViewModelLocated {
Future<void> endSession({required int minutes}) =>
GetIt.I<PaymentService>().charge(cents: minutes * 5);
}
// After: the constructor states the dependency.
class ParkingSessionViewModel {
ParkingSessionViewModel({required PaymentService payments})
: _payments = payments;
final PaymentService _payments;
Future<void> endSession({required int minutes}) =>
_payments.charge(cents: minutes * 5);
}
// get_it stays at the composition root and calls the constructor.
void registerParking() {
getIt.registerFactory<ParkingSessionViewModel>(
() => ParkingSessionViewModel(payments: getIt<PaymentService>()),
);
}go deeper
Recognise GetIt.I calls inside a class as a hidden dependency and know that a constructor parameter makes it visible.
Explain the runtime StateError and the shared-registry test problems that lookups create.
Refactor toward a composition root: registrations that call constructors, lookups only at feature entry points, and tests without registry setup.
Set the rule for where a locator may be called and weigh its convenience outside the widget tree against the coupling it invites.
## Two ways to get a dependency In **service location**, a class asks a registry for what it needs at the point of use: `GetIt.I<PaymentService>().charge(...)`. In **injection**, the class receives the dependency from outside, usually through its constructor. get_it can serve both styles; the difference is *where* `getIt<T>()` is called. ## What a lookup inside the class costs Take a parking app's `ParkingSessionViewModel.endSession()` that calls `GetIt.I<PaymentService>()`: - **Hidden dependency.** The constructor says it needs nothing. Only reading every method reveals the payment service, so reviewers and new team members miss it. - **Runtime failure.** If the type was never registered, `get` throws a `StateError` when `endSession` runs, possibly deep in a user flow, instead of the compiler rejecting a missing constructor argument. - **Shared test state.** Each test must register a fake in the global registry and reset or scope it afterwards. Forgetting `await getIt.reset()` in `tearDown` makes results depend on test order. - **Wider reach.** Any class can reach any registered object, so layering rules such as 'views never touch services' are enforced only by discipline. Flutter's architecture recommendations prefer dependency injection because it keeps the app free of globally accessible objects. | Question | Lookup inside the class | Constructor injection | |---|---|---| | What does it depend on? | read every method | read the constructor | | Missing dependency | `StateError` at runtime | compile error at the call site | | Unit test setup | register, then reset or pop scope | pass the fake | | Can a view reach a service? | yes, through the registry | only if handed one | ## Where a locator fits A locator is a convenient **composition root**, especially outside the widget tree where provider has no `BuildContext`: 1. `configureDependencies()` registers services, repositories and view model factories, and each registration **calls a constructor**, passing `getIt()` for the arguments. 2. The few places that start a feature, such as go_router route builders or `main()`, ask get_it for the top object: `ParkingScreen(viewModel: getIt<ParkingSessionViewModel>())`. 3. Nothing below those places imports get_it. This keeps get_it's strengths, lazy and async singletons, scopes and a registry reachable from background code, while every class stays constructor-injected and testable without it. get_it's own README lists constructor injection as a testing aid. ## When a lookup inside a class is tolerable - Framework-created objects you cannot construct yourself, where there is no constructor to inject through; even then, look up once and pass the result on. - Small prototypes, knowing the refactor cost grows with every class that does it. - A logging or analytics facade used everywhere, where teams sometimes accept the trade-off; it still needs a registration in tests. ## A migration path 1. Add constructor parameters for each dependency a class looks up. 2. Change its get_it registration to pass `getIt()` into the constructor. 3. Delete the lookups from the class body. 4. Simplify the class's tests to pass fakes directly, dropping registry setup. ## How the choice shows in a code review - A view model or repository file that imports `package:get_it/get_it.dart` is usually a hidden lookup. - A test file that registers fakes for a class it constructs itself is paying for a lookup the class should not make. - A registration closure that calls a constructor with `getIt()` arguments is the healthy pattern.
- Does using get_it only at the composition root lose its scopes and lazy singletons?No. Lifetimes are set at registration, so lazy singletons, async singletons and scopes work as before. Only the place where `getIt<T>()` is called changes: registrations and feature entry points instead of class bodies.
- How would you find the hidden lookups in an existing codebase?Search for `GetIt.I`, `GetIt.instance` and the app's `getIt` variable outside the DI setup files and route builders. Each hit inside a view model, repository or service is a candidate for a constructor parameter.
Constructor injection is a technician who receives a toolbox when hired: you can see what is in it, and a trainee can be handed practice tools. A locator lookup is the technician phoning the stores room mid-job: nothing shows which tools the job needs, and if the stores room lacks one, the failure happens mid-job rather than at hiring.
saying these in an interview costs you the question
- A get_it lookup inside a class is checked by the compiler like a constructor argument.
- Service location and constructor injection are equally easy to unit test.
- Using get_it at all means classes must call GetIt.I themselves.
- Constructor injection prevents using lazy singletons or scopes.
- Global lookups make layering rules enforce themselves.