Skip to content

Guides

Flutter dependency injection with get_it: one global container plus one per feature ​

Short answer. Use get_it as a registry where you register how to build your objects once, in one place. Classes get what they need through their constructors. Use injectable if you want a code generator to write the registrations. In a modular app, keep shared things (HTTP client, auth, analytics) in one global container, and give each feature its own small container for its repositories and use cases. The feature container is created when the feature opens and thrown away when it closes.

What dependency injection means here ​

A class that creates its own helpers is hard to change and hard to test:

dart
class ProfileRepository {
  final _client = ApiClient(baseUrl: 'https://api.example.com'); // fixed forever
}

With dependency injection, the class asks for what it needs and someone else supplies it:

dart
class ProfileRepository {
  ProfileRepository(this._client);
  final ApiClient _client;
}

get_it is the "someone else". It is a service locator: a registry you fill at app start and ask for objects later.

Step 1: Install ​

bash
flutter pub add get_it

Step 2: Register your objects ​

Create one container and fill it when the app starts. A single top-level variable is enough:

dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupDependencies() {
  getIt
    ..registerLazySingleton<ApiClient>(() => ApiClient(baseUrl: 'https://api.example.com'))
    ..registerLazySingleton<ProfileRepository>(() => ProfileRepository(getIt()))
    ..registerFactory<ProfileCubit>(() => ProfileCubit(getIt()));
}

Call setupDependencies() once in main() before runApp. Dart infers the type in getIt() from the constructor parameter, so you do not repeat it.

The three registration types you need most:

MethodWhat you getUse it for
registerSingletonOne instance, created nowThings that must exist at startup
registerLazySingletonOne instance, created on first useClients, repositories, services
registerFactoryA new instance on every requestCubits, blocs, view models

Registering the same type twice throws an error, unless you set allowReassignment = true. Leave that off in app code. It is meant for tests.

Step 3: Register an interface, not the class ​

Depend on an abstract type so you can swap the implementation, for a fake in tests or another vendor later:

dart
abstract interface class AnalyticsService {
  void track(String event);
}

class FirebaseAnalyticsService implements AnalyticsService {
  @override
  void track(String event) { /* ... */ }
}

getIt.registerLazySingleton<AnalyticsService>(FirebaseAnalyticsService.new);

Everyone who asks for AnalyticsService gets the Firebase one today, and a different one tomorrow, with no change to the callers.

Step 4: Keep lookups in one place ​

This rule matters most. Ask get_it for objects only in your setup code. Everywhere else, take what you need as a constructor argument:

dart
// Good: the page does not know get_it exists.
class ProfilePage extends StatelessWidget {
  const ProfilePage({required this.cubit, super.key});
  final ProfileCubit cubit;
  // ...
}

// Wiring, in the route or the shell:
ProfilePage(cubit: getIt<ProfileCubit>());

If widgets call getIt<...>() all over the place, you have hidden dependencies. Tests need the container to be set up first, and you cannot see what a widget needs from its signature.

Step 5: Generate the registrations with injectable ​

Writing every registration by hand gets long. injectable reads annotations and generates the code.

bash
flutter pub add injectable
flutter pub add --dev injectable_generator build_runner

Annotate your classes:

dart
@LazySingleton(as: ProfileRepository)
class ProfileRepositoryImpl implements ProfileRepository {
  ProfileRepositoryImpl(this._client);
  final ApiClient _client;
}

@injectable
class ProfileCubit extends Cubit<ProfileState> {
  ProfileCubit(this._repository) : super(const ProfileState());
  final ProfileRepository _repository;
}

Add an entry point:

dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

Generate and call it:

bash
dart run build_runner build --delete-conflicting-outputs

The annotations map to the registration types above: @injectable is a factory, @lazySingleton is a lazy singleton, and @singleton is an eager singleton. The as: argument registers the class under its interface.

The problem with one big container ​

One global container works for a small app. In a modular app with many features it starts to hurt:

  • Name clashes. Two features both have a Repository or a UseCase with similar roles, and one registration shadows another.
  • Leaked state. A feature's singletons stay alive after the user leaves it, with their streams and caches.
  • Hidden coupling. Feature A can ask for feature B's internal repository, because it is in the same container.

The fix is to split by lifetime and by owner:

  • Global container. Things the whole app shares and that live as long as the app: HTTP client, auth state, analytics, monitoring.
  • Feature container. Things only one feature uses: its repositories, data sources and use cases. It lives while the feature is on screen.

Per-feature containers with get_it ​

get_it has two ways to do this. They are not equal.

Scopes (pushNewScope). A new layer on the same container. When you pop it, everything registered in it is disposed:

dart
getIt.pushNewScope(
  scopeName: 'home',
  init: (g) {
    g.registerLazySingleton<HomeRepository>(() => HomeRepositoryImpl(g()));
  },
  dispose: () async => print('home scope disposed'),
);

// later
await getIt.popScopesTill('home');

This cleans up well. But a scope falls back to the layers below it, so code anywhere can still resolve HomeRepository while the scope is open, and two scopes that register the same type resolve by stack order. The isolation is a convention.

