A small Flutter feature can create its dependencies directly:
final client = ApiClient();
final service = CoursesService(client);
final repository = CoursesRepository(service);
final getCourses = GetCourses(repository);
final bloc = CoursesBloc(getCourses);
This is clear and easy to debug. I prefer it while the dependency graph is small.
The problem appears when an application has dozens of features, shared infrastructure, environment-specific implementations, and two apps using the same core package. Startup becomes a long construction script. The order matters, singleton choices become inconsistent, and tests need a reliable way to replace dependencies.
That is where I use get_it and injectable.
What each package does#
get_it is the runtime container. It stores factories and singleton instances and resolves them by type:
final getIt = GetIt.instance;
getIt.registerLazySingleton<ApiClient>(
() => ApiClient(baseUrl: environment.apiUrl),
);
getIt.registerLazySingleton<CoursesRepository>(
() => CoursesRepository(getIt<ApiClient>()),
);
getIt.registerFactory<CoursesBloc>(
() => CoursesBloc(getIt<CoursesRepository>()),
);
injectable generates this registration code from annotations. It reduces manual wiring while get_it still performs the lookup at runtime.
@injectable
class CoursesBloc extends Bloc<CoursesEvent, CoursesState> {
CoursesBloc(this._getCourses) : super(const CoursesState());
final GetCourses _getCourses;
}
The constructor remains explicit. The annotation tells injectable how the class should be registered.
The problem was wiring, not constructors#
Dependency injection does not mean hiding dependencies. I still want this:
class GetCourses {
const GetCourses(this._repository);
final CoursesRepository _repository;
}
The class clearly requires a repository and can be constructed directly in a unit test.
The container answers a different question: who assembles the complete production graph?
That distinction keeps business classes independent of get_it. Avoid calling getIt inside a repository or use case:
// Avoid service location inside business code.
class GetCourses {
Future<List<Course>> call() {
return getIt<CoursesRepository>().getCourses();
}
}
This hides the dependency and makes the class harder to test. Resolve dependencies at application or feature boundaries, then pass them through constructors.
Register by lifecycle#
The most important DI decision is not the annotation. It is the lifecycle.
Factory#
A new instance is created every time:
@injectable
CoursesBloc coursesBloc(GetCourses usecase) {
return CoursesBloc(usecase);
}
This is appropriate for page-scoped BLoCs. The page or BlocProvider owns closing the instance.
Lazy singleton#
One instance is created on first use:
@lazySingleton
class SessionRepository {
SessionRepository(this._storage);
final SecureStorage _storage;
}
This fits shared repositories and services that should live for the application.
Singleton#
One instance is created during setup. Use this when eager initialization is genuinely required. Creating everything eagerly increases startup work and may initialize services a user never reaches.
Do not make every dependency a singleton. Stateful BLoCs shared accidentally across pages can retain filters, errors, or closed streams.
Use modules for third-party and interface registrations#
You cannot add injectable annotations to classes from another package. Modules register them:
@module
abstract class NetworkModule {
@lazySingleton
Dio dio(AppEnvironment environment) {
return Dio(
BaseOptions(baseUrl: environment.apiUrl),
);
}
}
Modules also map interfaces to implementations:
@LazySingleton(as: CoursesRepository)
class ApiCoursesRepository implements CoursesRepository {
ApiCoursesRepository(this._service);
final CoursesService _service;
}
Feature modules are useful in a large app because network, storage, authentication, and each feature can keep related registrations together.
Keep the application boundary visible#
The generated setup usually runs before runApp:
final getIt = GetIt.instance;
@InjectableInit()
Future<void> configureDependencies() async {
getIt.init();
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
runApp(const App());
}
The exact generated method depends on the injectable version and configuration; follow the injectable package documentation for the current setup.
At the UI boundary, resolve a page-scoped BLoC:
BlocProvider(
create: (_) => getIt<CoursesBloc>()
..add(const CoursesRequested()),
child: const CoursesPage(),
)
Because BlocProvider created this BLoC, it also closes it. Passing an existing singleton with BlocProvider.value has different ownership. DI lifetime and widget lifetime must agree.
Environment-specific registrations#
Production, development, and tests may need different implementations:
abstract interface class Analytics {
Future<void> track(String event);
}
@Environment('prod')
@LazySingleton(as: Analytics)
class ProductionAnalytics implements Analytics {
// ...
}
@Environment('dev')
@LazySingleton(as: Analytics)
class DebugAnalytics implements Analytics {
// ...
}
The application selects the environment during configuration. This is cleaner than spreading if-debugMode checks across features.
Reset and replace dependencies in tests#
Constructor injection should handle most unit tests:
final repository = MockCoursesRepository();
final usecase = GetCourses(repository);
final bloc = CoursesBloc(usecase);
Integration and widget tests may use the container. Reset it between tests so registrations do not leak:
setUp(() async {
await getIt.reset();
getIt.registerSingleton<CoursesRepository>(
FakeCoursesRepository(),
);
});
If a singleton owns streams, databases, or clients, provide disposal behavior. Resetting a registration without releasing its resources creates difficult test and development leaks.
Generated DI still needs review#
injectable saves registration boilerplate, but it cannot choose the correct architecture automatically.
Review:
- whether a dependency should be factory or singleton;
- which layer owns disposal;
- whether an interface mapping is correct;
- whether two feature modules register the same type;
- whether initialization must be awaited;
- whether app-specific implementations stay outside a shared package.
Run code generation after changing annotations:
dart run build_runner build --delete-conflicting-outputs
Compilation catches many missing registrations, while startup tests catch environment and initialization problems.
Why I use both#
Manual construction is excellent when it stays readable. I introduce get_it when the application graph becomes a problem of its own, and injectable when maintaining registrations by hand becomes repetitive.
The combination works because constructors remain honest, feature wiring is centralized, and lifecycle choices are visible. The container should assemble the application. It should not become a shortcut used from every class.