skip to content

Service Location & Injection

Dependencies reach Flutter classes through constructors, a get_it service locator, injectable codegen, or Provider and Riverpod scopes. Interviewers ask how each lets a test swap in a fake.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

6

In a Flutter app, what is constructor injection, and how does it let a test swap a real payment service for a fake?

level: juniorimportance: must knowfreq 55%

answer

  1. dependencies arrive, never created inside
  2. required named parameters
  3. private final fields
  4. typed as the abstract class
  5. the test calls the constructor

basics

~10 s

Constructor injection means a class receives its dependencies as constructor parameters instead of creating or looking them up. A test builds the class itself and passes a FakePaymentService where production passes the real one.

solid answer

~30 s

With constructor injection a class declares what it needs as constructor parameters, in Flutter usually `required` named parameters assigned to private `final` fields, and never builds those objects itself. Flutter's architecture guide wires every layer this way: services into repositories, repositories into view models. If a parking app's `ParkingSessionViewModel` takes a `PaymentService` typed as an abstract class, a unit test just calls `ParkingSessionViewModel(payments: FakePaymentService())`; no widget tree, no network, no global registry to reset. Production code builds the real `CardPaymentService` in one place, the composition root, using `main()`, provider or get_it.

code

dart · 39 lines
dart
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';

abstract class PaymentService {
  Future<void> charge({required int cents});
}

class ParkingSessionViewModel extends ChangeNotifier {
  ParkingSessionViewModel({required PaymentService payments})
      : _payments = payments;

  final PaymentService _payments;
  bool paid = false;

  Future<void> endSession({required int minutes}) async {
    await _payments.charge(cents: minutes * 5);
    paid = true;
    notifyListeners();
  }
}

class FakePaymentService implements PaymentService {
  final charges = <int>[];

  @override
  Future<void> charge({required int cents}) async => charges.add(cents);
}

void main() {
  test('ending a 90-minute session charges 450 cents', () async {
    final fake = FakePaymentService();
    final vm = ParkingSessionViewModel(payments: fake);

    await vm.endSession(minutes: 90);

    expect(fake.charges, [450]);
    expect(vm.paid, isTrue);
  });
}

go deeper

for a junior

Explain that the class receives what it needs through its constructor and show a test passing a fake in.

for a middle

Justify the details: required named parameters, private final fields, abstract parameter types, and what each prevents.

for a senior

Place constructor injection at every layer boundary and keep the choice of tool, provider or get_it, confined to the composition root.

for a principal

Set the team rule for where object creation may happen, and how reviews catch hard-wired dependencies before they spread.

