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:
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:
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
flutter pub add get_itStep 2: Register your objects
Create one container and fill it when the app starts. A single top-level variable is enough:
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:
| Method | What you get | Use it for |
|---|---|---|
registerSingleton | One instance, created now | Things that must exist at startup |
registerLazySingleton | One instance, created on first use | Clients, repositories, services |
registerFactory | A new instance on every request | Cubits, 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:
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:
// 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.
flutter pub add injectable
flutter pub add --dev injectable_generator build_runnerAnnotate your classes:
@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:
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:
dart run build_runner build --delete-conflicting-outputsThe 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
Repositoryor aUseCasewith 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:
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:
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:
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:
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
disposecallback to the registration (registerLazySingleton(..., dispose: (x) => x.close())), so reset and unregister clean it up. - A singleton needs async setup. Use
registerSingletonAsyncand awaitgetIt.allReady()beforerunApp. - Business classes call
getItdirectly. 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.dartruns 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 childGetIt.asNewInstance()with a reference-counted lease. The feature's shell page takes a lease ininitState, builds its content after the container is ready, and releases the lease indispose. 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.
Related guides
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_appThe Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.