In a Flutter app, what is constructor injection, and how does it let a test swap a real payment service for a fake?
answer
- dependencies arrive, never created inside
- required named parameters
- private final fields
- typed as the abstract class
- the test calls the constructor
basics
~10 sConstructor 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 sWith 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 linesimport '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
Explain that the class receives what it needs through its constructor and show a test passing a fake in.
Justify the details: required named parameters, private final fields, abstract parameter types, and what each prevents.
Place constructor injection at every layer boundary and keep the choice of tool, provider or get_it, confined to the composition root.
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.