Sparround

Dependency injection and testability

State management and DI are often conflated, yet they answer different questions:

  • DI — who supplies an object's dependencies (how a repository reaches a view model).
  • State management — how a change reaches the UI.

Flutter's official architecture case study takes a clear position on DI: in the Compass app, dependency injection is handled using `package:provider`; based on their experience building Flutter apps, teams at Google recommend using `package:provider` to implement dependency injection.

The core pattern is constructor injection: services are injected into repositories, repositories into view models, and the injected dependencies are kept private so the view cannot bypass the view model.

ApproachHow it worksTesting
`package:provider` (the official case study's choice)`MultiProvider` at the root; passed into constructors with `context.read()`The tree is rebuilt by hand in widget tests
RiverpodProviders read each other with `ref.watch``overrides` replaces any dependency in one line
`RepositoryProvider` (flutter_bloc)package:provider inside; injected into blocs with `context.read()`A fake via `RepositoryProvider.value` in widget tests
`get_it` (service locator)A global registry: `GetIt.I<Repo>()`; no `BuildContext` neededThe registry must be reset between tests or state leaks
dart
// Service repository-yə konstruktordan verilir.
class BookingRepository {
  BookingRepository({required ApiClient apiClient}) : _apiClient = apiClient;
  final ApiClient _apiClient; // private: kənardan görünmür
}

// View model repository-ni konstruktordan alır və private saxlayır,
// belə ki, view onun üzərindən keçib repository-yə müraciət edə bilmir.
class HomeViewModel extends ChangeNotifier {
  HomeViewModel({required BookingRepository bookingRepository})
      : _bookingRepository = bookingRepository;
  final BookingRepository _bookingRepository;
}

// Kökdə qurulma (case study-nin nümunəsi ilə eyni struktur).
void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider(create: (context) => ApiClient()),
        Provider(create: (context) => SharedPreferencesService()),
        Provider(
          create: (context) => BookingRepository(apiClient: context.read()),
        ),
      ],
      child: const MainApp(),
    ),
  );
}

The official case study's style: constructor injection with private fields.

dart
// Provider: ağacı testdə əl ilə qurmaq.
await tester.pumpWidget(
  MultiProvider(
    providers: [
      Provider<BookingRepository>.value(value: FakeBookingRepository()),
    ],
    child: const MaterialApp(home: HomeScreen()),
  ),
);

// Riverpod: bir sətirlik override.
await tester.pumpWidget(
  ProviderScope(
    overrides: [
      bookingRepositoryProvider.overrideWithValue(FakeBookingRepository()),
    ],
    child: const MaterialApp(home: HomeScreen()),
  ),
);

// flutter_bloc: repository fake, bloc real.
await tester.pumpWidget(
  RepositoryProvider<BookingRepository>.value(
    value: FakeBookingRepository(),
    child: MaterialApp(
      home: BlocProvider(
        create: (context) => BookingBloc(context.read()),
        child: const HomeScreen(),
      ),
    ),
  ),
);

Replacing the same dependency in a test — three libraries, three ways.

The main risk of a service locator such as get_it is state leaking between tests: the registry is global, so every test must call reset, and forgetting it makes failures depend on test order. The second risk is that dependencies become invisible: you cannot tell what a class needs from its constructor, because the dependency is fetched inside a method with GetIt.I<T>(). So I use a locator only where BuildContext is not available (a background isolate, an entry point) and keep constructor injection in the main flow.

📚 Sources and documentation