Flutter DI without external packages
← All Articles

Flutter DI without external packages

InheritedWidget instead of GetIt — a type-safe, explicit, debuggable dependency layer that needs nothing from pubspec.yaml.

In this article:

  • Why a global service locator (GetIt, Injectable) silently erodes type safety
  • A typed Dependencies object built from the primitives in Part 1
  • Step-by-step async initialization with first-class error reporting
  • An InheritedDependencies widget 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 registered ApiClient — the failure shows up as a thrown exception in production.
  • Dependencies are invisible. Reading HomeBloc tells you nothing about what it needs. The graph lives implicitly inside the locator.
  • Initialization order is untyped. If AuthController is registered before ApiClient, 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…, not Bad 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 from InheritedWidget at the boundary, pass through constructors below it. Code that doesn't take BuildContext shouldn'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 BuildContext to read the graph. Code outside the widget tree (background isolates, top-level functions, main before runApp) 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 final field plus one new MapEntry. Both are mechanical and grep-able.
  • No cute lazy get_it magic 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).