Flutter / Asynchronous application boundaries / Lesson 9
View models and repository contracts
Share application state through explicit dependencies and expose commands with observable outcomes.
On this page
Lesson goals
- Separate interface decisions from data access
- Inject a narrow dependency
- Publish coherent state transitions
Separate interface decisions from data access
A screen should not need to know whether workshop records came from memory, a file, or a remote service. A repository owns the applicationโs data-access contract and can coordinate caching and source policies. A view model translates domain information into state and commands useful to a view. These are responsibilities, not a requirement to create a class for every sentence in the specification. Start with boundaries that make an actual decision easier to test. A tiny static screen can remain simple; shared asynchronous data usually benefits from a clearer owner.
Inject a narrow dependency
An abstract repository interface tells a caller what can be requested without revealing transport details. Inject it through a constructor so tests can provide a deterministic fake. Avoid global service lookup inside every method, which hides dependencies and makes test isolation harder. The first fragment declares only the load operation the view model needs. Its return type establishes the domain value, while error behavior still needs documentation. If failures are part of ordinary workflow, consider a structured result contract instead of requiring callers to interpret many unrelated low-level exceptions.
abstract interface class LabelRepository {
Future<List<String>> loadLabels();
}
class FakeLabels implements LabelRepository {
@override Future<List<String>> loadLabels() async => ['Maps', 'Rivers'];
}
The fake repository completes with Maps and Rivers without external I/O.
The interface permits alternative implementations while the synthetic fake establishes predictable test data.
Publish coherent state transitions
The second fragment uses ChangeNotifier as a small observable mechanism. It ignores a duplicate load while busy, clears a previous failure at the next attempt, and notifies around the asynchronous operation. The list is exposed as an unmodifiable snapshot so the view cannot silently mutate it. This is one possible duplicate-command policy; other tasks may need replacement, queuing, or explicit rejection. The mechanism does not itself resolve stale requests or disposal during work. The owner must coordinate lifetime, and a richer view model should add the appropriate generation or cancellation rules.
class LabelModel extends ChangeNotifier {
LabelModel(this.repository);
final LabelRepository repository;
bool busy = false;
String? failure;
List<String> _labels = [];
List<String> get labels => List.unmodifiable(_labels);
Future<void> load() async {
if (busy) return;
busy = true; failure = null; notifyListeners();
try { _labels = await repository.loadLabels(); }
catch (_) { failure = 'Could not load labels'; }
finally { busy = false; notifyListeners(); }
}
}
A successful load ends with busy false, no failure, and the fake labels.
Requires the Flutter foundation import and the preceding repository contract. The model must remain alive until this illustrative load completes.
Expose state without exposing arbitrary mutation
Public mutable fields make a teaching fragment short, but a production API should restrict writes to its commands and expose read-only getters or immutable state objects. Otherwise a view can set busy without starting work or replace a list without notification. Prefer a state representation that makes contradictory combinations difficult. For example, an explicit loading state can carry prior data when refresh is allowed, while a failed initial load has no data. Do not multiply booleans until every combination needs a paragraph explaining whether it is possible.
Dispose according to ownership and subscription scope
A screen-created view model is commonly disposed with that screen; an application-scoped model should not be disposed by a temporary child. Listeners must be detached when their subscriptions end. Define whether commands are allowed to complete after the final view disconnects, and keep UI-only dependencies out of repository objects. Passing BuildContext into a repository so it can show a dialog couples storage policy to a transient location in the tree. Return a meaningful outcome instead, and let the current UI owner decide how to present it.
Test observable outcomes and command policy
A view-model test should exercise initial state, loading transition, successful completion, failure, and the chosen duplicate-command behavior. Count repository calls when duplicate suppression is a requirement, but avoid asserting every incidental notification if the public contract only promises coherent states. A fake repository can defer completion to expose the busy interval. Then a widget test can verify that the view reflects model state and routes user activation to the right command. This keeps data policy failures distinct from semantic or layout failures.
Common errors and fixes
A view silently edits repository data
- Why it happens
- A mutable collection escaped the ownership boundary.
- How to fix it
- Expose immutable snapshots and explicit mutation commands.
- Prevent it next time
- Test that callers cannot alter model state through getters.
Two button presses produce duplicate saves
- Why it happens
- The command has no declared pending-operation policy.
- How to fix it
- Disable or reject duplicates where appropriate and define server idempotency separately.
- Prevent it next time
- Test repeated activation before completion.
A temporary screen disposes shared state
- Why it happens
- Creation and disposal ownership disagree.
- How to fix it
- Let the actual creating scope own disposal.
- Prevent it next time
- Document the lifetime of injected models.
Check your understanding
1. Should a repository return already localized widget text?
Show worked solution
Usually return domain values or structured failures instead. Localization belongs near the presentation policy, where the active locale and message context are known. Keep stable error categories across layers and translate them without making transport code depend on a screen.
2. How can a test prove duplicate suppression while loading?
Show worked solution
Use a fake with a deferred Future and a call counter. Invoke load twice before resolving the first call, assert one repository invocation, complete it, then verify busy returns false and the expected labels become visible. No real server or timing sleep is needed.
Key takeaways
- Separate interface decisions from data access
- Test observable outcomes and command policy