A separate instance (GetIt.asNewInstance). The feature gets its own container that does not fall back to the global one. The feature can only resolve what you register in it, and you list the global things it needs on purpose:

dart
final homeDi = GetIt.asNewInstance();

void initHomeDi(GetIt root) {
  homeDi
    // Imported from the global container: one line per shared type.
    ..registerFactory<ApiClient>(() => root<ApiClient>())
    // Owned by the feature.
    ..registerLazySingleton<HomeRepository>(() => HomeRepositoryImpl(homeDi()))
    ..registerFactory<LoadHomeUseCase>(() => LoadHomeUseCase(homeDi()));
}

Future<void> disposeHomeDi() => homeDi.reset();

The import is a factory that asks the global container, so the global container keeps ownership of the shared object. Resetting the feature container does not dispose it.

Create the container when the feature screen opens and reset it when it closes:

dart
class HomeShellPage extends StatefulWidget {
  const HomeShellPage({super.key});

  @override
  State<HomeShellPage> createState() => _HomeShellPageState();
}

class _HomeShellPageState extends State<HomeShellPage> {
  late final LoadHomeUseCase _loadHome;

  @override
  void initState() {
    super.initState();
    initHomeDi(getIt);
    _loadHome = homeDi();
  }

  @override
  void dispose() {
    disposeHomeDi();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => HomePage(loadHome: _loadHome);
}

Keep two cases in mind. If a feature can be opened twice at the same time, count how many screens use the container and reset it only when the last one closes. And if the replacement screen mounts before the old one disposes, a plain reset on dispose would destroy the container the new screen needs. A counter fixes both.

Testing ​

Create a fresh container per test, so tests do not share state:

dart
test('shows the profile name', () async {
  final di = GetIt.asNewInstance()
    ..registerSingleton<ProfileRepository>(FakeProfileRepository());

  final cubit = ProfileCubit(di());
  await cubit.load();

  expect(cubit.state.name, 'Ada');
});

If you must use the global container in a test, call getIt.reset() in tearDown. get_it documents reset for tests.

Common problems ​

  • "Object/factory with type X is not registered inside GetIt". You asked for something that is not registered, or you asked before setupDependencies() ran. In a feature container, you may have forgotten an import line.
  • A type is already registered. You registered it twice. Look for a setup function that runs twice, for example once per test without a reset.
  • A singleton holds on to resources. Pass a dispose callback to the registration (registerLazySingleton(..., dispose: (x) => x.close())), so reset and unregister clean it up.
  • A singleton needs async setup. Use registerSingletonAsync and await getIt.allReady() before runApp.
  • Business classes call getIt directly. Move the lookup to the setup code and pass the object through the constructor.

How Archipelago does this ​

Archipelago generates this pattern with get_it and injectable. It is called Dual-GetIt.

  • Global container. The app's injector.dart runs the injectable-generated setup. Each infrastructure package (network, monitoring, router registry) and each feature's global module adds its registrations through injectable's package modules. Feature SDKs are registered lazily. Only boot-critical ones, such as auth, are eager.
  • Feature container. Each feature has a FeatureContainer. It is a child GetIt.asNewInstance() with a reference-counted lease. The feature's shell page takes a lease in initState, builds its content after the container is ready, and releases the lease in dispose. The last release tears the container down.
  • Explicit imports. One file per feature, <name>_imports.dart, is the only place that reads the global container. A type that is not imported throws instead of resolving another feature's registration.
  • No lookups in pages. The shell builds the Cubit, Bloc or ChangeNotifier from the lease and passes it down. Pages never read GetIt. A lint rule flags service-locator calls outside the DI files.

Earlier versions of Archipelago used pushNewScope for features. It moved to child containers because scopes fall back to the global layer, so isolation was only a convention. If you have a project generated before that change, see Migrating feature DI.

References: Dual-GetIt DI Pattern, Feature Manifests and SDKs, and the flutter_modular_monorepo brick.

FAQ ​

Is get_it a service locator, and is that bad? ​

Yes, it is a service locator. The usual criticism is that classes hide their needs when they call the locator directly. You avoid that by looking things up only in setup code and passing them in through constructors. Then get_it is just the wiring.

Do I need injectable? ​

No. For a small app, manual registrations are short and easy to read. injectable pays off when you have many classes, or many packages that each register their own.

get_it or Provider or Riverpod? ​

They solve different problems. Provider and Riverpod tie objects to the widget tree. get_it does not need a BuildContext, so it works in plain Dart code, in packages and in background tasks. You can use get_it for services and a state management package for UI state.

Singleton or lazy singleton? ​

Use a lazy singleton unless the object must exist before the first frame. A lazy singleton does no work until someone asks for it, so it does not slow down app start.

Scope or separate instance for features? ​

Scopes are simpler and good enough when isolation between features is not a goal. Choose a separate instance when you want a feature to be unable to reach into another feature's objects.

Try Archipelago

Make a free account, install the CLI, and generate a project:

dart pub global activate archipelago_cli
archipelago auth login --interactive
archipelago create --output ./my_app

The Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.