← Articles

Dependency injection in Flutter with get_it and injectable

By Charlin Joe · 22 November 2024

Dependency injection (DI) is about one thing: making dependencies explicit. Instead of a class creating its own database connection or HTTP client, you pass those in from the outside. The result is code that is easier to test, easier to reason about, and easier to change.

get_it is a service locator — a global registry where you register types and retrieve them anywhere in the app. injectable is a code generator that writes the registration boilerplate for you, driven by annotations.

Why bother with a DI container at all?

In a small app, passing dependencies through constructors is fine. In a large app with 50+ services, you end up with constructor chains five levels deep. A service locator flattens that without forcing every widget to know about every dependency.

The downside of service locators over pure constructor injection is that dependencies are implicit — you can't tell from the type signature what a class needs. That's why injectable annotations serve as documentation: they make the dependency graph visible without requiring manual wiring.

Setup

Add the packages:

dependencies:
  get_it: ^7.6.0
  injectable: ^2.3.0

dev_dependencies:
  injectable_generator: ^2.4.0
  build_runner: ^2.4.0

Create a single GetIt instance and a setup file:

// lib/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart'; // generated

final getIt = GetIt.instance;

@InjectableInit()
Future<void> configureDependencies({String env = 'prod'}) async =>
    getIt.init(environment: env);

Call it before runApp:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await configureDependencies();
  runApp(const MyApp());
}

Run the generator:

dart run build_runner build --delete-conflicting-outputs

Registering services

injectable supports three lifetimes:

AnnotationEquivalentUse case
@singletonregisterSingletonShared state, DB connection
@lazySingletonregisterLazySingletonHeavy object, init deferred
@injectableregisterFactoryStateless service, created fresh each time
@lazySingleton
class ApiClient {
  ApiClient(this._baseUrl);
  final String _baseUrl;
  // ...
}

@singleton
class AuthRepository {
  AuthRepository(this._api, this._storage);
  final ApiClient _api;
  final SecureStorage _storage;

  Future<User> signIn(String email, String password) async {
    final token = await _api.post('/auth/login', {'email': email, 'password': password});
    await _storage.write('token', token);
    return User.fromToken(token);
  }
}

@injectable
class OrderService {
  OrderService(this._repo);
  final OrderRepository _repo;

  Future<List<Order>> getOrders() => _repo.fetchOrders();
}

injectable reads constructor parameters and wires them automatically. No manual getIt.registerSingleton<ApiClient>(ApiClient(baseUrl)) needed.

Environment-specific registrations

Register different implementations for test vs production:

@prod
@LazySingleton(as: OrderRepository)
class OrderRepositoryImpl implements OrderRepository {
  OrderRepositoryImpl(this._api);
  final ApiClient _api;

  @override
  Future<List<Order>> fetchOrders() => _api.get('/orders');
}

@test
@LazySingleton(as: OrderRepository)
class MockOrderRepository implements OrderRepository {
  @override
  Future<List<Order>> fetchOrders() async => [
    Order(id: '1', status: 'pending'),
  ];
}

In tests:

setUp(() async {
  await configureDependencies(env: 'test');
});

Async initialization

Some dependencies need async setup (e.g., opening a database):

@module
abstract class RegisterModule {
  @preResolve
  @singleton
  Future<SharedPreferences> get prefs => SharedPreferences.getInstance();

  @preResolve
  @singleton
  Future<Database> get database => openDatabase('app.db', version: 1);
}

@preResolve tells injectable to await these futures during configureDependencies(), so all singletons are ready before runApp is called.

Using dependencies in widgets

Retrieve from getIt at the widget boundary, not deep inside widget trees:

// Prefer: retrieve once, pass down
class OrdersScreen extends StatelessWidget {
  final _service = getIt<OrderService>();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Order>>(
      future: _service.getOrders(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const CircularProgressIndicator();
        return OrderList(orders: snapshot.data!);
      },
    );
  }
}

If you're using Riverpod or BLoC, inject getIt dependencies into providers/cubits at creation time rather than calling getIt inside widgets.

Common pitfalls

Registering singletons that hold mutable UI state. Singletons live for the app's lifetime. If a singleton caches a BuildContext or holds a reference to a widget, you'll get memory leaks and crashes after navigation.

Forgetting to reset in tests. Call getIt.reset() in tearDown to avoid state leaking between tests.

Circular dependencies. If A needs B and B needs A, the generator will throw. Break the cycle by extracting a shared interface or using a factory function.

Over-using @injectable (factory) when you need a singleton. If a service holds a cache or maintains a stream, registering it as a factory means every caller gets an empty cache. Use @singleton or @lazySingleton for stateful services.

Testing with overrides

You can override individual registrations without re-running the full setup:

test('shows orders', () async {
  await configureDependencies(env: 'test');
  getIt.unregister<OrderRepository>();
  getIt.registerSingleton<OrderRepository>(MockOrderRepository());

  // test...
});

Or use a proper test environment annotation as shown above for cleaner separation.

Sign in to like, dislike, or report.

Dependency injection in Flutter with get_it and injectable — ANN Tech