By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Dependency injection (DI) is the plumbing that makes Clean Architecture work. Without it, your BLoCs new up their own repositories, your repositories new up their own HTTP clients, and before long every class is wired to concrete implementations that cannot be mocked or swapped.
Flutter has three main DI approaches: Provider (widget-tree based), get_it (service locator), and injectable (code generation on top of get_it). Each has trade-offs. At iStoria, we use get_it + injectable across our 50+ module codebase — it handles the scale without widget-tree coupling or manual registration.
This guide compares all three, shows when to use each, and provides a production wiring pattern with annotated factories, singletons, and environment-based registration.
Without DI, classes construct their own dependencies:
// BAD — tightly coupled, untestable
class LessonCubit extends Cubit<LessonState> {
LessonCubit() : super(LessonInitial()) {
// Cubit creates its own repository, which creates its own Dio...
_repository = LessonRepositoryImpl(
remoteDatasource: LessonRemoteDatasource(Dio()),
localDatasource: LessonLocalDatasource(database),
networkInfo: NetworkInfoImpl(connectivity),
);
}
}
This is impossible to test (you cannot swap the repository with a mock) and impossible to reconfigure (changing the Dio instance means editing every class). DI fixes this by inverting the control: dependencies are created externally and passed in.
Provider uses InheritedWidget to make objects available down the widget tree. You register dependencies at the top of the tree and consume them with context.read() / context.watch().
Pros: Built into Flutter (via the provider package). Simple for small apps. Scoped to the widget tree (dependencies are disposed when the subtree is disposed).
Cons: Everything is tied to BuildContext. You cannot access a repository from a non-widget context (a background service, a use case, a scheduled callback). At scale, the provider tree becomes deeply nested and hard to trace. Not suitable for Clean Architecture where the domain layer must not depend on Flutter.
Use when: The app is small (under ~10 screens), all dependencies are UI-scoped, and you do not need DI outside the widget tree.
get_it is a simple service locator. You register dependencies in a global container and retrieve them by type. No BuildContext needed.
Pros: Works anywhere — widgets, BLoCs, use cases, background tasks. Simple API (getIt.get() / getIt.call()). No code generation required. Decoupled from the widget tree.
Cons: Registration is manual — for a large app, the configureDependencies() function becomes long and brittle. Dependencies are not disposed automatically (you manage lifecycles). The service-locator pattern is sometimes considered an anti-pattern because it hides dependencies (a class can call getIt.get() internally rather than declaring its dependencies in the constructor).
Use when: You need DI outside the widget tree, want simplicity, and the app is medium-sized (manual registration is manageable).
injectable generates the get_it registration code from annotations. You annotate your classes with @injectable, @singleton, @LazySingleton, and injectable creates the wiring for you.
Pros: All the benefits of get_it, minus the manual registration. Adding a new dependency is just annotating the class and running build_runner. Supports environments (dev, prod, test) for different registrations. Scales effortlessly to 50+ modules.
Cons: Requires code generation (build_runner). Adds a build step to the development workflow. The generated code is verbose (but you never read it).
Use when: The app is large (10+ features), you want automatic registration, and the team is comfortable with code generation. This is what we use at iStoria.
Regardless of which DI tool you use, avoid this:
// BAD — hidden dependency
class LessonCubit extends Cubit<LessonState> {
LessonCubit() : super(LessonInitial()) {
// Don't call getIt inside the class — declare the dependency in the constructor
final repo = getIt<LessonRepository>();
}
}
// GOOD — explicit dependency
@injectable
class LessonCubit extends Cubit<LessonState> {
final LessonRepository repository;
LessonCubit(this.repository) : super(LessonInitial());
}
The constructor-injected version declares its dependency explicitly. injectable reads the constructor parameter and automatically resolves LessonRepository from the container. The class is testable (pass a mock) and its dependencies are visible at a glance.
injectable supports environments — you can register different implementations for dev, prod, and test:
@LazySingleton(as: AuthRepository, env: ['dev'])
class MockAuthRepository implements AuthRepository { ... }
@LazySingleton(as: AuthRepository, env: ['prod'])
class AuthRepositoryImpl implements AuthRepository { ... }
At iStoria, we use this for feature flags (a local implementation in dev, a remote-config implementation in prod) and for analytics (a no-op in dev, a PostHog implementation in prod).
Some dependencies require async initialization (a database that needs to open, a sync engine that needs to connect). get_it supports registerLazySingletonAsync and the isReady mechanism. Structure your app initialization so all async dependencies are resolved before the first route renders. A splash screen or an initialization gate handles this cleanly.
lib/
├── main.dart
├── core/
│ ├── di/
│ │ ├── injection.dart # getIt instance + configureDependencies()
│ │ ├── injection.config.dart # GENERATED by injectable
│ │ └── modules/
│ │ ├── network_module.dart # @module providing Dio, interceptors
│ │ ├── database_module.dart # @module providing Drift database
│ │ └── external_module.dart # @module for third-party clients
│ └── ...
└── features/
├── auth/
│ ├── data/repositories/auth_repository_impl.dart # @LazySingleton
│ └── presentation/cubit/auth_cubit.dart # @injectable
└── lesson/
├── data/repositories/lesson_repository_impl.dart # @LazySingleton
└── presentation/cubit/lesson_cubit.dart # @injectable
// pubspec.yaml
// dependencies:
// get_it: ^8.0.0
// injectable: ^2.6.0
// dev_dependencies:
// injectable_generator: ^2.6.0
// build_runner: ^2.4.0
// Run: dart run build_runner build --delete-conflicting-outputs
// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit(preferRelativeImports: true)
Future<void> configureDependencies(String env) async {
await getIt.init(environment: env);
}
// core/di/modules/network_module.dart — @module provides third-party deps
@module
abstract class NetworkModule {
@lazySingleton
Dio dio() {
return Dio(BaseOptions(
baseUrl: 'https://api.istoria.app/v2',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
))..interceptors.addAll([
AuthInterceptor(getIt<AuthStorage>()),
LogInterceptor(requestBody: true, responseBody: true),
]);
}
@lazySingleton
Connectivity connectivity() => Connectivity();
}
// features/lesson/data/repositories/lesson_repository_impl.dart
@LazySingleton(as: LessonRepository)
class LessonRepositoryImpl implements LessonRepository {
final LessonRemoteDatasource remoteDatasource;
final LessonLocalDatasource localDatasource;
final NetworkInfo networkInfo;
// injectable reads these constructor params and resolves them from getIt
const LessonRepositoryImpl(
this.remoteDatasource,
this.localDatasource,
this.networkInfo,
);
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
// ... implementation
}
}
// features/lesson/presentation/cubit/lesson_cubit.dart
@injectable
class LessonCubit extends Cubit<LessonState> {
final LessonRepository repository;
LessonCubit(this.repository) : super(LessonInitial());
Future<void> loadLessons(String courseId) async {
emit(LessonLoading());
final result = await repository.getLessons(courseId);
result.fold(
(failure) => emit(LessonError(failure.message)),
(lessons) => emit(LessonLoaded(lessons)),
);
}
}
// main.dart — wire everything up
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(Environment.prod);
runApp(MyApp());
}
// In a widget or anywhere in the app:
// final cubit = getIt<LessonCubit>(); // resolved with all dependencies
Use Provider if all your dependencies are UI-scoped and you never need DI outside the widget tree. Use get_it if you need to access dependencies from BLoCs, background services, or use cases without a BuildContext. For Clean Architecture specifically, get_it is the better choice because the domain layer must not depend on Flutter widgets.
For apps with 10+ features, yes. Manual get_it registration becomes a 200-line file that breaks when you rename a class or add a parameter. Injectable eliminates that by generating the wiring from annotations. The build_runner step adds a few seconds to development but saves hours of debugging registration mismatches.
BLoCs should be factory-scoped (@injectable, not @singleton) in most cases. A new instance per access means the state is fresh when the user navigates to the feature. Use @singleton only for app-wide BLoCs (like an AuthBloc or ThemeBloc) whose state must persist across navigation. A common mistake is singleton-scoping feature BLoCs, which causes stale state when revisiting a screen.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work