Architecture all 6 written
Layers, dependency injection, error modelling and offline. What "architectural thinking" means in an advert.
24 Layers that survive a feature request
Layers are not folders. A layer is a promise about which direction the dependencies point, and the only version of that promise worth anything is one the compiler enforces. Get the direction right and a feature request touches one layer. Get it wrong and every request touches all three.
The rule, and the three names
- Presentation — widgets, and whatever holds their state. Knows the domain. Knows nothing about http, SQL or JSON.
- Domain — entities, rules and use cases. Knows nothing outside itself. Not Flutter, not the network, not the database.
- Data — clients, DTOs, caches, and the mappers between the wire and the domain. Knows the domain, because it implements the domain's interfaces.
That last point is the one people miss. The interface a repository implements is declared in the domain and implemented in data, so the arrow points inward even though the call at run time goes outward. That inversion is the entire trick.
// ---------- DOMAIN ----------------------------------------------------------
// Knows nothing below it. No JSON, no http, no widgets, no Flutter import.
class Task {
const Task({required this.id, required this.title, required this.done});
final String id;
final String title;
final bool done;
/// A business rule, and it lives here rather than in a screen or a client.
Task complete() => Task(id: id, title: title, done: true);
}
/// The port. Declared by the domain, in the domain's own vocabulary.
abstract interface class TaskPort {
Future<Task?> byId(String id);
Future<void> save(Task task);
}
/// The use case: one job, and the only place the rules are decided.
class CompleteTask {
const CompleteTask(this.tasks);
final TaskPort tasks;
Future<String> call(String id) async {
final task = await tasks.byId(id);
if (task == null) return 'no such task';
if (task.done) return 'already done';
await tasks.save(task.complete());
return 'completed ${task.title}';
}
}
// ---------- DATA -----------------------------------------------------------
// Knows the wire shape and the mapping. Depends on the domain above. The
// domain does not depend on this.
typedef Row = Map<String, Object?>;
class TaskApi {
TaskApi(this.rows);
final List<Row> rows;
Future<Row?> get(String id) async {
await Future<void>.delayed(Duration.zero);
for (final row in rows) {
if (row['task_id'] == id) return row;
}
return null;
}
Future<void> put(Row row) async {
rows.removeWhere((r) => r['task_id'] == row['task_id']);
rows.add(row);
}
}
class TaskRepository implements TaskPort {
TaskRepository(this._api);
final TaskApi _api;
/// The mapper IS the boundary. It is a real place in the code, and it is
/// where the wire's spelling stops.
Task _toDomain(Row row) => Task(
id: row['task_id']! as String,
title: row['task_title']! as String,
done: row['is_done']! as bool,
);
Row _toWire(Task task) => {
'task_id': task.id,
'task_title': task.title,
'is_done': task.done,
};
@override
Future<Task?> byId(String id) async {
final row = await _api.get(id);
return row == null ? null : _toDomain(row);
}
@override
Future<void> save(Task task) => _api.put(_toWire(task));
}
// ---------- PRESENTATION ---------------------------------------------------
// Talks to the use case. Formats for a screen. Never sees a Row.
class TaskViewModel {
TaskViewModel(this._complete);
final CompleteTask _complete;
final messages = <String>[];
Future<void> tapped(String id) async => messages.add(await _complete(id));
}
Future<void> main() async {
final api = TaskApi([
{'task_id': 't1', 'task_title': 'buy milk', 'is_done': false},
{'task_id': 't2', 'task_title': 'call mum', 'is_done': true},
]);
// Wiring, and this is the only place all three layers are named together.
final screen = TaskViewModel(CompleteTask(TaskRepository(api)));
await screen.tapped('t1');
await screen.tapped('t1');
await screen.tapped('t9');
print('screen: ${screen.messages}');
print('wire row: ${api.rows.firstWhere((r) => r['task_id'] == 't1')}');
test('the use case applied the rule and wrote', screen.messages[0], 'completed buy milk');
test('the second tap is answered by the rule, not the network',
screen.messages[1], 'already done');
test('a missing id is an answer, not a crash', screen.messages[2], 'no such task');
test('the data layer wrote the wire shape back',
api.rows.firstWhere((r) => r['task_id'] == 't1')['is_done'], true);
test('and the wire spelling never left the data layer',
api.rows.first.keys.toList(), ['task_id', 'task_title', 'is_done']);
test('the domain rule is testable with nothing plugged in at all',
const Task(id: 't', title: 'x', done: false).complete().done, true);
}
What it printed, when the build ran it
screen: [completed buy milk, already done, no such task]
wire row: {task_id: t1, task_title: buy milk, is_done: true}
ok the use case applied the rule and wrote
ok the second tap is answered by the rule, not the network
ok a missing id is an answer, not a crash
ok the data layer wrote the wire shape back
ok and the wire spelling never left the data layer
ok the domain rule is testable with nothing plugged in at all
Notice where the wiring happened: one line in main. Every layer named its
dependency as a parameter and let someone else decide what to pass. That is dependency
injection, and lesson 25 is about nothing else. Notice also the mapper — three lines of
dull field copying that everyone is tempted to delete.
The payoff, which is the only reason to bother
Layering costs you indirection today and pays out the first time a dependency changes. Here the rate comes from the network, then from a local table, and the domain and the presentation are not edited between the two.
// ---------- DOMAIN. Written once, and not edited again in this panel. -------
class Rate {
const Rate(this.pair, this.value);
final String pair;
final double value;
}
abstract interface class RatePort {
Future<Rate> latest(String pair);
}
class ConvertMoney {
const ConvertMoney(this.rates);
final RatePort rates;
Future<String> call(String pair, double amount) async {
final rate = await rates.latest(pair);
return '${(amount * rate.value).toStringAsFixed(2)} $pair';
}
}
// ---------- DATA. Two of them. Neither is known to the domain. -------------
class HttpRates implements RatePort {
@override
Future<Rate> latest(String pair) async {
await Future<void>.delayed(Duration.zero);
final body = <String, Object?>{'symbol': pair, 'px': 1.25};
return Rate(body['symbol']! as String, body['px']! as double);
}
}
class TableRates implements RatePort {
TableRates(this.rows);
final List<(String, double)> rows;
@override
Future<Rate> latest(String pair) async {
final row = rows.firstWhere((r) => r.$1 == pair);
return Rate(row.$1, row.$2);
}
}
/// And the third implementation, which is a test: three lines, no I/O.
class FakeRates implements RatePort {
FakeRates(this.fixed);
final double fixed;
@override
Future<Rate> latest(String pair) async => Rate(pair, fixed);
}
/// Reports the type an expression has to the COMPILER, which is not always
/// the type that turns up at run time.
Type declaredType<T>(T _) => T;
Future<void> main() async {
final overHttp = ConvertMoney(HttpRates());
final overTable = ConvertMoney(TableRates([('GBPUSD', 1.25)]));
final fromHttp = await overHttp('GBPUSD', 80);
final fromTable = await overTable('GBPUSD', 80);
print('over http: $fromHttp');
print('over table: $fromTable');
print('domain compiled against ${declaredType(overHttp.rates)}, '
'got ${overHttp.rates.runtimeType}');
test('the use case, over the network', fromHttp, '100.00 GBPUSD');
test('and over a local table, the same answer', fromTable, fromHttp);
test('the two sources are different classes',
overHttp.rates.runtimeType == overTable.rates.runtimeType, false);
test('the domain was compiled against the port it wrote',
declaredType(overHttp.rates), RatePort);
test('what turned up at run time is a data class it has never heard of',
overHttp.rates.runtimeType, HttpRates);
test('and a test is just a third implementation',
await ConvertMoney(FakeRates(2)).call('GBPUSD', 10), '20.00 GBPUSD');
}
What it printed, when the build ran it
over http: 100.00 GBPUSD over table: 100.00 GBPUSD domain compiled against RatePort, got HttpRates ok the use case, over the network ok and over a local table, the same answer ok the two sources are different classes ok the domain was compiled against the port it wrote ok what turned up at run time is a data class it has never heard of ok and a test is just a third implementation
The two assertions in the middle are the argument. declaredType reports
RatePort — the type the domain was compiled against — while
runtimeType reports HttpRates. The domain has no name for the
class it is actually talking to, so there is nothing in it to edit when that class is
replaced. And the last assertion is the same property seen from the test's side: your
fake is not a testing trick, it is a fourth implementation of a contract the domain
already published.
What a violation looks like
The rule is worth having because breaking it is a compile error, not a review comment. Below, the domain is handed the data layer's shape directly, which is exactly what happens at 5pm when the mapper looks like pointless typing.
// ---------- DOMAIN ----------------------------------------------------------
class Money {
const Money(this.pence);
final int pence;
}
String receipt(Money total) => 'total ${total.pence}p';
// ---------- DATA -----------------------------------------------------------
// What the endpoint actually sends. The data layer's shape, and its spelling.
Map<String, Object?> totalRow() => {'total_pence': 1299};
void main() {
// Six months in, in a hurry: the row is right there and it has the number in
// it, so why map it. This is the layer violation, and it is a compile error.
print(receipt(totalRow()));
}
What the analyser said
line 16: The argument type 'Map<String, Object?>' can't be assigned to the parameter type 'Money'. [argument_type_not_assignable]
Now imagine the version that does compile: receipt takes a
Map<String, Object?> and reads total_pence out of it.
Nothing breaks today. But the domain now knows the wire's spelling, so renaming a JSON
field is a change to your business rules, and testing the receipt logic needs a fake JSON
body. The mapper you skipped was buying both of those.
The honest cost of mappers. Two classes with the same fields and a function between them feels like duplication, and sometimes it is — if your API is yours, changes with your app, and is used by nothing else, one shared model is a reasonable call and you should be able to say why. The moment the API is owned by another team, versioned, or shared with a web client, the second model stops being duplication and starts being the thing that stops their sprint from being your regression.
Feature-first or layer-first
Both put the same three names in the tree. They differ in what sits at the top.
- Layer-first —
lib/presentation/,lib/domain/,lib/data/, with features inside each. Easy to explain, and fine for a small app. The cost shows up in review: "add coupons" touches three top-level trees, two people editing different features collide in the same folders, and deleting a feature means hunting its pieces across the app. - Feature-first —
lib/features/checkout/, withpresentation/ domain/ data/inside it. One feature is one folder, so it can be reviewed in one place, owned by one person and deleted in one commit. This is what most teams above about four people end up at, and the reason is usually merge conflicts rather than aesthetics. - Feature-first still has layers. It nests them inside features
instead of above them. A folder called
checkoutwith sixteen files and no internal structure is not feature-first, it is unlayered. - The two real questions it raises. Where do shared things live, and
may one feature import another? Answer both explicitly on day one, or
lib/core/becomes the folder nobody can reason about. A workable answer: shared domain concepts get promoted to a shared module, and cross-feature access goes through a narrow port rather than reaching into another feature's data layer.
Say this part out loud if asked: the folder layout is the cheap half of the decision. You can honour the dependency rule in either shape and break it in either shape. Which is why it is worth making the rule checkable.
/// The dependency rule, written down as data: which layer may import which.
const rule = <String, Set<String>>{
'presentation': {'presentation', 'domain'},
'domain': {'domain'},
'data': {'data', 'domain'},
};
String layerOf(String path) => path.split('/')[1];
/// Given every file and what it imports, report the edges that break the rule.
/// This is about fifteen lines and it runs in CI, which is the difference
/// between a layering you have and a layering you only meant.
List<String> violations(Map<String, List<String>> imports) {
final out = <String>[];
for (final file in imports.entries) {
final from = layerOf(file.key);
for (final target in file.value) {
final to = layerOf(target);
if (!rule[from]!.contains(to)) out.add('$from -> $to in ${file.key}');
}
}
return out;
}
void main() {
final good = <String, List<String>>{
'lib/presentation/task_page.dart': ['lib/domain/complete_task.dart'],
'lib/domain/complete_task.dart': ['lib/domain/task.dart'],
'lib/data/task_repository.dart': ['lib/domain/task.dart', 'lib/data/task_api.dart'],
};
// One import added in a hurry, six months in.
final drifted = <String, List<String>>{
...good,
'lib/domain/complete_task.dart': ['lib/domain/task.dart', 'lib/data/task_api.dart'],
};
print('clean: ${violations(good)}');
print('drifted: ${violations(drifted)}');
test('presentation reaching into the domain is fine', violations(good), <String>[]);
test('so is the data layer implementing a domain port', rule['data']!.contains('domain'), true);
test('the domain may reach exactly one layer, its own', rule['domain'], {'domain'});
test('the one bad import is caught', violations(drifted).length, 1);
test('and named, with a direction',
violations(drifted).first, 'domain -> data in lib/domain/complete_task.dart');
}
What it printed, when the build ran it
clean: [] drifted: [domain -> data in lib/domain/complete_task.dart] ok presentation reaching into the domain is fine ok so is the data layer implementing a domain port ok the domain may reach exactly one layer, its own ok the one bad import is caught ok and named, with a direction
Real projects do this with a lint rule or a small script over the import graph rather than a hand-written map, but the shape is the same and it is worth having. Layering decays one convenient import at a time, and it decays silently, because every single one of those imports was the fastest way to ship that afternoon.
When three layers are too many. A settings screen that flips two booleans does not need an entity, a port, a repository, a mapper and a use case. Applying the full stack everywhere is the mistake that gives clean architecture its reputation, and it is a mistake a reviewer can see. The defensible position, and the one worth saying in an interview: the dependency rule everywhere, the number of layers per feature. A use case that only forwards a call to a repository is a layer you have not earned yet — and it is easy to add later, because the direction of the arrows already lets you.
25 Dependency injection without magic
Dependency injection is one idea: a class asks for what it needs instead of making it. Everything else — locators, containers, generated code — is delivery, and delivery is where the trade-offs live. Start with the version that needs no library at all, because it is also the one with the strongest guarantee.
Constructor injection, and why it is the default
- The dependencies are the signature. Reading the constructor tells you everything the class touches. Four parameters is a design review you get for free.
- A missing dependency will not compile. That is the whole argument against a locator, and it is a strong one.
- Nothing global is involved, so two instances with different collaborators can exist at once — in production, and in two tests running side by side.
abstract interface class Mailer {
Future<void> send(String to, String body);
}
/// The real one. In a unit test this opens a socket, which is exactly why
/// nobody wants it there.
class SmtpMailer implements Mailer {
@override
Future<void> send(String to, String body) async =>
throw StateError('SmtpMailer opened a socket to $to');
}
/// A fake is not a testing trick. It is a second implementation of a contract
/// somebody already wrote down.
class RecordingMailer implements Mailer {
final sent = <String>[];
@override
Future<void> send(String to, String body) async => sent.add('$to: $body');
}
/// Version one: makes its own dependency. Nothing can get between the two.
class SignupSelfMade {
final Mailer _mailer = SmtpMailer();
Future<String> register(String email) async {
await _mailer.send(email, 'welcome');
return 'registered $email';
}
}
/// Version two: asks for it. One word different, and now it is testable.
class Signup {
const Signup(this._mailer);
final Mailer _mailer;
Future<String> register(String email) async {
await _mailer.send(email, 'welcome');
return 'registered $email';
}
}
Future<void> main() async {
// Trying to test the self-made version reaches the real thing.
String reached;
try {
await SignupSelfMade().register('ada@example.com');
reached = 'no side effect';
} on StateError catch (e) {
reached = e.message;
}
final fake = RecordingMailer();
final result = await Signup(fake).register('ada@example.com');
// Two instances, two collaborators, no global state between them.
final other = RecordingMailer();
await Signup(other).register('grace@example.com');
print('self-made: $reached');
print('injected: $result');
print('recorded: ${fake.sent}');
test('the self-made version reaches the real collaborator in a test',
reached, 'SmtpMailer opened a socket to ada@example.com');
test('the injected version talks to whatever it was handed',
result, 'registered ada@example.com');
test('so the test can read what was sent', fake.sent, ['ada@example.com: welcome']);
test('and two of them run side by side without interfering',
[fake.sent.length, other.sent.length], [1, 1]);
}
What it printed, when the build ran it
self-made: SmtpMailer opened a socket to ada@example.com injected: registered ada@example.com recorded: [ada@example.com: welcome] ok the self-made version reaches the real collaborator in a test ok the injected version talks to whatever it was handed ok so the test can read what was sent ok and two of them run side by side without interfering
A useful diagnostic falls out of that: if a class is hard to test, it usually
has a dependency it makes itself. Not always the network — a
DateTime.now(), a random number, a singleton read from
SharedPreferences. The fix is the same each time, and it is one parameter.
So why does anyone need a container
Because the composition root gets far away. A widget nine levels down needs the auth
token, and threading it through nine constructors that do not care is worse than the
problem. Flutter's own answer is the tree — InheritedWidget from lesson 20, and
the providers built on it. The other answer is a service locator, and it is short enough to
write out.
/// A locator is a map from a type to a way of making that type. That is all
/// get_it is, plus about two thousand lines of care.
class Locator {
final _makers = <Type, Object Function()>{};
final _cached = <Type, Object>{};
final _isSingleton = <Type>{};
/// A new one every time it is asked for.
void addFactory<T extends Object>(T Function() make) {
_makers[T] = make;
}
/// One, built on first use, then reused for the life of the app.
void addLazySingleton<T extends Object>(T Function() make) {
_makers[T] = make;
_isSingleton.add(T);
}
T get<T extends Object>() {
final ready = _cached[T];
if (ready != null) return ready as T;
final make = _makers[T];
if (make == null) {
// The cost of the whole approach, in one line: this is a run-time
// failure where a constructor parameter would have been a compile error.
throw StateError('nothing registered for $T');
}
final made = make() as T;
if (_isSingleton.contains(T)) _cached[T] = made;
return made;
}
}
class ApiClient {
ApiClient(this.builds) {
builds.add('ApiClient built');
}
final List<String> builds;
}
class Request {
Request(this.builds) {
builds.add('Request built');
}
final List<String> builds;
}
void main() {
final builds = <String>[];
final locator = Locator()
..addLazySingleton<ApiClient>(() => ApiClient(builds))
..addFactory<Request>(() => Request(builds));
print('before any get: $builds');
final a = locator.get<ApiClient>();
final b = locator.get<ApiClient>();
final r1 = locator.get<Request>();
final r2 = locator.get<Request>();
String missing;
try {
locator.get<Locator>();
missing = 'no error';
} on StateError catch (e) {
missing = e.message;
}
print('after: $builds');
test('a lazy singleton is not built until something asks', builds.first, 'ApiClient built');
test('and then it is the same object every time', identical(a, b), true);
test('a factory hands back a new one each time', identical(r1, r2), false);
test('so it was built twice, and the singleton once', builds.length, 3);
test('and a type nobody registered fails at run time, not at compile time',
missing, 'nothing registered for Locator');
}
What it printed, when the build ran it
before any get: [] after: [ApiClient built, Request built, Request built] ok a lazy singleton is not built until something asks ok and then it is the same object every time ok a factory hands back a new one each time ok so it was built twice, and the singleton once ok and a type nobody registered fails at run time, not at compile time
Twenty lines, and it does the job. The last assertion is the price of admission: the dependency has moved out of the signature, so nothing checks it until the code runs. In an app that means a crash on a screen nobody opened during QA.
The part people forget: it is a global
The locator is usually a top-level variable, which means every test in your suite shares it. Below is one panel with two "tests" in it, run in both orders.
/// The locator from above, trimmed, plus the reset that everyone forgets.
class Locator {
final _makers = <Type, Object Function()>{};
void add<T extends Object>(T Function() make) => _makers[T] = make;
T get<T extends Object>() {
final make = _makers[T];
if (make == null) throw StateError('nothing registered for $T');
return make() as T;
}
void reset() => _makers.clear();
}
/// Global, because that is the point of a locator and also its problem.
Locator sl = Locator();
abstract interface class Prices {
int of(String sku);
}
class LivePrices implements Prices {
@override
int of(String sku) => 999;
}
class FreePrices implements Prices {
@override
int of(String sku) => 0;
}
/// The dependency is nowhere in this signature. You cannot tell from the call
/// site that this reads a global.
int checkout(String sku) => sl.get<Prices>().of(sku);
/// A test that sets up a fake, and forgets to clean up.
int discountDayTest() {
sl.add<Prices>(FreePrices.new);
return checkout('tea');
}
/// A test that expects the real price, and registers nothing because the app's
/// bootstrap "already does that".
int normalPriceTest() => checkout('tea');
void main() {
sl = Locator()..add<Prices>(LivePrices.new);
final discountFirst = [discountDayTest(), normalPriceTest()];
sl = Locator()..add<Prices>(LivePrices.new);
final normalFirst = [normalPriceTest(), discountDayTest()];
// The same suite, with reset() between tests.
sl = Locator();
String loud;
try {
normalPriceTest();
loud = 'passed for no reason';
} on StateError catch (e) {
loud = e.message;
}
print('discount first: $discountFirst');
print('normal first: $normalFirst');
print('reset between: $loud');
test('the first test gets its fake', discountFirst[0], 0);
test('and the second inherits it, quietly', discountFirst[1], 0);
test('reordered, the same second test sees the live price', normalFirst[0], 999);
test('so the suite result depends on the order the tests ran in',
[discountFirst.join(','), normalFirst.join(',')], ['0,0', '999,0']);
test('a locator emptied between tests fails loudly instead',
loud, 'nothing registered for Prices');
}
What it printed, when the build ran it
discount first: [0, 0] normal first: [999, 0] reset between: nothing registered for Prices ok the first test gets its fake ok and the second inherits it, quietly ok reordered, the same second test sees the live price ok so the suite result depends on the order the tests ran in ok a locator emptied between tests fails loudly instead
That second assertion is the bug, and it is nastier than a failing test. The test passed. It asserted the wrong number and was satisfied, because someone else's fake was still registered. Suites with a shared locator and no reset drift into a state where a subset of tests only pass together, and the person who finally runs one file on its own gets blamed for breaking it.
The mitigations are known and worth naming: reset the container in
setUp, register fakes through an explicit override rather than by re-adding,
and keep the locator out of the code under test — resolve at the edge, inject the rest of
the way down. Which is the compromise most teams land on: a locator at the
composition root, constructor injection everywhere below it.
Lifetimes, which is the question people fumble
Three answers, and each is a decision about when the object dies.
enum Life { singleton, transient, scoped }
abstract interface class Closeable {
void close();
}
/// Per-user or per-screen state. The kind of thing that must not outlive the
/// screen or the login that created it.
class CartSession implements Closeable {
CartSession(this.id, this._log);
final String id;
final List<String> _log;
@override
void close() => _log.add('closed $id');
}
class Container {
final _makers = <Type, Object Function()>{};
final _lives = <Type, Life>{};
final _app = <Type, Object>{};
final _scopes = <Map<Type, Object>>[];
void register<T extends Object>(Life life, T Function() make) {
_makers[T] = make;
_lives[T] = life;
}
void openScope() => _scopes.add({});
/// Closing a scope is the only reason to have one: it disposes what it held.
void closeScope() {
for (final held in _scopes.removeLast().values) {
if (held is Closeable) held.close();
}
}
T get<T extends Object>() {
if (_lives[T] == Life.transient) return _makers[T]!() as T;
final home = _lives[T] == Life.singleton || _scopes.isEmpty ? _app : _scopes.last;
return (home[T] ??= _makers[T]!()) as T;
}
}
void main() {
final log = <String>[];
var carts = 0;
final c = Container()
..register<ApiClient>(Life.singleton, ApiClient.new)
..register<Query>(Life.transient, Query.new)
..register<CartSession>(Life.scoped, () => CartSession('cart-${++carts}', log));
final api = c.get<ApiClient>();
c.openScope();
final firstCart = c.get<CartSession>();
final sameCart = c.get<CartSession>();
c.closeScope();
c.openScope();
final secondCart = c.get<CartSession>();
c.closeScope();
print('carts made: $carts, log: $log');
test('a singleton is the same object for the life of the app',
identical(api, c.get<ApiClient>()), true);
test('a transient is a fresh object every time',
identical(c.get<Query>(), c.get<Query>()), false);
test('a scoped object is shared inside its scope',
identical(firstCart, sameCart), true);
test('and the next scope gets its own', identical(firstCart, secondCart), false);
test('closing the scope disposed what it held', log, ['closed cart-1', 'closed cart-2']);
test('while the singleton is untouched by any of it',
identical(api, c.get<ApiClient>()), true);
}
class ApiClient {}
class Query {}
What it printed, when the build ran it
carts made: 2, log: [closed cart-1, closed cart-2] ok a singleton is the same object for the life of the app ok a transient is a fresh object every time ok a scoped object is shared inside its scope ok and the next scope gets its own ok closing the scope disposed what it held ok while the singleton is untouched by any of it
Mapping those onto real objects is the actual skill:
- Singleton — stateless clients and things that are expensive to build: the http client, the database connection, a logger. Safe precisely because they hold no user data.
- Transient — anything that holds state for one use. A request builder, a form validator, a Bloc for a screen you might have two of.
- Scoped — anything whose life matches a login, a flow or a route. The cart, the current user's profile cache, an upload queue for one document.
The bug this prevents is the one you have seen. User logs out, next user logs in, and the app shows the previous person's name or basket. That is almost always per-user state registered as a singleton, because singleton is the default and it worked in testing with one account. A login scope, closed on logout, makes the whole class of bug structurally impossible — and saying that sentence in an interview is worth more than naming any package.
The packages, for recognition
// pubspec: get_it, injectable (annotations) and injectable_generator (build).
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
final sl = GetIt.instance;
void bootstrap() {
// The three lifetimes you built above, under their real names.
sl.registerLazySingleton<ApiClient>(ApiClient.new);
sl.registerFactory<Query>(Query.new);
// Scopes: push on login, pop on logout, and everything scoped goes with it.
sl.pushNewScope(
scopeName: 'session',
init: (scope) => scope.registerLazySingleton<CartSession>(CartSession.new),
dispose: () async {},
);
// sl.popScope() on logout. dispose: is where you close streams and files.
// The reset a test suite needs, and the override that makes a fake explicit.
// await sl.reset();
// sl.registerSingleton<Prices>(FakePrices());
}
// injectable is a code generator over the same container. You annotate, and it
// writes the bootstrap() above for you.
@LazySingleton(as: Prices)
class LivePrices implements Prices {
LivePrices(this.client); // constructor params are resolved for you
final ApiClient client;
@override
int of(String sku) => 999;
}
@module
abstract class ThirdPartyModule {
// For things you do not own the constructor of.
@preResolve
Future<Database> get db => Database.open();
}
// Then: dart run build_runner build, and call configureDependencies() in main.
get_it and injectable are not installed, and no panel in this course claims a verdict on code it did not run. Everything the container above does was built and tested from scratch in the panels above; this is here so the names are familiar.
Two things to notice in that listing. injectable resolves
constructor parameters, so the generated container pushes you back towards
constructor injection — the generator is a wiring tool, not a replacement for the design.
And pushNewScope is the login-scope answer above, which is the feature worth
knowing get_it for. If asked to defend a choice: constructor injection as the default, one
container at the composition root, a code generator only once the wiring file is genuinely
long enough to be a chore. A hundred-line bootstrap you can read beats a generated one you
cannot.
26 Modelling failure in the type system
Dart's exceptions are invisible. Nothing in a signature says a
function throws, and nothing makes a caller handle it — so "this can fail" is a fact that
lives in documentation, or in a stack trace from a user. A Result type moves
that fact into the return type, where the compiler can hold you to it.
The type, and the three methods it needs
It is a sealed class with two cases, exactly like the load state in lesson 4. The
interesting part is the combinators: without them every call site turns into a
switch and people give up by Thursday.
/// Sealed, so the compiler knows the list of cases is closed. Two type
/// parameters, because the failure deserves a type as much as the success.
sealed class Result<T, E> {
const Result();
/// Change the success. Leave a failure alone.
Result<U, E> map<U>(U Function(T value) f) => switch (this) {
Ok(:final value) => Ok(f(value)),
Err(:final error) => Err(error),
};
/// Chain a step that can itself fail. This is the method that makes the type
/// worth having: the first failure short-circuits and nothing after it runs.
Result<U, E> then<U>(Result<U, E> Function(T value) f) => switch (this) {
Ok(:final value) => f(value),
Err(:final error) => Err(error),
};
/// Collapse both sides into one type. Every screen ends in a fold.
R fold<R>(R Function(T value) onOk, R Function(E error) onErr) => switch (this) {
Ok(:final value) => onOk(value),
Err(:final error) => onErr(error),
};
T getOrElse(T Function(E error) fallback) => fold((v) => v, fallback);
}
class Ok<T, E> extends Result<T, E> {
const Ok(this.value);
final T value;
}
class Err<T, E> extends Result<T, E> {
const Err(this.error);
final E error;
}
// Two steps that can each fail, and a log so we can see which of them ran.
final ran = <String>[];
Result<int, String> parseAge(String raw) {
ran.add('parse');
final n = int.tryParse(raw);
if (n == null) return const Err('not a number');
return Ok(n);
}
Result<String, String> band(int age) {
ran.add('band');
if (age < 18) return const Err('under age');
return Ok(age < 65 ? 'adult' : 'senior');
}
String screen(Result<String, String> r) =>
r.fold((v) => 'band: $v', (e) => 'sorry: $e');
void main() {
final good = parseAge('42').then(band);
final unparseable = parseAge('x').then(band);
final tooYoung = parseAge('9').then(band);
print('good: ${screen(good)}');
print('unparseable: ${screen(unparseable)}');
print('too young: ${screen(tooYoung)}');
print('steps run: $ran');
test('a good value flows through both steps', screen(good), 'band: adult');
test('the first failure short-circuits', screen(unparseable), 'sorry: not a number');
test('so the second step ran twice, not three times',
ran.where((s) => s == 'band').length, 2);
test('a failure from the second step is carried the same way',
screen(tooYoung), 'sorry: under age');
test('map touches the success only', parseAge('7').map((n) => n * 2).getOrElse((_) => -1), 14);
test('and does nothing at all to a failure',
parseAge('x').map((n) => n * 2).getOrElse((_) => -1), -1);
}
What it printed, when the build ran it
good: band: adult unparseable: sorry: not a number too young: sorry: under age steps run: [parse, band, parse, parse, band] ok a good value flows through both steps ok the first failure short-circuits ok so the second step ran twice, not three times ok a failure from the second step is carried the same way ok map touches the success only ok and does nothing at all to a failure
Three things fell out of that. Failure is in the return type, so a caller cannot fail to
notice it. then gives you a pipeline that stops at the first problem without a
single early return. And fold is the only place the two paths meet, which tends
to be exactly where the screen is drawn.
The guarantee, proved
The reason for the sealed class rather than a record with two nullable fields is the same reason as in lesson 4. Miss a case and it does not compile.
sealed class Result<T, E> {}
class Ok<T, E> extends Result<T, E> {
Ok(this.value);
final T value;
}
class Err<T, E> extends Result<T, E> {
Err(this.error);
final E error;
}
/// The failure path was going to be added later.
String show(Result<int, String> r) => switch (r) {
Ok(value: final v) => 'stock: $v',
};
void main() {
print(show(Ok(3)));
}
What the analyser said
line 14: The type 'Result<int, String>' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Err<int, String>()'. Try adding a wildcard pattern or cases that match 'Err<int, String>()'. [non_exhaustive_switch_expression]
Give the failure a type, not a sentence
Most Result code in the wild is Result<T, String>, which
throws away the thing that was worth having. A string cannot be switched on, so the UI ends
up printing it — and a printed error message is the same dead end as a caught exception with
a nice toString. Make E a sealed family and each failure becomes a
decision.
sealed class Result<T, E> {
const Result();
}
class Ok<T, E> extends Result<T, E> {
const Ok(this.value);
final T value;
}
class Err<T, E> extends Result<T, E> {
const Err(this.error);
final E error;
}
/// The failure type is a family, and each case carries what the UI needs to
/// act. This is the half of Result that people skip.
sealed class SaveError {
const SaveError();
}
class Offline extends SaveError {
const Offline(this.queued);
final int queued;
}
class Rejected extends SaveError {
const Rejected(this.field, this.why);
final String field;
final String why;
}
class SessionExpired extends SaveError {
const SessionExpired();
}
/// One switch, four arms, four different things the app should DO. Nested
/// patterns match the Result and the error inside it in one step.
({String show, bool retry, bool signIn}) act(Result<String, SaveError> r) => switch (r) {
Ok(:final value) => (show: 'saved $value', retry: false, signIn: false),
Err(error: Offline(:final queued)) =>
(show: 'saved on this device, $queued waiting', retry: true, signIn: false),
Err(error: Rejected(:final field, :final why)) =>
(show: '$field: $why', retry: false, signIn: false),
Err(error: SessionExpired()) =>
(show: 'your session ended', retry: false, signIn: true),
};
void main() {
final saved = act(const Ok('draft-9'));
final offline = act(const Err(Offline(3)));
final rejected = act(const Err(Rejected('email', 'already in use')));
final expired = act(const Err(SessionExpired()));
print('saved: $saved');
print('offline: $offline');
print('rejected: $rejected');
print('expired: $expired');
test('success shows the id', saved.show, 'saved draft-9');
test('offline is the only one that offers a retry',
[offline.retry, rejected.retry, expired.retry], [true, false, false]);
test('and it can say how much is queued, because the case carries the number',
offline.show, 'saved on this device, 3 waiting');
test('a rejection points at the field the user must fix', rejected.show, 'email: already in use');
test('and only one failure sends them to a login screen',
[saved.signIn, offline.signIn, expired.signIn], [false, false, true]);
}
What it printed, when the build ran it
saved: (retry: false, show: saved draft-9, signIn: false) offline: (retry: true, show: saved on this device, 3 waiting, signIn: false) rejected: (retry: false, show: email: already in use, signIn: false) expired: (retry: false, show: your session ended, signIn: true) ok success shows the id ok offline is the only one that offers a retry ok and it can say how much is queued, because the case carries the number ok a rejection points at the field the user must fix ok and only one failure sends them to a login screen
Read those five outcomes again as a design review. Retry, field highlight, sign-in and
a queue count are four genuinely different behaviours, and every one of them came out of
the type rather than out of a string. If your error type is String, or
Exception, or dynamic, the UI has exactly one behaviour available
to it: show the text and hope.
When an exception is the right answer
This is the part interviewers press on, because the honest answer is not "always use Result". Two categories, and they get opposite treatment.
- Expected domain failures — offline, rejected by the server, not found, card declined, validation. These are part of the feature. They belong in the return type, because the caller has to do something specific about each.
- Bugs and impossibilities — an index out of range, a null that the types said could not happen, a switch arm you thought was unreachable. These should crash. A crash reaches your reporting tool; a polite message on a screen does not.
sealed class Result<T> {
const Result();
R fold<R>(R Function(T value) onOk, R Function(String error) onErr) => switch (this) {
Ok(:final value) => onOk(value),
Err(:final error) => onErr(error),
};
}
class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
class Err<T> extends Result<T> {
const Err(this.error);
final String error;
}
/// A real bug: <= where < was meant. It is in the domain, and it is not a
/// failure mode - it is wrong code.
int sumOf(List<int> prices) {
var total = 0;
for (var i = 0; i <= prices.length; i++) {
total += prices[i];
}
return total;
}
/// Policy A: never throw, whatever happens. Popular, and it hides the bug.
Result<int> catchEverything(List<int> prices) {
try {
return Ok(sumOf(prices));
} catch (_) {
return const Err('could not total the basket');
}
}
/// Policy B: only the failures the domain expects become a Result. Anything
/// else is left alone, so it reaches the crash reporter.
Result<int> expectedOnly(List<int> prices) {
if (prices.isEmpty) return const Err('empty basket');
return Ok(sumOf(prices));
}
void main() {
final reported = <String>[];
final friendly = catchEverything([100, 250]).fold((v) => 'total $v', (e) => e);
final quietSoFar = reported.length;
String outcome;
try {
expectedOnly([100, 250]);
outcome = 'no crash';
} catch (e) {
reported.add(e.runtimeType.toString());
outcome = 'crashed';
}
final realFailure = expectedOnly([]).fold((v) => 'total $v', (e) => e);
print('policy A: $friendly');
print('policy B: $outcome, reported $reported');
print('expected failure: $realFailure');
test('the catch-all turned a programming bug into a domain failure',
friendly, 'could not total the basket');
test('and told nobody about it', quietSoFar, 0);
test('without the catch-all the same bug crashes', outcome, 'crashed');
test('so it reaches whatever you report crashes with', reported.length, 1);
test('while a failure the domain actually expects is still a Result',
realFailure, 'empty basket');
}
What it printed, when the build ran it
policy A: could not total the basket policy B: crashed, reported [RangeError] expected failure: empty basket ok the catch-all turned a programming bug into a domain failure ok and told nobody about it ok without the catch-all the same bug crashes ok so it reaches whatever you report crashes with ok while a failure the domain actually expects is still a Result
That is the rule in one line: catch what you can name.
on FormatException, on SocketException, on
TimeoutException — each mapped to a case of your error family. A bare
catch (e) at a layer boundary is how a team ends up with a crash-free dashboard
and a support queue.
The trap: getOrThrow
Every Result codebase grows this method within a month, usually because a
widget needed the value and the fold was two lines too many. It compiles, it reads nicely,
and it undoes the whole thing.
sealed class SaveError {
const SaveError();
}
class Offline extends SaveError {
const Offline();
}
class Rejected extends SaveError {
const Rejected(this.field);
final String field;
}
sealed class Result<T, E> {
const Result();
R fold<R>(R Function(T value) onOk, R Function(E error) onErr) => switch (this) {
Ok(:final value) => onOk(value),
Err(:final error) => onErr(error),
};
/// The shortcut. Note what it does to E: a typed value becomes a message.
T getOrThrow() => fold((v) => v, (e) => throw Exception('save failed: $e'));
}
class Ok<T, E> extends Result<T, E> {
const Ok(this.value);
final T value;
}
class Err<T, E> extends Result<T, E> {
const Err(this.error);
final E error;
}
/// The version the type was designed for: the failure is still a value, so the
/// caller can decide between a retry and a field highlight.
({String show, bool retry}) viaFold(Result<String, SaveError> r) => r.fold(
(v) => (show: v, retry: false),
(e) => switch (e) {
Offline() => (show: 'offline', retry: true),
Rejected(:final field) => (show: 'fix $field', retry: false),
},
);
/// The version everyone writes by the third sprint.
({String show, bool retry}) viaThrow(Result<String, SaveError> r) {
try {
return (show: r.getOrThrow(), retry: false);
} catch (e) {
return (show: 'something went wrong', retry: e is SaveError);
}
}
/// The static type of an expression, which is what a reviewer sees.
Type declaredType<T>(T _) => T;
void main() {
const failure = Err<String, SaveError>(Offline());
print('fold: ${viaFold(failure)}');
print('throw: ${viaThrow(failure)}');
test('the fold version knows this failure is worth retrying',
viaFold(failure).retry, true);
test('the throw version cannot tell, because a typed error became a message',
viaThrow(failure).retry, false);
test('so the best it can offer is a generic apology',
viaThrow(failure).show, 'something went wrong');
test('both are declared identically, so a reviewer sees no difference',
declaredType(viaFold) == declaredType(viaThrow), true);
}
What it printed, when the build ran it
fold: (retry: true, show: offline) throw: (retry: false, show: something went wrong) ok the fold version knows this failure is worth retrying ok the throw version cannot tell, because a typed error became a message ok so the best it can offer is a generic apology ok both are declared identically, so a reviewer sees no difference
The last assertion is the reason this spreads. The two functions have the same
signature, so nothing in review flags the second one, and nothing in the type system
objects. A Result that everyone calls getOrThrow on is strictly
worse than exceptions: same invisible failures, plus a wrapper type, plus a team that
believes it has error handling. If you find getOrThrow at fifty call sites,
the honest move is to delete the Result type or delete the method — not to
keep both.
Where to put the boundary
- Data layer: catch narrowly, map to your error family. This is the only
place that should know what a
SocketExceptionis. - Domain:
Resultin and out. Use cases compose withthenand never see atry. - Presentation: fold, once, into state. The sealed UI state from lesson 23 is what the fold produces, which is why these two types keep the same shape.
- Not everywhere. A function that cannot fail should not return a
Result, and wrapping a synchronous getter is noise. If a step's only failure is a bug, let it throw.
The strongest thing to say about this in an interview is the trade-off, not the
preference. Result costs you a type parameter, a set of combinators everyone
has to learn, and friction at every boundary with a package that throws — which in Flutter
is all of them. It buys you failures that are visible in signatures and impossible to
forget. That is a good trade in a codebase with a real domain and a bad one in a thin app
that mostly renders a feed, and a team that adopts it halfway gets the costs of both. Dart
having no checked exceptions is what tips it: in a language that declares
throws, most of this argument does not arise.
27 Repositories, caching and a single source of truth
A repository is not a wrapper around your API client. It is the answer to a question the app has to answer somewhere: where does this value live, and who is allowed to change it? One owner per piece of data, and everything else reads from that owner.
What it is for, and what it is not
- It speaks the domain's language.
profile(id), notGET /v2/users/:id. The caller cannot tell whether the answer came from memory, disk or the network, and that is the point. - It owns the caching decision. If the widget knows about the TTL, the repository has failed.
- It is not one class per endpoint. That is a client. A repository per concept — profile, basket, catalogue — with as many sources behind it as the concept needs.
import 'dart:async';
class Profile {
const Profile(this.id, this.name);
final String id;
final String name;
}
/// The fake remote. It counts calls, because "did that hit the network" is the
/// only interesting question you can ask a cache.
class ProfileApi {
ProfileApi(this._name);
String _name;
int calls = 0;
Future<Profile> fetch(String id) async {
calls++;
await Future<void>.delayed(Duration.zero);
return Profile(id, _name);
}
/// Someone edits the record elsewhere. The cache cannot know.
void editedElsewhere(String name) => _name = name;
}
/// Injected time, so a five minute TTL is testable in a microsecond.
class Clock {
Duration _t = Duration.zero;
Duration get now => _t;
void advance(Duration d) => _t += d;
}
class ProfileRepository {
ProfileRepository(this._api, this._clock, {this.ttl = const Duration(minutes: 5)});
final ProfileApi _api;
final Clock _clock;
final Duration ttl;
Profile? _value;
Duration? _storedAt;
bool get stale => _storedAt == null || _clock.now - _storedAt! >= ttl;
Future<Profile> profile(String id, {bool force = false}) async {
if (!force && _value != null && !stale) return _value!;
final fresh = await _api.fetch(id);
_value = fresh;
_storedAt = _clock.now;
return fresh;
}
}
Future<void> main() async {
final api = ProfileApi('Ada');
final clock = Clock();
final repo = ProfileRepository(api, clock);
final first = await repo.profile('u1');
final second = await repo.profile('u1');
final afterTwoReads = api.calls;
clock.advance(const Duration(minutes: 4));
await repo.profile('u1');
final insideTtl = api.calls;
api.editedElsewhere('Ada L');
clock.advance(const Duration(minutes: 2)); // six minutes in total
final afterExpiry = await repo.profile('u1');
await repo.profile('u1', force: true);
print('calls: $afterTwoReads then $insideTtl then ${api.calls}');
print('name after expiry: ${afterExpiry.name}');
test('two reads, one request', afterTwoReads, 1);
test('and both callers hold the same object, not two copies',
identical(first, second), true);
test('inside the TTL it is still a cache hit', insideTtl, 1);
test('past the TTL it goes back to the network', afterExpiry.name, 'Ada L');
test('force ignores the TTL, which is what pull-to-refresh needs', api.calls, 3);
test('the caller never learned where any of that came from',
first.runtimeType, Profile);
}
What it printed, when the build ran it
calls: 1 then 1 then 3 name after expiry: Ada L ok two reads, one request ok and both callers hold the same object, not two copies ok inside the TTL it is still a cache hit ok past the TTL it goes back to the network ok force ignores the TTL, which is what pull-to-refresh needs ok the caller never learned where any of that came from
Any cache has to answer three questions, and if you cannot say your answers out loud you do not have a cache, you have a variable. What is the key — here one id, in real life usually a map keyed by id. When is it stale — a TTL is the cheap answer, an ETag or a server timestamp is the good one. Who invalidates it — writes through this repository, a pull to refresh, and a logout that clears everything.
Stale while revalidate, and one request instead of five
The TTL version above still makes the user wait after five minutes, which is the wrong trade for a profile name. Better: hand back what you have immediately, and refresh behind it. The second half of that panel is single-flight — five widgets asking at the same moment should produce one request, not five.
import 'dart:async';
class Profile {
const Profile(this.name);
final String name;
}
class ProfileApi {
ProfileApi(this._name);
String _name;
int calls = 0;
Future<Profile> fetch() async {
calls++;
await Future<void>.delayed(Duration.zero);
return Profile(_name);
}
void editedElsewhere(String name) => _name = name;
}
class Clock {
Duration _t = Duration.zero;
Duration get now => _t;
void advance(Duration d) => _t += d;
}
class SwrRepository {
SwrRepository(this._api, this._clock, this.ttl);
final ProfileApi _api;
final Clock _clock;
final Duration ttl;
Profile? _value;
Duration? _storedAt;
Future<Profile>? _inFlight;
bool get _stale => _storedAt == null || _clock.now - _storedAt! >= ttl;
/// Synchronous on purpose. The UI gets whatever exists right now, and a
/// refresh happens beside it. No await, so no spinner over good data.
({String? now, bool refreshing}) read() {
if (_stale) _refresh();
return (now: _value?.name, refreshing: _inFlight != null);
}
/// Single flight: the first caller starts the request, the rest join it.
Future<Profile> _refresh() => _inFlight ??= _api.fetch().then((fresh) {
_value = fresh;
_storedAt = _clock.now;
_inFlight = null;
return fresh;
});
Future<void> settle() async {
while (_inFlight != null) {
await _inFlight;
}
}
}
Future<void> main() async {
final api = ProfileApi('Ada');
final clock = Clock();
final repo = SwrRepository(api, clock, const Duration(minutes: 5));
final cold = repo.read();
await repo.settle();
final warm = repo.read();
api.editedElsewhere('Ada L');
clock.advance(const Duration(minutes: 6));
// Three widgets build in the same frame, all asking for the profile.
final stale = [repo.read(), repo.read(), repo.read()];
await repo.settle();
final fresh = repo.read();
print('cold: $cold');
print('stale: ${stale.first}');
print('fresh: $fresh');
print('requests: ${api.calls}');
test('a cold read has nothing to draw yet, and starts a fetch',
[cold.now == null, cold.refreshing], [true, true]);
test('once it lands the read is instant', [warm.now, warm.refreshing], ['Ada', false]);
test('past the TTL the read still returns the old value at once',
stale.first.now, 'Ada');
test('three widgets in one frame is still one request', api.calls, 2);
test('and the read after it sees the new value', fresh.now, 'Ada L');
test('with nothing in flight any more', fresh.refreshing, false);
}
What it printed, when the build ran it
cold: (now: null, refreshing: true) stale: (now: Ada, refreshing: true) fresh: (now: Ada L, refreshing: false) requests: 2 ok a cold read has nothing to draw yet, and starts a fetch ok once it lands the read is instant ok past the TTL the read still returns the old value at once ok three widgets in one frame is still one request ok and the read after it sees the new value ok with nothing in flight any more
Those two ideas are worth naming precisely in an interview because they are cheap and they fix visible problems. Stale while revalidate removes the spinner over data you already had — the single biggest perceived-speed win in most apps. Single flight removes the burst of identical requests you get when a screen with six widgets mounts, which is a bug your backend team notices before you do.
Two caches, and a user who cannot trust the app
Here is the failure that "single source of truth" is a phrase about. Nobody sets out to build two caches. You get the second one by copying a value into a screen's state because it was convenient.
/// The repository. Everyone agrees this is the source of truth.
class PriceRepository {
final _pence = <String, int>{'tea': 250};
final _listeners = <void Function(int)>[];
int priceOf(String sku) => _pence[sku]!;
void write(String sku, int pence) {
_pence[sku] = pence;
for (final tell in _listeners) {
tell(pence);
}
}
void watch(void Function(int) onChange) => _listeners.add(onChange);
}
/// A screen that copied the value at open time, because it needed it in a
/// field and that was one line. It is now a second cache.
class CopyingScreen {
CopyingScreen(PriceRepository repo, String sku) : shown = repo.priceOf(sku);
int shown;
}
/// The same screen, subscribing instead of copying. One extra line.
class WatchingScreen {
WatchingScreen(PriceRepository repo, String sku) : shown = repo.priceOf(sku) {
repo.watch((pence) => shown = pence);
}
int shown;
}
void main() {
final repo = PriceRepository();
final basket = CopyingScreen(repo, 'tea');
final watched = WatchingScreen(repo, 'tea');
final agreedAtFirst = [basket.shown, watched.shown, repo.priceOf('tea')];
// A price change arrives: another screen saved, or a push notification did.
repo.write('tea', 300);
// And a second copy of the same screen, opened after the change.
final reopened = CopyingScreen(repo, 'tea');
print('at first: $agreedAtFirst');
print('after the write: basket ${basket.shown}, reopened ${reopened.shown}, '
'watched ${watched.shown}, repo ${repo.priceOf('tea')}');
test('everyone agreed to begin with', agreedAtFirst, [250, 250, 250]);
test('the repository has the new price', repo.priceOf('tea'), 300);
test('the screen that copied still shows the old one', basket.shown, 250);
test('so two copies of one screen disagree with each other',
[basket.shown, reopened.shown], [250, 300]);
test('the subscribing screen agrees with the repository', watched.shown, 300);
}
What it printed, when the build ran it
at first: [250, 250, 250] after the write: basket 250, reopened 300, watched 300, repo 300 ok everyone agreed to begin with ok the repository has the new price ok the screen that copied still shows the old one ok so two copies of one screen disagree with each other ok the subscribing screen agrees with the repository
That fourth assertion is what the user reports: the basket says £2.50 and the product page says £3.00, and it depends which one they opened first. The same shape turns up in three disguises. A local database and an in-memory map, where a write updates one. A Bloc holding a list while the repository holds another. Two repositories for the same concept, because two features each built their own. The test for all of them is one question: if this value changes, how many places have to be told? More than one is the bug.
One source the UI watches
The fix generalises. Stop letting callers pull and copy, and give them a stream to watch. The repository holds the current value, so a widget that subscribes late still gets something to draw, and every subscriber sees the same sequence.
import 'dart:async';
class Profile {
const Profile(this.name);
final String name;
}
class ProfileApi {
int calls = 0;
Future<Profile> fetch() async {
calls++;
await Future<void>.delayed(Duration.zero);
return const Profile('Ada');
}
Future<Profile> rename(String name) async {
await Future<void>.delayed(Duration.zero);
return Profile(name);
}
}
/// The source of truth: one held value, one broadcast stream, and writes that
/// go through here so there is nowhere else for the value to live.
class ProfileRepository {
ProfileRepository(this._api);
final ProfileApi _api;
final _out = StreamController<Profile>.broadcast();
Profile? _value;
/// A late subscriber gets the current value first, then the changes. That
/// replay is what stops a widget from having to keep its own copy.
Stream<Profile> watch() async* {
final held = _value;
if (held != null) yield held;
yield* _out.stream;
}
Future<void> load() async => _publish(await _api.fetch());
Future<void> rename(String name) async => _publish(await _api.rename(name));
void _publish(Profile p) {
_value = p;
_out.add(p);
}
Future<void> close() => _out.close();
}
Future<void> main() async {
final api = ProfileApi();
final repo = ProfileRepository(api);
final early = <String>[];
final subEarly = repo.watch().listen((p) => early.add(p.name));
await repo.load();
await Future<void>.delayed(Duration.zero);
// A second screen opens well after the load.
final later = <String>[];
final subLater = repo.watch().listen((p) => later.add(p.name));
await Future<void>.delayed(Duration.zero);
await repo.rename('Ada L');
await Future<void>.delayed(Duration.zero);
print('early: $early');
print('later: $later');
print('fetches: ${api.calls}');
test('the first subscriber saw the load and the rename', early, ['Ada', 'Ada L']);
test('the later one was handed the current value immediately', later.first, 'Ada');
test('and then followed the same changes', later, ['Ada', 'Ada L']);
test('so both screens end on the same value', early.last, later.last);
test('and the second screen cost no extra request', api.calls, 1);
await subEarly.cancel();
await subLater.cancel();
await repo.close();
}
What it printed, when the build ran it
early: [Ada, Ada L] later: [Ada, Ada L] fetches: 1 ok the first subscriber saw the load and the rename ok the later one was handed the current value immediately ok and then followed the same changes ok so both screens end on the same value ok and the second screen cost no extra request
This is the shape worth being able to draw on a whiteboard: writes go in one end, a stream comes out the other, and the UI only reads from the stream. A widget that never holds its own copy cannot show a stale one. In a real app the held value lives in a local database rather than a field, the stream is a query watch, and the picture is identical — which is also why the offline queue in the next lesson slots in without redrawing anything.
The honest limits
- Replay needs care. The
watch()above yields the current value then the stream, which is a hand-rolled version of whatBehaviorSubjector a database query stream gives you. Between those two lines there is a gap where a write can be missed; a real implementation reads inside the same synchronous block, and this is worth knowing because it is the kind of detail an interviewer will poke at. - A stream repository needs an owner. Something has to close that controller. That is lesson 25's scopes, and a repository registered as an app-wide singleton while holding per-user data is the logout bug again.
- One endpoint, one screen, no cache? Then a repository is a file you added for symmetry. Call the client directly and add the layer when a second reader turns up. The pattern earns its keep the moment two callers want the same value, and not before.
28 Offline first, and conflict resolution
Offline first is not a caching strategy bolted on at the end. It is a decision about who owns a write: the device accepts it, stores it, and syncs later — which means you have signed up for a queue, a replay, and two writers editing the same record. Those three things, in that order, are the lesson.
The outbox
- The local write always succeeds. No spinner, no error, no network in the path. That is the whole user-facing promise.
- Each write gets an id made on the device. This is the idempotency key, and it is what makes a replay safe rather than hopeful.
- Order is part of the meaning. Add then remove is not the same as remove then add, so the queue replays in sequence and stops at the first failure.
/// A write the user made. The id comes from the device, before any network
/// call exists, which is what makes replaying it safe.
class Op {
const Op(this.id, this.kind, this.sku);
final String id;
final String kind;
final String sku;
@override
String toString() => '$kind $sku';
}
class Server {
bool reachable = false;
final basket = <String>[];
final log = <String>[];
final _applied = <String>{};
int duplicates = 0;
Future<bool> apply(Op op) async {
await Future<void>.delayed(Duration.zero);
if (!reachable) return false;
// Seen this id before? Say yes and do nothing. Six lines, and it is the
// difference between a safe replay and a double charge.
if (_applied.contains(op.id)) {
duplicates++;
return true;
}
_applied.add(op.id);
log.add(op.toString());
if (op.kind == 'add') basket.add(op.sku);
if (op.kind == 'remove') basket.remove(op.sku);
return true;
}
}
class Outbox {
Outbox(this._server);
final Server _server;
final _queue = <Op>[];
int get pending => _queue.length;
/// Accepted locally, always. The user is not waiting for anyone.
void write(Op op) => _queue.add(op);
/// Replay in order, and keep anything that did not land.
Future<int> flush() async {
var sent = 0;
while (_queue.isNotEmpty) {
if (!await _server.apply(_queue.first)) break;
_queue.removeAt(0);
sent++;
}
return sent;
}
}
Future<void> main() async {
final server = Server();
final outbox = Outbox(server);
// On a train, in a tunnel.
outbox.write(const Op('op-1', 'add', 'tea'));
outbox.write(const Op('op-2', 'add', 'jam'));
outbox.write(const Op('op-3', 'remove', 'jam'));
final sentWhileOffline = await outbox.flush();
final serverSawNothing = server.log.isEmpty;
final stillQueued = outbox.pending;
server.reachable = true;
final sentOnReconnect = await outbox.flush();
// The app was killed after the server applied these but before the queue was
// cleared, so on next launch the same three ops are still there.
final replayed = Outbox(server)
..write(const Op('op-1', 'add', 'tea'))
..write(const Op('op-2', 'add', 'jam'))
..write(const Op('op-3', 'remove', 'jam'));
await replayed.flush();
print('server log: ${server.log}');
print('basket: ${server.basket}');
print('duplicates ignored: ${server.duplicates}');
test('nothing left the device while it was offline', sentWhileOffline, 0);
test('and all three writes are still waiting', [serverSawNothing, stillQueued],
[true, 3]);
test('on reconnect all three land, in the order they were made',
server.log, ['add tea', 'add jam', 'remove jam']);
test('so the basket is what the user built, and nothing is queued',
[server.basket, sentOnReconnect, outbox.pending], [['tea'], 3, 0]);
test('replaying the same three changes nothing', server.basket, ['tea']);
test('because the server recognised the ids it had already applied',
server.duplicates, 3);
}
What it printed, when the build ran it
server log: [add tea, add jam, remove jam] basket: [tea] duplicates ignored: 3 ok nothing left the device while it was offline ok and all three writes are still waiting ok on reconnect all three land, in the order they were made ok so the basket is what the user built, and nothing is queued ok replaying the same three changes nothing ok because the server recognised the ids it had already applied
Two details that panel glosses over and a real one must not. The queue has to be persisted — an outbox in memory is an outbox you lose on the crash that made you want one. And a failure needs a type, which is lesson 26 arriving here: a timeout or a 500 leaves the op queued, while a 400 or a 409 must remove it, or one malformed write blocks every write behind it forever. That poison-message case is worth mentioning unprompted.
Two writers, one record
Now the part that is genuinely hard. Two devices, both offline, both editing the same contact. Whatever you do here you are choosing what to lose.
typedef Contact = ({String name, String phone, int at});
/// Last write wins: keep the whole record with the newest timestamp.
Contact lww(Contact a, Contact b) => a.at >= b.at ? a : b;
/// Field level merge: for each field, prefer the version that actually
/// changed. It needs the common ancestor, which is why you keep it.
Contact merge(Contact base, Contact a, Contact b) => (
name: a.name != base.name ? a.name : b.name,
phone: a.phone != base.phone ? a.phone : b.phone,
at: a.at > b.at ? a.at : b.at,
);
List<String> changed(Contact base, Contact edit) => [
if (edit.name != base.name) 'name',
if (edit.phone != base.phone) 'phone',
];
void main() {
const base = (name: 'Ada Lovelace', phone: '0100', at: 10);
// The phone fixed the name. The tablet fixed the number. Neither saw the
// other, and the tablet synced second.
const fromPhone = (name: 'Ada L', phone: '0100', at: 20);
const fromTablet = (name: 'Ada Lovelace', phone: '0200', at: 25);
final byLww = lww(fromPhone, fromTablet);
final byMerge = merge(base, fromPhone, fromTablet);
// And the case no rule can fix: both edited the same field.
const bothNamed = (name: 'A. Lovelace', phone: '0100', at: 30);
final sameField = merge(base, fromPhone, bothNamed);
print('last write wins: $byLww');
print('field merge: $byMerge');
print('same field: $sameField');
test('each device changed exactly one field',
[changed(base, fromPhone), changed(base, fromTablet)], [['name'], ['phone']]);
test('last write wins keeps the newer record whole', byLww.phone, '0200');
test('and throws away the name edit, silently', byLww.name, 'Ada Lovelace');
test('the merge keeps both edits', [byMerge.name, byMerge.phone], ['Ada L', '0200']);
test('but when both edited the same field a rule can only pick one',
sameField.name, 'Ada L');
test('so that edit is lost too, and only the user could have decided',
sameField.name == bothNamed.name, false);
}
What it printed, when the build ran it
last write wins: (at: 25, name: Ada Lovelace, phone: 0200) field merge: (at: 25, name: Ada L, phone: 0200) same field: (at: 30, name: Ada L, phone: 0100) ok each device changed exactly one field ok last write wins keeps the newer record whole ok and throws away the name edit, silently ok the merge keeps both edits ok but when both edited the same field a rule can only pick one ok so that edit is lost too, and only the user could have decided
Last write wins is the default in almost every sync layer, including the one you get for free, and the reason is that it always terminates and needs no history. The cost is in that third assertion: an edit disappeared and nobody was told. That is acceptable for a last-read position or a theme preference, and it is not acceptable for a document, a basket, or anything a user typed. The decision is per field, not per app — and being able to say which fields in your app can tolerate it is the answer that lands.
Was it even a conflict?
Both strategies above assumed a conflict. A version tells you whether there is one. A single number does that when one server serialises every write; it cannot tell you whether two edits were concurrent or one already contained the other, which is what a vector — one counter per device — is for.
typedef Vector = Map<String, int>;
int at(Vector v, String device) => v[device] ?? 0;
Set<String> _keys(Vector a, Vector b) => {...a.keys, ...b.keys};
bool same(Vector a, Vector b) => _keys(a, b).every((d) => at(a, d) == at(b, d));
bool dominates(Vector a, Vector b) =>
!same(a, b) && _keys(a, b).every((d) => at(a, d) >= at(b, d));
/// The only question that matters on sync: does one of these already contain
/// the other, or did they genuinely happen side by side?
String relation(Vector a, Vector b) {
if (same(a, b)) return 'identical';
if (dominates(a, b)) return 'a already has b';
if (dominates(b, a)) return 'b already has a';
return 'concurrent';
}
Vector join(Vector a, Vector b) => {
for (final d in _keys(a, b)) d: at(a, d) > at(b, d) ? at(a, d) : at(b, d),
};
/// A single server-side version, for comparison. It can reject a stale write,
/// which is useful, but that is all it can do.
String ifMatch({required int clientSaw, required int serverHas}) =>
clientSaw == serverHas ? 'accepted' : 'conflict, refetch';
void main() {
// The phone wrote twice; the tablet wrote once, having seen the phone's first
// write. So the tablet's version already contains the phone's.
const phoneOnly = {'phone': 2};
const tabletAfterPhone = {'phone': 1, 'tablet': 1};
const phoneAndTablet = {'phone': 2, 'tablet': 1};
print('plain version: ${ifMatch(clientSaw: 3, serverHas: 4)}');
print('phoneOnly vs tabletAfterPhone: ${relation(phoneOnly, tabletAfterPhone)}');
print('phoneAndTablet vs tabletAfterPhone: '
'${relation(phoneAndTablet, tabletAfterPhone)}');
test('a single version can spot a stale write',
ifMatch(clientSaw: 3, serverHas: 4), 'conflict, refetch');
test('and says nothing when the client is up to date',
ifMatch(clientSaw: 4, serverHas: 4), 'accepted');
test('a vector can tell that one edit already contains the other',
relation(phoneAndTablet, tabletAfterPhone), 'a already has b');
test('which a single number would have called a conflict',
ifMatch(clientSaw: 2, serverHas: 3), 'conflict, refetch');
test('and it still recognises a real one', relation(phoneOnly, tabletAfterPhone),
'concurrent');
test('after merging, the vector is the pointwise maximum of both',
join(phoneOnly, tabletAfterPhone), {'phone': 2, 'tablet': 1});
}
What it printed, when the build ran it
plain version: conflict, refetch phoneOnly vs tabletAfterPhone: concurrent phoneAndTablet vs tabletAfterPhone: a already has b ok a single version can spot a stale write ok and says nothing when the client is up to date ok a vector can tell that one edit already contains the other ok which a single number would have called a conflict ok and it still recognises a real one ok after merging, the vector is the pointwise maximum of both
Worth knowing why timestamps are the weakest of the three: they depend on device clocks, and a phone whose clock is ten minutes fast wins every conflict it takes part in — even the ones it started. If you use timestamps for last-write-wins, use the server's clock. And be honest about the cost of the other end: vectors and CRDTs grow with the number of devices, need pruning, and are a real amount of code. Most apps are right to choose server-serialised versions with a conflict prompt, and to say why.
Optimistic UI, and a rollback that does not clobber
Offline first pushes you towards optimistic updates: paint the change now, reconcile later. The trap is the rollback. Restoring the value you snapshotted is obvious, correct in the simple case, and wrong the moment two edits are in flight.
class Server {
String stored = 'Ada';
/// Rejects anything too short. The delay is per call, so a slow first
/// request can answer after a fast second one - which is the whole point.
Future<bool> rename(String next, int ms) async {
await Future<void>.delayed(Duration(milliseconds: ms));
if (next.length < 3) return false;
stored = next;
return true;
}
}
/// The obvious version: keep what was there, put it back on rejection.
class NaiveScreen {
NaiveScreen(this._server);
final Server _server;
String shown = 'Ada';
String? banner;
Future<void> rename(String next, int ms) async {
final previous = shown;
shown = next; // the frame the user sees
if (!await _server.rename(next, ms)) {
shown = previous; // and the bug
banner = 'could not rename';
}
}
}
/// The same thing, with a revision counter. Only undo if nothing newer has
/// happened since; otherwise leave the newer value alone.
class GuardedScreen {
GuardedScreen(this._server);
final Server _server;
String shown = 'Ada';
String? banner;
int _revision = 0;
Future<void> rename(String next, int ms) async {
final previous = shown;
final mine = ++_revision;
shown = next;
if (!await _server.rename(next, ms)) {
if (_revision == mine) shown = previous;
banner = 'could not rename';
}
}
}
Future<void> main() async {
// One edit, rejected. Both versions behave the same, and correctly.
final simple = GuardedScreen(Server());
final inFlight = simple.rename('Ad', 5);
final immediately = simple.shown;
await inFlight;
// Two edits: a slow one that will be rejected, then a fast one that is fine.
final naiveServer = Server();
final naive = NaiveScreen(naiveServer);
await Future.wait([naive.rename('Ad', 30), naive.rename('Ada Lovelace', 5)]);
final guardedServer = Server();
final guarded = GuardedScreen(guardedServer);
await Future.wait([guarded.rename('Ad', 30), guarded.rename('Ada Lovelace', 5)]);
print('optimistic frame: $immediately');
print('naive: shows ${naive.shown}, server has ${naiveServer.stored}');
print('guarded: shows ${guarded.shown}, server has ${guardedServer.stored}');
test('the UI showed the change before the server had answered', immediately, 'Ad');
test('a rejection puts it back, and says so', [simple.shown, simple.banner],
['Ada', 'could not rename']);
test('with two edits in flight the naive rollback undoes the good one',
naive.shown, 'Ada');
test('so the screen disagrees with the server it just talked to',
naive.shown == naiveServer.stored, false);
test('the guarded version leaves the newer edit alone', guarded.shown, 'Ada Lovelace');
test('and agrees with the server', guarded.shown, guardedServer.stored);
}
What it printed, when the build ran it
optimistic frame: Ad naive: shows Ada, server has Ada Lovelace guarded: shows Ada Lovelace, server has Ada Lovelace ok the UI showed the change before the server had answered ok a rejection puts it back, and says so ok with two edits in flight the naive rollback undoes the good one ok so the screen disagrees with the server it just talked to ok the guarded version leaves the newer edit alone ok and agrees with the server
The naive screen ends up showing a name the server does not have, from a rollback that was individually correct. This is the two-caches bug from lesson 27 wearing a different coat, and the durable fix is the same: after a failed write, do not restore a snapshot — re-read the source of truth and let it emit. The revision guard above is the cheap version of that, and it is what you want when the source of truth is a stream the screen already watches.
What to say when asked "how would you make this work offline"
- Which actions work offline, and which honestly cannot. Reading a feed, yes. A payment, no. Say the second part out loud; a candidate who claims everything works offline has not shipped it.
- Where writes go. A persisted outbox with device-generated ids, replayed in order, with retryable and permanent failures treated differently.
- What happens on conflict, per field. Server-serialised versions and a prompt for anything the user typed; last-write-wins only where losing an edit costs nothing.
- What the user sees. A pending marker on unsynced items, a count of what is queued, and an honest message when something was rejected. Silent sync is indistinguishable from silent data loss.
29 Code generation, and its real cost
Code generation buys you one thing, and it is not typing speed. It
removes the class of bug where a hand-written ==, copyWith or
toJson quietly stops matching its own field list. What it costs is CI minutes,
generated files in review, and a project that does not analyse until you have run it.
What it replaces
Every generator in this space writes the same kind of code: mechanical, derived entirely from a field list, and impossible to make interesting.
- json_serializable —
fromJsonandtoJsonfrom your field names, with@JsonKeyfor the cases where the wire disagrees with Dart. The mapper from lesson 24 is what this generates. - freezed — the whole value-object package:
==,hashCode,copyWith,toString, plus sealed unions of the kind lesson 4 built by hand, and JSON if you also add json_serializable. - built_value — the older answer to the same problem, with immutable collections and builders. Still in large codebases, rarely chosen new.
- Pigeon — a different job entirely: type-safe method channels between Dart and Swift or Kotlin, generated on both sides from one definition. Lesson 39.
- The wider family —
injectableover get_it (lesson 25),retrofitover dio,auto_route,driftfor SQL,mockito's generated mocks (lesson 30). All the same machinery:build_runnerplussource_genplus an annotation.
Notice what is not on that list. Nobody generates business rules, and nobody should. The generator's whole claim is that the output is a mechanical function of the input, which is only true for code that has no decisions in it.
part, and why it is not an import
You write part 'invoice.g.dart'; and the builder writes part of
'invoice.dart'; at the top of its output. That pairing is doing real work, and it is
worth being able to explain.
- The two files are one library. Not two libraries that import each other — one, split across files.
- Privacy in Dart is per library. That is the payoff. A name starting
with
_is invisible to other libraries but perfectly visible to another part of the same one, which is how the generated file can declare_$Invoiceand_Invoice, and how it can read your private fields to build an==. Animportcould do neither. - The part file shares your imports. It has none of its own; it sees exactly what the library it belongs to sees. So an import in your file can be load-bearing for code you did not write.
- One library means one namespace. Two top-level names cannot collide
across the parts, which is why generated names are prefixed with
_$— it keeps them out of the space you might reasonably use. - The declaration comes before the file exists. You type the
partline, and until the builder runs it points at nothing. That is not a quirk; it is the cost, and it is proven further down this page.
The honest argument: boilerplate rots
Here is the case for generation, and it is not the one usually made. Hand-writing these four members is not hard and it is not slow. The problem is what happens six weeks later, when the class gains a field and only some of them are updated.
/// The model on day one. Four hand-written members, and every one of them is a
/// second copy of the field list.
class Contact {
const Contact({required this.id, required this.name});
final String id;
final String name;
factory Contact.fromJson(Map<String, Object?> json) => Contact(
id: json['id']! as String,
name: json['name']! as String,
);
Map<String, Object?> toJson() => {'id': id, 'name': name};
Contact copyWith({String? id, String? name}) =>
Contact(id: id ?? this.id, name: name ?? this.name);
@override
bool operator ==(Object other) =>
other is Contact && other.id == id && other.name == name;
@override
int get hashCode => Object.hash(id, name);
}
/// Six weeks later, with an email address. fromJson and toJson were updated,
/// because a missing required argument is a compile error and a missing key
/// shows up the first time anyone looks at the payload.
///
/// == and hashCode were not, because nothing anywhere fails when they are
/// wrong. That asymmetry is the entire lesson.
class ContactV2 {
const ContactV2({required this.id, required this.name, required this.email});
final String id;
final String name;
final String email;
factory ContactV2.fromJson(Map<String, Object?> json) => ContactV2(
id: json['id']! as String,
name: json['name']! as String,
email: json['email']! as String,
);
Map<String, Object?> toJson() => {'id': id, 'name': name, 'email': email};
@override
bool operator ==(Object other) =>
other is ContactV2 && other.id == id && other.name == name;
@override
int get hashCode => Object.hash(id, name);
}
/// Every state container has this guard under some name — Cubit's emit,
/// ValueNotifier's setter, a Riverpod notifier's assignment. If the new state
/// equals the old one, no listener is told anything.
class Store {
Store(this._state);
ContactV2 _state;
final shown = <String>[];
void emit(ContactV2 next) {
if (next == _state) return;
_state = next;
shown.add('${next.name} <${next.email}>');
}
}
void main() {
const wire = <String, Object?>{'id': 'c1', 'name': 'Ada'};
final parsed = Contact.fromJson(wire);
final renamed = parsed.copyWith(name: 'Ada Lovelace');
final parsedAgain = Contact.fromJson(wire);
final deduped = <Contact>{parsed, parsedAgain};
// The same person, three ways. Only the email differs between the first two.
const ada = ContactV2(id: 'c1', name: 'Ada', email: 'ada@example.com');
const newEmail = ContactV2(id: 'c1', name: 'Ada', email: 'ada@lovelace.org');
const newName = ContactV2(id: 'c1', name: 'Ada Lovelace', email: 'ada@lovelace.org');
final store = Store(ada);
store.emit(newEmail);
store.emit(newName);
print('round-trip: ${parsed.toJson()}');
print('copyWith: ${renamed.toJson()}');
print('v1 in a set: ${deduped.length}');
print('v2 equality: ${ada == newEmail} <- their emails are different');
print('v2 in a set: ${<ContactV2>{ada, newEmail}.length}');
print('store showed: ${store.shown}');
test('the hand-written pair round-trips a payload', parsed.toJson(), wire);
test('and copyWith changes one field and keeps the other',
renamed.toJson(), {'id': 'c1', 'name': 'Ada Lovelace'});
test('two separate parses of the same payload are equal', parsed == parsedAgain, true);
test('so a Set folds them into one, which needs hashCode to agree as well',
deduped.length, 1);
test('but once the model gained a field, two different contacts are equal',
ada == newEmail, true);
test('so a Set keeps the first and drops the second, silently',
<ContactV2>{ada, newEmail}.length, 1);
test('and the store never showed the new email until an unrelated field moved',
store.shown, ['Ada Lovelace <ada@lovelace.org>']);
}
What it printed, when the build ran it
round-trip: {id: c1, name: Ada}
copyWith: {id: c1, name: Ada Lovelace}
v1 in a set: 1
v2 equality: true <- their emails are different
v2 in a set: 1
store showed: [Ada Lovelace <ada@lovelace.org>]
ok the hand-written pair round-trips a payload
ok and copyWith changes one field and keeps the other
ok two separate parses of the same payload are equal
ok so a Set folds them into one, which needs hashCode to agree as well
ok but once the model gained a field, two different contacts are equal
ok so a Set keeps the first and drops the second, silently
ok and the store never showed the new email until an unrelated field moved
Read the last assertion as a bug report, because it is one you will receive. "The email
updates on the profile screen, but only after you edit the name." Nothing in that sentence
points at ==, the network call is correct, the parse is correct, the state was
assigned — and the guard in the state container declined to notify anybody. Lesson 2's
equality trap and lesson 17's rebuild rules are the same mechanism seen from two sides.
Now look at which members the compiler defended. Adding a required field breaks the
constructor and breaks fromJson, so you are stopped. It says nothing at all about
toJson dropping a key, == ignoring a field,
hashCode disagreeing with ==, or copyWith having no
parameter for the new field so it can never be changed through it. Five of those are behaviour
changes with no diagnostic attached, and each of them is one distracted afternoon away.
That is the argument. Not "generation is faster" — a generator makes those five places impossible to get out of step, because they are all derived from the one field list you edited.
The copyWith null problem
This is the question to have an answer ready for. A copyWith that takes
nullable parameters cannot distinguish "leave this alone" from "set it to null", because both
arrive as null. So a nullable field can be set, and can never be cleared.
/// A sentinel: a value no caller can produce and no field can hold, so seeing
/// it means the argument was not passed. A plain const Object() is enough.
const _absent = Object();
class Profile {
const Profile({required this.name, this.nickname});
final String name;
final String? nickname;
/// What almost everyone writes by hand. The ?? is the bug: an omitted
/// argument and an explicit null are the same value here.
Profile naive({String? name, String? nickname}) =>
Profile(name: name ?? this.name, nickname: nickname ?? this.nickname);
/// The version that works, and the reason a generated copyWith takes Object?
/// parameters with a const sentinel default rather than typed ones.
///
/// Note which parameter needed it. [name] is non-nullable, so null there can
/// only mean absent and ?? is correct. Only the nullable field is ambiguous,
/// and that is the rule: sentinels exactly where null is a legal value.
Profile copyWith({String? name, Object? nickname = _absent}) => Profile(
name: name ?? this.name,
nickname: identical(nickname, _absent) ? this.nickname : nickname as String?,
);
String show() => '$name / ${nickname ?? "no nickname"}';
}
void main() {
const ada = Profile(name: 'Ada', nickname: 'Countess');
final naiveRename = ada.naive(name: 'Ada Lovelace');
final naiveClear = ada.naive(nickname: null);
final rename = ada.copyWith(name: 'Ada Lovelace');
final clear = ada.copyWith(nickname: null);
final reset = ada.copyWith(nickname: 'Ada');
print('naive rename: ${naiveRename.show()}');
print('naive clear: ${naiveClear.show()} <- asked for null, got the old value');
print('rename: ${rename.show()}');
print('clear: ${clear.show()}');
print('set: ${reset.show()}');
test('the naive copyWith can change a field', naiveRename.name, 'Ada Lovelace');
test('and it leaves the arguments you omitted alone', naiveRename.nickname, 'Countess');
test('but asking it for null hands back the old value', naiveClear.nickname, 'Countess');
test('the sentinel version also leaves omitted arguments alone', rename.nickname, 'Countess');
test('and it can actually clear the field', clear.nickname, null);
test('while still setting one when asked', reset.nickname, 'Ada');
test('so omitted and null are two different things now, and were not before',
[naiveClear.nickname == null, clear.nickname == null], [false, true]);
}
What it printed, when the build ran it
naive rename: Ada Lovelace / Countess naive clear: Ada / Countess <- asked for null, got the old value rename: Ada Lovelace / Countess clear: Ada / no nickname set: Ada / Ada ok the naive copyWith can change a field ok and it leaves the arguments you omitted alone ok but asking it for null hands back the old value ok the sentinel version also leaves omitted arguments alone ok and it can actually clear the field ok while still setting one when asked ok so omitted and null are two different things now, and were not before
What an interviewer is probing with this one. It is not whether you know the trick — it
is whether you have ever thought about what your own copyWith does, because the
failure is invisible until a user tries to remove their avatar or clear a note and the value
comes back. Say the mechanism: ?? collapses absent and null into one case, a
sentinel gives absent its own value, and the alternative is a wrapper type
(Optional<T>, or a one-field Patch<T>) which is more
typing at every call site but keeps the signature honestly typed. Then say the part that
shows judgement: it only matters for nullable fields, so a model with none of them has
nothing to fix.
What it costs, with numbers
None of the above is free, and pretending otherwise is how a team ends up regretting the decision. The costs are real, and they are mostly paid by people who are not the person who added the annotation.
- CI time. A cold
build_runner buildhas no cache. On a mid-sized app it is a minute or two, on every pipeline, before a single test runs. - Review diffs. Commit the output and a two-line model change arrives as a four-hundred-line pull request. Ignore it and a fresh clone does not compile.
- A red editor. Between adding an annotation and running the builder, the analyser reports errors on code that is correct — and they cascade, because a type that failed to resolve breaks every file that uses it.
- Lock-step versions. The annotation package and the generator are two
packages that must agree, and both pin
analyzertransitively. A major bump is never one line in the pubspec. - Debugging through it. A stack trace through
invoice.freezed.dart:412is a file nobody on the team has read, and the fix is usually in the annotation rather than where the exception surfaced. - Merge conflicts you cannot resolve by reading. Two branches that both touched a model produce a conflict in generated code. The only correct resolution is to take either side and regenerate, which is a thing every new joiner learns the hard way.
/// The members a value model needs, how many times a NEW field has to be
/// named inside each, and whether the compiler catches you forgetting.
class Member {
const Member(this.name, this.mentions, {required this.loud});
final String name;
final int mentions;
final bool loud;
}
const model = <Member>[
Member('the field declaration', 1, loud: true),
Member('the constructor parameter', 1, loud: true),
Member('fromJson', 2, loud: true), // a required argument goes missing
Member('toJson', 2, loud: false), // the key is simply absent on the wire
Member('==', 2, loud: false),
Member('hashCode', 1, loud: false),
Member('copyWith', 3, loud: false), // parameter, argument, and the ?? this.x
Member('toString', 1, loud: false),
];
/// Seconds, from a mid-sized app with roughly ninety generated files.
/// Substitute your own numbers; it is the shape of the answer that transfers.
const cold = 95; // build_runner build, no cache, as CI runs it
const warm = 3; // build_runner watch, one file touched
const prsPerMonth = 40;
const prsTouchingAModel = 5; // about one in eight, in any real codebase
int get edits => model.fold(0, (n, m) => n + m.mentions);
List<String> get silent => [for (final m in model) if (!m.loud) m.name];
void main() {
const everyPr = prsPerMonth * cold;
const cachedProperly = prsTouchingAModel * cold;
print('edits for one new field: $edits');
print('places that rot quietly: $silent');
print('cold vs incremental: ${(cold / warm).toStringAsFixed(1)}x');
print('CI per month: ${everyPr ~/ 60}m ${everyPr % 60}s regenerating always, '
'${cachedProperly ~/ 60}m ${cachedProperly % 60}s cached');
test('one new field is thirteen edits across eight members', edits, 13);
test('and the compiler catches you in only three of the eight places',
model.where((m) => m.loud).length, 3);
test('so five places can go stale with no diagnostic at all', silent.length, 5);
test('which is five eighths of the work', close(silent.length * 100 / model.length, 62.5),
true);
test('a cold generator run costs about thirty-two incremental ones',
close(cold / warm, 31.667, 0.001), true);
test('regenerating on every pull request is sixty-three minutes of CI a month',
everyPr ~/ 60, 63);
test('caching it, so you pay only when a model changes, is under eight minutes',
cachedProperly ~/ 60, 7);
}
What it printed, when the build ran it
edits for one new field: 13 places that rot quietly: [toJson, ==, hashCode, copyWith, toString] cold vs incremental: 31.7x CI per month: 63m 20s regenerating always, 7m 55s cached ok one new field is thirteen edits across eight members ok and the compiler catches you in only three of the eight places ok so five places can go stale with no diagnostic at all ok which is five eighths of the work ok a cold generator run costs about thirty-two incremental ones ok regenerating on every pull request is sixty-three minutes of CI a month ok caching it, so you pay only when a model changes, is under eight minutes
Those two numbers are the trade in one sentence, and it is the sentence to say out loud. Generation costs you a minute and a half of CI per pipeline that needs it. Not generating costs you five places per field where the compiler will not help, on a model that changes every sprint. Sixty-three minutes a month is cheap; it is also entirely avoidable, because caching the build cache keyed on your annotated sources takes it to eight. A candidate who says "we cache the generated output in CI and only regenerate when a model file changes" has answered the cost objection properly.
The red editor, proven
This is the cost people actually complain about, so it is worth seeing the analyser say it rather than taking my word for it. Below is exactly what you have typed in the moment before you first run the builder: a class mixing in the name the generator is going to write.
// Nothing here is wrong. It is what a freezed model looks like, and _$Invoice
// is a mixin that build_runner will write into invoice.freezed.dart. Until it
// runs, the name does not exist, and the analyser has no way to know that it is
// about to.
class Invoice with _$Invoice {
const Invoice({required this.id, required this.pence});
final String id;
final int pence;
}
void main() {
print(const Invoice(id: 'inv-1', pence: 1299).pence);
}
What the analyser said
line 5: Classes can only mix in mixins and classes. [mixin_of_non_class]
The part line gets its own error, and that one is more helpful: the analyser
reports that the target of the URI has not been generated, and suggests running the generator
that would produce it. Both errors are correct and both go away with one command. The
consequence worth internalising is that an unresolved generated type cascades
— one un-run builder turns into an editor full of red in files that never mentioned it, which
is why "did you run build_runner" is the first question on every Flutter team.
The failure mode this creates. A developer sees hundreds of errors, does
not connect them to a missing generated file, and starts "fixing" them. Two things prevent
it: put the command in the README and in a make target or shell alias, and have
CI run the builder as its own step so a missing regeneration fails with a message about code
generation rather than a hundred type errors.
Wiring it up
# ======================= pubspec.yaml =======================
# The split is the part that is got wrong on day one.
dependencies:
flutter:
sdk: flutter
# ANNOTATIONS ship inside your app. Small, but they are runtime code, and
# your source imports them directly.
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
dev_dependencies:
# GENERATORS never ship. They run on your machine and on CI, and they are
# where the ninety-five seconds goes.
build_runner: ^2.4.13
freezed: ^2.5.7
json_serializable: ^6.8.0
# The pairs move together: freezed 2.x wants freezed_annotation 2.x,
# json_serializable 6.x wants json_annotation 4.x. Bump one and pub either
# solves for an old version of the other or refuses outright, and the message
# names a constraint on analyzer or source_gen rather than the package you
# touched. That is the lock-step cost, and it is an afternoon per major bump.
# ============= build.yaml, beside pubspec.yaml =============
# Optional, and worth writing the first time a default annoys you.
targets:
$default:
builders:
json_serializable:
options:
# fromJson throws a named error on a missing or wrongly typed key,
# instead of handing you a null that fails three screens later.
checked: true
# Omit the key entirely rather than sending "note": null.
include_if_null: false
# The wire is snake_case, your Dart stays lowerCamelCase, and you stop
# writing @JsonKey(name: ...) on every second field.
field_rename: snake
# =================== the two commands ===================
#
# dart run build_runner build --delete-conflicting-outputs
#
# One pass, then it exits. The flag is not optional in practice: rename a
# file or change branch and the old .g.dart is an orphan the builder will
# refuse to overwrite. Every team ends up aliasing this line.
#
# dart run build_runner watch --delete-conflicting-outputs
#
# Stays running and regenerates on save. This is the difference between the
# three second number above and the ninety-five second one, and it is the
# only way to work on models without hating the setup.
#
# Note the prefix: "dart run" is correct in a Flutter package too. The older
# "flutter pub run build_runner ..." still works and is no longer the
# documented form.
# ========== .gitignore, and this one is a real decision ==========
#
# *.freezed.dart
# *.g.dart
#
# Ignore them: CI must generate before it can analyse or test, and a fresh
# clone does not compile until someone runs the builder.
# Commit them: every model change carries a few hundred generated lines into
# review, and two branches touching one model conflict in a file that cannot be
# resolved by reading it.
#
# Most teams ignore them and pay the CI minutes. That is the defensible
# default, and the reason is not convenience: a generated file is not a source
# of truth, and anything reviewed as though it were will be reviewed badly.
Configuration, not code. Nothing to execute.
What the annotated source looks like
// ---------- what you write ------------------------------------------------
import 'package:freezed_annotation/freezed_annotation.dart';
// Two builders, two part files, both declared before either exists.
part 'invoice.freezed.dart';
part 'invoice.g.dart';
@freezed
class Invoice with _$Invoice {
// A factory redirecting to a class the builder writes. This is the whole
// declaration: the field list appears once, and nowhere else.
const factory Invoice({
required String id,
required int pence,
String? note,
}) = _Invoice;
factory Invoice.fromJson(Map<String, Object?> json) => _$InvoiceFromJson(json);
}
/// A union, which is the feature worth the dependency on its own. Three
/// constructors are three subclasses. Newer freezed emits a real sealed class,
/// so you switch on it with Dart's own patterns (lesson 4) rather than with a
/// generated when() helper.
@freezed
sealed class SyncState with _$SyncState {
const factory SyncState.queued(String id) = Queued;
const factory SyncState.sending(String id, int attempt) = Sending;
const factory SyncState.failed(String id, String reason) = Failed;
}
// json_serializable on its own, with no freezed. An ordinary class with
// ordinary fields; only the two JSON members are generated, and == and
// copyWith remain your problem.
@JsonSerializable(fieldRename: FieldRename.snake)
class Customer {
const Customer({required this.id, required this.displayName, this.email});
final String id;
final String displayName; // display_name on the wire
final String? email;
factory Customer.fromJson(Map<String, Object?> json) => _$CustomerFromJson(json);
Map<String, Object?> toJson() => _$CustomerToJson(this);
}
// ---------- and what lands in invoice.freezed.dart -------------------------
//
// mixin _$Invoice {
// String get id;
// int get pence;
// String? get note;
// $InvoiceCopyWith<Invoice> get copyWith;
// Map<String, dynamic> toJson();
// }
//
// class _Invoice implements Invoice {
// ... operator ==, hashCode, toString, and the copyWith implementation
// }
//
// The generated copyWith takes Object? parameters with a const sentinel as the
// default - the same mechanism as the second panel on this page - which is why
// a generated copyWith can set a nullable field to null and a hand-written one
// usually cannot.
//
// For the three classes above the output runs to several hundred lines: lines
// you do not write, do not review, and cannot get out of step with the field
// list. That is the case for it, stated at its strongest.
freezed and json_serializable are deliberately not dependencies of this course. Every runnable panel here is plain Dart against an empty pubspec, which is what lets the build execute and analyse all of them offline in a few seconds; adding a generator would mean a pub get, a network fetch and a build_runner pass before a single page could be produced. So this one is shown rather than run — and every behaviour it claims for the generated output is demonstrated by hand in the runnable panels above.
The decision rule
The answer is not "always" or "never", and an interviewer asking this is listening for a rule rather than a preference.
- Generate what is mechanical and high-churn. Wire models. A DTO layer with thirty classes that change whenever the API does is exactly the case: the code is derived, there is a lot of it, and it goes stale silently.
- Hand-write what is small and stable. Three domain entities with two
fields each, written once and edited twice a year, do not justify a builder in the pipeline.
Neither does a settings object. Dart's records (lesson 5) already give you
==andhashCodefor free where a name is not needed. - Take unions on their own merits. A sealed hierarchy with five cases and constructor arguments is genuinely tedious by hand, and it is the part of freezed that pays for itself fastest. Lesson 4 and lesson 26 are both easier with it.
- Do not mix strategies inside one layer. Half-generated models are worse
than either extreme, because nobody can tell by looking whether
==is trustworthy. - Price it before you adopt it. If your CI is already twelve minutes,
adding ninety seconds to every pipeline is a decision that belongs to the team, not to
whoever needed a
copyWithon a Tuesday.
The strongest version of this answer refuses the framing. Code generation is not a
productivity tool, it is a correctness tool: it makes a class of silent divergence
impossible, and the typing it saves is incidental. That is why "it saves me writing
copyWith" is a weak justification and "our DTO layer has thirty models and I do
not want == to be a matter of discipline" is a strong one. Then name the cost
without being asked — CI minutes, generated diffs, a red analyser until you regenerate,
lock-step versions — and say how you would contain each. An engineer who can price their own
tools is the thing being assessed here; the annotation syntax is not.