← Flutter Reference

Flutter Dependency Injection: Provider, get_it, injectable

Architecture · Intermediate

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.

Why Dependency Injection Matters

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.

The Three Approaches

Provider (Widget-Tree DI)

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 (Service Locator)

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 (get_it + Code Generation)

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.

The Anti-Pattern to Avoid

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.

Environment-Based Registration

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).

Async Registration

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.

Recommended folder structure

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

Code example

// 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

Implementation checklist

  • Choose your DI approach based on app size: Provider for small/widget-only apps, get_it for medium, injectable for large.
  • Never call getIt.get() inside a class body — always declare dependencies as constructor parameters.
  • Annotate repositories as @LazySingleton (created once, on first access) and BLoCs/Cubits as @injectable (new instance per access).
  • Create @module classes for third-party dependencies (Dio, Connectivity, database instances) that you do not own.
  • Use environments (dev/prod/test) to register different implementations for testing and feature-flagged services.
  • Run build_runner after adding or changing annotations to regenerate the registration code.
  • Resolve async dependencies before the first route renders using an initialization gate or splash screen.

Common mistakes

  • Calling getIt.get() inside class methods instead of declaring dependencies in the constructor, hiding coupling.
  • Using Provider for everything in a Clean Architecture app, forcing the domain layer to depend on BuildContext.
  • Registering BLoCs as singletons when they should be factory-scoped, causing stale state across feature navigation.
  • Forgetting to run build_runner after annotation changes, leading to runtime 'type not registered' errors.
  • Not handling async dependency initialization, causing the first API call to fail because the database is not open yet.

Related definitions

Related reading

FAQ

Provider or get_it — which should I use?

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.

Is injectable worth the code generation overhead?

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.

Should BLoCs be singletons or factories?

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