In this article:
- Why a global service locator (GetIt, Injectable) silently erodes type safety
- A typed
Dependenciesobject built from the primitives in Part 1 - Step-by-step async initialization with first-class error reporting
- An
InheritedDependencieswidget that scopes the graph to a subtree - How tests get isolated state for free
#What's Wrong with a Service Locator
The default reach for DI in the Flutter ecosystem is get_it, often paired with injectable. The API is appealingly small:
final getIt = GetIt.instance;
void setup() {
getIt.registerSingleton(ApiClient());
getIt.registerSingleton(AuthController(getIt<ApiClient>()));
}
class HomeBloc {
final api = getIt<ApiClient>(); // resolved at runtime
}
Three quiet problems hide in those lines.
- Type safety is runtime.
getIt<ApiClient>()compiles even if no one registeredApiClient— the failure shows up as a thrown exception in production. - Dependencies are invisible. Reading
HomeBloctells you nothing about what it needs. The graph lives implicitly inside the locator. - Initialization order is untyped. If
AuthControlleris registered beforeApiClient, the failure is, again, runtime.
Flutter already gives us better tools.
#A Typed Dependencies Object
Start with a plain class that owns every long-lived object the app needs.
class Dependencies {
late final Database database;
late final ApiClient apiClient;
late final AuthController auth;
late final SyncService sync;
static Dependencies of(BuildContext context) =>
InheritedDependencies.of(context);
}
Three things to notice.
- Fields are
late final. They are non-nullable, set exactly once, and the analyzer knows their types. - There are no global getters. The only way to read dependencies is via
BuildContext. - The class itself has no behaviour — it's a typed container, nothing more.
#Step-by-Step Initialization
Most apps need a deterministic boot sequence: open the database, then build the API client, then wire up auth, then start sync. Express that as ordered steps with names.
typedef InitStep = FutureOr<void> Function(Dependencies);
final List<MapEntry<String, InitStep>> _bootSteps = [
MapEntry('Database', (deps) async {
deps.database = await Database.open();
}),
MapEntry('API client', (deps) {
deps.apiClient = ApiClient(baseUrl: Config.url);
}),
MapEntry('Auth', (deps) {
deps.auth = AuthController(api: deps.apiClient);
}),
MapEntry('Sync', (deps) {
deps.sync = SyncService(api: deps.apiClient, db: deps.database);
}),
];
Future<Dependencies> bootstrap() async {
final deps = Dependencies();
for (final step in _bootSteps) {
final sw = Stopwatch()..start();
try {
await step.value(deps);
developer.log('boot · ${step.key}', time: sw.elapsed);
} catch (e, st) {
throw BootFailure(step.key, e, st);
}
}
return deps;
}
class BootFailure implements Exception {
final String step;
final Object cause;
final StackTrace stack;
BootFailure(this.step, this.cause, this.stack);
@override
String toString() => 'Failed at step "$step": $cause';
}
What this buys you:
- Compile-time order. A later step can read fields that earlier steps assigned, because the assignments are right there in the same list.
- Honest error messages. A failure says
Failed at step "Database": SqliteException…, notBad state: not registered. - Trivial timing. Wrapping each step in a stopwatch gives you a free boot profile.
#InheritedWidget at the Root
The container reaches the rest of the app via a single InheritedWidget. This is exactly the mechanism that Theme, MediaQuery, and Navigator use under the hood — there's nothing exotic here.
class InheritedDependencies extends InheritedWidget {
final Dependencies deps;
const InheritedDependencies({
super.key,
required this.deps,
required super.child,
});
static Dependencies of(BuildContext context) {
final w = context
.dependOnInheritedWidgetOfExactType<InheritedDependencies>();
assert(w != null, 'No InheritedDependencies found in widget tree');
return w!.deps;
}
@override
bool updateShouldNotify(InheritedDependencies old) =>
!identical(deps, old.deps);
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final deps = await bootstrap();
runApp(InheritedDependencies(
deps: deps,
child: const MyApp(),
));
}
Because updateShouldNotify compares by identity, the container is rebuilt at most when you swap it out wholesale (e.g. log out → boot a new graph for a new user).
#Consuming Dependencies
At the leaves of the tree there are two equally good styles. Pick one per layer and stay consistent.
Inline lookup — fine for one-off reads inside build:
@override
Widget build(BuildContext context) {
final auth = Dependencies.of(context).auth;
return ListenableBuilder(
listenable: auth,
builder: (_, __) => Text(auth.user?.name ?? 'Guest'),
);
}
Constructor injection — preferred for anything outside build (controllers, repositories, services):
class FeedRepository {
final ApiClient api;
final Database db;
FeedRepository(this.api, this.db);
Future<List<Post>> load() async { /* … */ }
}
// Wire it once, near where it's used:
class _FeedScreenState extends State<FeedScreen> {
late final FeedRepository repo = FeedRepository(
Dependencies.of(context).apiClient,
Dependencies.of(context).database,
);
}
The rule of thumb is: read fromInheritedWidgetat the boundary, pass through constructors below it. Code that doesn't takeBuildContextshouldn't know that DI exists.
#Testing Without Global State
Because Dependencies is a normal object, every test gets its own instance. There is no tearDown(() => getIt.reset()) dance.
testWidgets('shows guest when logged out', (tester) async {
final deps = Dependencies()
..apiClient = FakeApiClient()
..database = InMemoryDatabase()
..auth = AuthController(api: FakeApiClient());
await tester.pumpWidget(InheritedDependencies(
deps: deps,
child: const MaterialApp(home: HomeScreen()),
));
expect(find.text('Guest'), findsOneWidget);
});
Two tests running in parallel cannot collide — there's nothing global to collide on.
#Trade-offs
This pattern is not free. Be honest about what it costs:
- You need a
BuildContextto read the graph. Code outside the widget tree (background isolates, top-level functions,mainbeforerunApp) has to be passed dependencies explicitly. In practice that's a feature — it forces you to make the boundary visible. - No automatic registration. Adding a new service is one new
late finalfield plus one newMapEntry. Both are mechanical and grep-able. - No cute lazy
get_itmagic for one-off resolutions in tests. You build the graph you need, which is more typing but also means the test reads top-to-bottom.
Compared to a service locator you give up two-line registration and gain compile-time safety, an explicit graph, deterministic boot, and isolated tests. Across a real codebase that exchange is overwhelmingly worth it.
#Wrap-up
This is the same pattern that powers Theme, Navigator, and MediaQuery inside Flutter itself. There is no novel mechanism — only the discipline to lean on what the framework already provides instead of reaching for a package.
If you are starting a new app, try the no-package version first. The day you genuinely outgrow it, swapping in get_it is mechanical because every read goes through the same single function: Dependencies.of(context).