## What constructor injection is **Constructor injection** is the simplest form of dependency injection: a class lists the objects it depends on as **constructor parameters** and stores them. It never calls `CardPaymentService()` itself and never looks the object up in a global registry. Whoever creates the class decides which implementation it gets. Flutter's architecture guide uses this for every link between layers. Its case study states that communication between two layers happens by passing a component into the constructor of the component that consumes it, such as a service into a repository. ## The shape it takes in Dart The idiomatic Flutter version has three parts: - **`required` named parameters**, so a caller cannot forget a dependency and the call site reads clearly; - **private `final` fields**, so the view that holds a view model cannot reach past it into the data layer; - **abstract parameter types**, such as `PaymentService`, so any implementation fits. ```dart class ParkingSessionViewModel extends ChangeNotifier { ParkingSessionViewModel({required PaymentService payments}) : _payments = payments; final PaymentService _payments; } ``` ## Swapping the payment service in a test In a parking app, ending a session charges the driver's card. The real `CardPaymentService` talks to a payment API; a test must not. With constructor injection the test does the wiring itself: 1. Create a `FakePaymentService` that implements `PaymentService` and records charges. 2. Construct `ParkingSessionViewModel(payments: fake)`. 3. Call `endSession` and assert on what the fake recorded. Nothing global changes, so tests cannot leak into each other, and the test needs no Flutter binding because a view model is plain Dart. | Approach | How a test swaps the service | Risk | |---|---|---| | Constructor injection | Pass the fake to the constructor | None beyond writing the fake | | Created inside the class | Impossible without editing the class | Tests hit the real API | | Global locator lookup | Re-register the fake in shared state | Leaks between tests if not reset | ## Views take their view model the same way Constructor injection is not only for plain Dart classes. Flutter's guide says a view's inputs should usually be just a `key` and its view model, passed through the widget's constructor: `const CheckoutScreen({super.key, required this.viewModel})`. That has two effects: - the widget never decides which view model, repository or payment service it works with; - a widget test can build a `CheckoutViewModel` on top of a `FakePaymentService` and pump `CheckoutScreen(viewModel: vm)` with no registry and no provider. The same rule therefore runs from the data layer up to the widgets: every object is handed what it needs by whoever creates it. ## Where the real objects come from Something still has to call the constructors with real objects. That place is the **composition root**: - plain code in `main()` for a small app; - the provider package, which Flutter's guide recommends, placing services and repositories near the top of the widget tree and building view models in route builders; - a get_it service locator, whose registrations call the constructors. Whichever tool you pick, the classes themselves stay unaware of it. ## Patterns to question in review - `final _payments = CardPaymentService();` as a field initializer: the dependency is hard-wired. - An optional parameter with a real default, `PaymentService? payments` plus `?? CardPaymentService()`: tests can inject, but production code now imports the concrete class and a forgotten argument silently uses the real one. get_it's own README lists optional constructor parameters as a way to inject mocks, so treat it as a compromise, not the default. - Typing the parameter as `CardPaymentService`: a fake then has to extend the real class and inherits its behaviour.

  • Why keep the injected dependency in a private field?
    Flutter's guide makes injected repositories and services private so the object holding them cannot be used as a back door. A view holds its view model; if the view model's `PaymentService` were public, the view could charge a card directly and skip the view model's logic.
  • Does constructor injection remove the need for provider or get_it?
    No. It moves the choice of implementation to the caller, but some code must still create the real objects and pass them in. provider or get_it are ways to organise that composition root; the injected classes stay the same whichever you use.

saying these in an interview costs you the question

  • Constructor injection needs a DI framework to work in Flutter.
  • A view model should create its own payment service to stay self-contained.
  • Typing the parameter as the concrete class is fine because fakes can extend it.
  • Unit tests of a view model must pump a widget tree to inject dependencies.
  • Injected dependencies should be public so tests can replace them later.
open as a page

With get_it, how do registerSingleton, registerLazySingleton and registerFactory differ in when the object is created and how many instances exist?

level: middleimportance: must knowfreq 55%

basics

~20 s

registerSingleton stores an instance you already built, returned on every get; registerLazySingleton runs its factory on the first get and reuses that one object; registerFactory runs its factory on every get, returning a new object each time.

open as a page

With get_it, how do you swap the real PaymentService for a fake in tests without registrations leaking from one test case to the next?

level: middleimportance: should knowfreq 40%

basics

~20 s

Register the fake in setUp and await getIt.reset() in tearDown so every test starts empty. To override one type over an existing setup, set allowReassignment or push a new scope, register the fake there, and pop it afterwards.

open as a page

With the injectable package, how do annotations such as @Injectable(as:), @lazySingleton and @Environment become get_it registrations?

level: middleimportance: should knowfreq 28%

basics

~20 s

injectable's generator reads annotated classes and writes a .config.dart file whose init() calls get_it's register methods: @injectable becomes a factory, @singleton and @lazySingleton their get_it counterparts, as: binds an abstract type, and @Environment limits a class to matching environments.

open as a page

In Flutter's architecture guide, how does the provider package act as dependency injection, and how does a widget test inject a fake PaymentService?

level: middleimportance: should knowfreq 35%

basics

~20 s

The guide places services and repositories as Provider objects above the app, and route builders create view models with context.read(). A widget test wraps the screen in a Provider<PaymentService> that creates a FakePaymentService, so every lookup below it finds the fake.

open as a page

Why does calling GetIt.I<PaymentService>() inside a Flutter view model make it harder to test and reason about than constructor injection, and where does a service locator still fit?

level: seniorimportance: should knowfreq 42%

basics

~20 s

A lookup inside the class hides the dependency, fails only at runtime when nothing is registered, and forces tests to manage a global registry. Keep get_it at the composition root, where registrations call constructors, and inject everything else.

open as a page