Dart 70 🎯 Dependency Injection Patterns in Pure Dart (get_it, injectable)
Dependency injection in pure Dart has two dominant tools, and they solve the problem at different levels. get_it is a service locator: a container where you register instances by type and retrieve them from anywhere without passing them through constructors . injectable is a code generator that sits on top of get_it: you annotate classes, run build_runner, and it generates the registration code so you do not write it by hand . The relationship is not competitive—injectable produces get_it registrations. The choice is whether the convenience of generated registrations is worth the build step.
The service locator pattern itself is controversial. It provides O(1) access to dependencies from anywhere, which is convenient in Flutter where BuildContext complicates constructor injection. But it also hides dependencies: a class that calls getIt<ApiClient>() inside a method does not declare that dependency in its constructor, which makes the coupling invisible to readers and to the type system . The patterns in this chapter aim to get the convenience without the hidden coupling—prefer constructor injection where possible, use the locator at the composition root, and use injectable to reduce the boilerplate of registering everything manually.
Key point: get_it is a service locator with O(1) type-based lookup and no code generation; injectable generates the get_it registration code from annotations and requires build_runner; both are framework-agnostic and work in pure Dart, but the locator should be used at the composition root rather than scattered through business logic.
Why these two packages exist
The composition root problem. A Dart application has a single place where dependencies are wired together—the main() function or a configureDependencies() function called at startup. Without a container, you construct the object graph manually: final api = ApiClient(); final repo = UserRepository(api); final useCase = GetUserUseCase(repo);. This works, but every new dependency changes the signature of main, and every test must reconstruct the graph. A container centralizes the construction and lets tests swap implementations .
The Flutter BuildContext problem. In Flutter, constructor injection is awkward because widgets are rebuilt frequently and dependencies often need to cross widget boundaries. get_it provides access without BuildContext, which is why it became popular in Flutter projects . In pure Dart—servers, CLIs, libraries—this advantage disappears, and constructor injection is usually cleaner. The patterns in this chapter apply to pure Dart, where the locator is optional rather than necessary.
The boilerplate problem. Registering twenty services manually in get_it is repetitive and error-prone. If UserRepository gains a new dependency, you must update its registration. injectable solves this by reading the constructor signature and generating the correct registration automatically . A class annotated with @injectable produces factory<UserRepository>(() => UserRepository(getIt<ApiClient>())) without the developer writing that line.
The testing problem. A service locator makes testing both easier and harder. It is easier because you can register a mock implementation before the test runs and reset the container afterward . It is harder because if the production code reaches into the locator from deep inside a method, the test cannot substitute the dependency without configuring the global container. The rule is to inject through constructors wherever possible, and use the locator only at the boundary where construction happens.
a. get_it: registration and retrieval
get_it provides a global GetIt.instance and registration methods for different lifetimes. The three core registration types are singleton (created immediately, shared everywhere), lazy singleton (created on first access, shared thereafter), and factory (new instance every retrieval) .
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
class ApiClient {
Future<void> fetch() async {}
}
class UserRepository {
final ApiClient api;
UserRepository(this.api);
}
void configureDependencies() {
getIt.registerSingleton<ApiClient>(ApiClient());
getIt.registerLazySingleton<UserRepository>(
() => UserRepository(getIt<ApiClient>()),
);
}
void main() {
configureDependencies();
final repo = getIt<UserRepository>();
repo.api.fetch();
}
The registration order matters for lazy singletons: UserRepository depends on ApiClient, and getIt<ApiClient>() must resolve when the lazy factory runs. The factory is not executed until getIt<UserRepository>() is called, which is after configureDependencies has registered both .
get_it also supports asynchronous registration for services that need initialization. registerSingletonAsync accepts a factory that returns a Future<T>, and getAsync<T>() waits for the future to complete before returning the instance . The allReady() method returns a future that completes when all async registrations are done, which is useful at startup.
getIt.registerSingletonAsync<Database>(() async {
final db = Database();
await db.open();
return db;
});
await getIt.allReady();
final db = getIt<Database>(); // no await needed after allReady
For testing, getIt.reset() clears all registrations, and allowReassignment lets a test replace a registration without resetting the container . The setUp/tearDown pattern is to register mocks in setUp and reset in tearDown so each test starts clean.
b. injectable: generated registrations
injectable requires three dependencies: injectable as a dependency, and injectable_generator plus build_runner as dev dependencies . The setup has two parts: an annotation on a top-level function that generates the initialization, and annotations on the classes to be registered.
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'main.config.dart';
final getIt = GetIt.instance;
@InjectableInit()
void configureDependencies() => getIt.init();
The @InjectableInit() annotation tells the generator to create a main.config.dart file with an extension method init() on GetIt. The configureDependencies() function calls that generated method. In main(), you call configureDependencies() before using any dependency .
The classes are annotated with @injectable, @singleton, or @lazySingleton. The generator reads the constructor and produces the registration:
@injectable
class ApiClient {}
@injectable
class UserRepository {
final ApiClient api;
UserRepository(this.api);
}
Running dart run build_runner build generates factory<UserRepository>(() => UserRepository(getIt<ApiClient>())) automatically . If UserRepository gains a second dependency, you change the constructor, re-run the generator, and the registration updates. No manual registration code to maintain.
For abstract classes, @Injectable(as: Service) binds the implementation to the interface: factory<Service>(() => ServiceImpl()) . For multiple implementations of the same interface, @Named('impl1') registers with a name, and @Named('impl1') on the constructor parameter selects which one to inject .
Third-party types—classes you do not own and cannot annotate—go in a @module abstract class with getter methods. The generator treats each getter as a factory :
@module
abstract class RegisterModule {
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
Async types are registered with @factoryMethod on a static initializer, or with @preResolve if the future should be awaited during initialization rather than on first access .
c. Choosing between them and the testing pattern
The decision is straightforward. If the project has few dependencies and the team prefers no build step, use get_it directly. The registrations are explicit, readable, and debuggable without generated code. If the project has many dependencies, frequent constructor changes, or multiple environments, injectable reduces the maintenance burden. The build step is the cost; the generated registrations are the benefit .
The testing pattern is the same for both. Use a fake implementation, register it in getIt, and reset after the test. With injectable, the test can still override the generated registration by registering a fake after calling configureDependencies() with allowReassignment: true, or by resetting and registering only the fakes needed for that test .
The architectural warning is more important than the tool choice. A service locator used carelessly turns every class into a hidden dependency on the global container. The rule is to inject dependencies through constructors in the domain and data layers, and use getIt only in the composition root—the place where the object graph is built. If a use case calls getIt<RankingRepository>() inside its call method, it cannot be tested without configuring the global container, and its dependency is invisible to anyone reading the constructor. Constructor injection with getIt at the top is the pattern that preserves testability and readability.
Complete Example Session
The following session builds a pure Dart application with get_it and a fake repository for testing, then shows the equivalent injectable setup.
// ============================================
// PART 1: DOMAIN INTERFACE
// ============================================
abstract class RankingRepository {
Future<List<String>> getRankings();
}
// ============================================
// PART 2: DATA IMPLEMENTATION
// ============================================
class ApiRankingRepository implements RankingRepository {
@override
Future<List<String>> getRankings() async => ['Alice', 'Bob'];
}
// ============================================
// PART 3: USE CASE WITH CONSTRUCTOR INJECTION
// ============================================
class GetRankingsUseCase {
final RankingRepository repository;
GetRankingsUseCase(this.repository);
Future<List<String>> call() => repository.getRankings();
}
// ============================================
// PART 4: GET_IT REGISTRATION
// ============================================
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void configureDependencies() {
getIt.registerLazySingleton<RankingRepository>(
() => ApiRankingRepository(),
);
getIt.registerLazySingleton<GetRankingsUseCase>(
() => GetRankingsUseCase(getIt<RankingRepository>()),
);
}
// ============================================
// PART 5: PRODUCTION MAIN
// ============================================
Future<void> main() async {
configureDependencies();
final useCase = getIt<GetRankingsUseCase>();
final rankings = await useCase();
print(rankings); // [Alice, Bob]
}
// ============================================
// PART 6: TEST WITH FAKE REPOSITORY
// ============================================
class FakeRankingRepository implements RankingRepository {
@override
Future<List<String>> getRankings() async => ['TestUser'];
}
Future<void> testExample() async {
getIt.reset();
getIt.registerLazySingleton<RankingRepository>(
() => FakeRankingRepository(),
);
getIt.registerLazySingleton<GetRankingsUseCase>(
() => GetRankingsUseCase(getIt<RankingRepository>()),
);
final useCase = getIt<GetRankingsUseCase>();
final rankings = await useCase();
print(rankings); // [TestUser]
await getIt.reset();
}
// ============================================
// PART 7: INJECTABLE EQUIVALENT (ANNOTATIONS)
// ============================================
// import 'package:injectable/injectable.dart';
//
// @LazySingleton(as: RankingRepository)
// class ApiRankingRepository implements RankingRepository { ... }
//
// @injectable
// class GetRankingsUseCase {
// final RankingRepository repository;
// GetRankingsUseCase(this.repository);
// }
//
// @InjectableInit()
// void configureDependencies() => getIt.init();
//
// Run: dart run build_runner build
// Generated main.config.dart contains the registrations.
// ============================================
// PART 8: INJECTABLE MODULE FOR THIRD-PARTY TYPE
// ============================================
// @module
// abstract class RegisterModule {
// @lazySingleton
// HttpClient get httpClient => HttpClient();
// }
The eight parts cover the domain interface, the data implementation, the use case with constructor injection, the manual get_it registration, the production entry point, the test with a fake repository, the injectable annotations that would replace the manual registrations, and the module pattern for third-party types. What the session demonstrates is that the use case never calls the locator—it receives the repository through its constructor, and the locator is used only at the composition root. This is the pattern that keeps the dependency graph visible and the tests simple.
Quick Reference
get_it Registration Types
| Method | Lifetime | Created When |
|---|---|---|
registerSingleton<T>(instance) | One instance | Immediately |
registerLazySingleton<T>(factory) | One instance | First access |
registerFactory<T>(factory) | New instance | Every access |
registerSingletonAsync<T>(factory) | One instance | Async, at startup |
injectable Annotations
| Annotation | Generated Code |
|---|---|
@injectable | factory<T>(() => T(...)) |
@singleton | registerSingleton<T>(...) |
@lazySingleton | registerLazySingleton<T>(...) |
@Injectable(as: Interface) | factory<Interface>(() => Impl()) |
@Named('name') | factory<T>(..., instanceName: 'name') |
@module | Third-party type registrations |
Testing Commands
| Command | Purpose |
|---|---|
getIt.reset() | Clear all registrations |
getIt.allowReassignment = true | Replace existing registration |
getIt.registerSingleton<T>(fake) | Register mock before test |
Setup Commands
| Step | Command |
|---|---|
Add get_it | dart pub add get_it |
Add injectable | dart pub add injectable |
| Add generator | dart pub add dev:injectable_generator |
| Add build runner | dart pub add dev:build_runner |
| Generate | dart run build_runner build |
Best Practices
✅ Do This:
GetRankingsUseCase(getIt<RankingRepository>()) // ✅ constructor injection
getIt.registerLazySingleton<Repo>(() => RepoImpl()); // ✅ lazy registration
@LazySingleton(as: RankingRepository) // ✅ injectable binding
getIt.reset(); // ✅ clean test state
❌ Don’t Do This:
class UseCase { final repo = getIt<Repo>(); } // ❌ hidden dependency
getIt.registerSingleton<Repo>(RepoImpl()); // ❌ eager, may fail at startup
// call configureDependencies() after using a dependency // ❌ registration order
// leave mocks registered between tests // ❌ test pollution
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
GetIt throws “not registered” | Dependency used before configureDependencies() | Call registration in main() before use |
Lazy singleton resolves null dependency | Registration order wrong | Register dependencies before dependents |
injectable generated file missing | build_runner not run | dart run build_runner build |
| Async singleton returns before ready | Used get() instead of getAsync() | await getIt.getAsync<T>() or allReady() |
| Test fails with “already registered” | Previous test left registrations | getIt.reset() in tearDown |
| Hidden dependency in use case | Called getIt inside method | Inject through constructor |
Real-World Examples
1. Basic lazy singleton
getIt.registerLazySingleton<ApiClient>(() => ApiClient());
2. Factory for stateless service
getIt.registerFactory<Logger>(() => Logger());
3. Async registration
getIt.registerSingletonAsync<Db>(() => Db.open());
4. Injectable singleton
@singleton class Config {}
5. Injectable binding to interface
@LazySingleton(as: RankingRepository) class ApiRepo implements RankingRepository {}
6. Injectable named instance
@Named('dev') @Injectable(as: Service) class DevService implements Service {}
7. Module for third-party
@module abstract class RegisterModule { HttpClient get client => HttpClient(); }
8. Test with fake
getIt.registerLazySingleton<RankingRepository>(() => FakeRepo());
9. Reset between tests
tearDown(() => getIt.reset());
10. Await all async registrations
await getIt.allReady();
Visual
get_it Lookup Flow
┌──────────────────────────────────────────────┐
│ getIt<RankingRepository>() │
│ │ │
│ ▼ │
│ O(1) lookup in internal Map │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ registered not registered │
│ │ │ │
│ ▼ ▼ │
│ Return Throw StateError │
│ instance │
└──────────────────────────────────────────────┘
injectable Generation
┌──────────────────────────────────────────────┐
│ @injectable │
│ class UserRepository { │
│ final ApiClient api; │
│ UserRepository(this.api); │
│ } │
│ │ │
│ ▼ │
│ dart run build_runner build │
│ │ │
│ ▼ │
│ Generated main.config.dart: │
│ factory<UserRepository>( │
│ () => UserRepository(getIt<ApiClient>()) │
│ ); │
└──────────────────────────────────────────────┘
Composition Root Pattern
┌──────────────────────────────────────────────┐
│ main() │
│ configureDependencies() ← composition root │
│ │ │
│ ▼ │
│ getIt<GetRankingsUseCase>() │
│ │ │
│ ▼ │
│ UseCase receives Repo via constructor │
│ │ │
│ ▼ │
│ UseCase does NOT call getIt │
│ (dependency is visible and testable) │
└──────────────────────────────────────────────┘
Test Isolation
┌──────────────────────────────────────────────┐
│ setUp: │
│ getIt.registerLazySingleton<Repo>(Fake) │
│ │ │
│ ▼ │
│ test runs with fake │
│ │ │
│ ▼ │
│ tearDown: getIt.reset() │
│ │ │
│ ▼ │
│ next test starts clean │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
get_it | Service locator, O(1) lookup, no code gen |
injectable | Code generator for get_it registrations |
| Singleton | One instance, created immediately |
| Lazy singleton | One instance, created on first access |
| Factory | New instance every access |
| Async registration | registerSingletonAsync, getAsync |
| Injectable binding | @Injectable(as: Interface) |
| Injectable named | @Named('name') |
| Module | @module for third-party types |
| Testing | reset(), allowReassignment |
Key takeaways:
get_itis a service locator, not a dependency injector. It provides access to instances but does not automatically inject them into constructors. The injection is manual, throughgetIt<T>()inside a factory .injectablegenerates theget_itregistration code. You annotate classes, runbuild_runner, and the generated file contains thefactoryandregisterSingletoncalls you would otherwise write by hand .- Constructor injection is the pattern that preserves testability. Pass dependencies through constructors, and use
getItonly at the composition root. A use case that callsgetItinternally has a hidden dependency that cannot be substituted without configuring the global container . - Lazy singletons resolve dependencies when first accessed. Register the dependencies before the dependent, or use
dependsOnfor async registrations . injectablerequires a build step. The generatedmain.config.dartmust be regenerated when constructors change. This is the trade-off for less manual registration code .- Tests need
reset(). A service locator is global state. Leaving registrations from one test leaks into the next .
Remember: get_it and injectable are the two dominant DI tools in pure Dart. get_it is the container: register instances, retrieve them by type. injectable is the generator: annotate classes, generate the container’s registrations, and avoid writing them by hand. The architectural rule that matters more than either tool is the composition root pattern: build the object graph once at startup, inject dependencies through constructors, and use the locator only where construction happens. A service locator used inside business logic is a hidden dependency; a service locator used at the entry point is a wiring mechanism. The difference is whether your tests need to know about it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!