skip to content

Codebase Layering

How an app is split into views and view models over repositories and services, with injected dependencies, commands, result types and feature packages. Interviewers probe where the testable seams are.

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

explore

questions

23

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

How does the official Flutter architecture guide's Compass app organize lib/, and why does it mix feature-first and layer-first folders?

level: juniorimportance: must knowfreq 58%

basics

~20 s

The Compass app groups lib/ui by feature, because each feature has one view and one view model, but groups lib/data by type, because repositories and services are shared across features. Domain models sit in lib/domain, used by both layers.

open as a page

In Flutter's official architecture guide, what are views, view models, repositories and services, and which layer does each belong to?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Flutter's architecture guide puts views (compositions of widgets) and view models (UI state and UI logic) in the UI layer, and repositories (the source of truth per data type) and services (stateless wrappers around external APIs) in the data layer.

open as a page

Why does Flutter's architecture guide return a sealed Result<T> of Ok or Error from services and repositories instead of throwing exceptions?

level: middleimportance: must knowfreq 45%

basics

~20 s

Dart methods never declare what they throw, so callers forget to catch. A sealed Result<T> puts failure in the return type: the caller must unwrap Ok or Error, typically with a switch, before it can use the value.

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

When a Flutter app's wallet feature becomes a local package, what should its top-level library export from lib/src, and what should stay internal?

level: middleimportance: must knowfreq 45%

basics

~20 s

Export only what other packages legitimately use: the feature's routes or entry widget, the contract implementations the app shell wires, a setup function and every type in those signatures. View models, repositories, services, DTOs and private widgets stay under lib/src.

open as a page

In Flutter's recommended MVVM architecture, which responsibilities belong in a view model and which belong in a repository?

level: middleimportance: must knowfreq 50%

basics

~20 s

A view model turns domain data into UI state, keeps view state such as filters, and exposes callbacks for the view. A repository owns one data type: fetching, caching, retry, error handling and mapping raw data into domain models.

open as a page

In Flutter's architecture guide, how does a view use a view model's Command to show a spinner and ignore double taps while 'save draft' runs?

level: juniorimportance: should knowfreq 38%

basics

~10 s

The view wraps the button in a ListenableBuilder listening to the command, shows a progress indicator while command.running is true, and wires onPressed to execute(), which returns early if the command is already running.

open as a page

In Flutter's architecture guide, what does the full Command class do on execute(), and how do running, error, completed, result and clearResult() fit together?

level: middleimportance: should knowfreq 33%

basics

~20 s

execute() returns if already running, otherwise sets running, clears the last result, notifies, awaits the action's Result, then resets running and notifies. error and completed are derived from whether that Result is Error or Ok; clearResult() resets it.

open as a page

In a Flutter view model, why hold screen state in one immutable class updated with copyWith, and what trap does a hand-written copyWith hit with nullable fields?

level: middleimportance: should knowfreq 32%

basics

~20 s

An immutable state class replaced through copyWith keeps every field consistent and lets the view read one snapshot. A hand-written copyWith using nullable parameters with ?? cannot set a field back to null; freezed's generated copyWith can.

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

In a multi-package Flutter repository managed with Melos, what does melos bootstrap do, and how do Melos scripts run a command across packages?

level: middleimportance: should knowfreq 30%

basics

~20 s

melos bootstrap resolves every package's dependencies so local packages use each other's source, and runs bootstrap hooks. Scripts under the melos: key run a command once, or with exec run it inside each package matching packageFilters.

open as a page

In a Flutter app following the architecture guide's MVVM, how does a tap reach the repository, and how does the new state reach the widgets?

level: middleimportance: should knowfreq 42%

basics

~20 s

The widget's callback calls a view model method, which asks the repository to change the data; the repository returns new data, the view model updates its state and calls notifyListeners(), and a ListenableBuilder rebuilds the view.

open as a page

In Flutter's architecture guide, how does a service differ from a repository, and why do repositories return domain models instead of API models?

level: middleimportance: should knowfreq 40%

basics

~20 s

A service wraps one external data source and holds no state; a repository owns one data type, calling services and adding caching and retry. Repositories return domain models so view models never depend on raw API shapes.

open as a page

In a Flutter view model following the architecture guide, how would you make 'save draft' optimistic, and how do you roll back safely when the save fails offline?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Update the UI state as if the save succeeded and notify, then await the repository. On an Error result, restore the state captured before the change, flag the error for a one-shot message, and notify again.

open as a page

A Flutter 'save draft' Command1 wraps an action that throws a SocketException instead of returning Result.error; what does the guide's Command do, and how do you fix it?

level: seniorimportance: should knowfreq 25%

basics

~20 s

The full Command awaits the action in try/finally without a catch, so the exception escapes execute(); running resets but result stays null, leaving error and completed false and the UI silent. Return Result.error from the repository instead.

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

When splitting a Flutter super-app's rides, food and wallet features into local packages, how should the packages depend on each other, and where is the app wired together?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Dependencies point one way: the app shell depends on every feature, features depend only on shared core packages, and features never depend on each other. The shell composes the router, injects cross-feature contracts and owns main.dart and the platform folders.

open as a page

In Flutter's architecture guide, when should you add the optional domain layer of use cases, and what dependency rules come with it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Add use cases only for view model logic that merges several repositories, is very complex, or is reused by several view models. Use cases depend on repositories; view models may depend on both use cases and repositories.

open as a page

A Flutter FlightSearchViewModel exposes its repository as a public field, calls FlightApiService directly, and its FlightRepository calls TripRepository; which architecture-guide rules does this break, and how would you fix it?

level: seniorimportance: should knowfreq 35%

basics

~20 s

It breaks three rules: repositories stay private to view models, view models never call services, and repositories never know each other. Make the fields private, go through the repository, and merge trip data in the view model or a use case.

open as a page

Three teams own the rides, food and wallet features of one Flutter super-app; how would you decide the package boundaries, what goes in shared packages, and when not to split?

level: principalimportance: should knowfreq 24%

basics

~20 s

Draw package boundaries along team ownership: one package per feature, a thin set of shared core packages, and an app shell. Split only when seams are stable and teams collide; otherwise keep feature-first folders in one package.

open as a page