Architecture and Testing
Clean Architecture layers and the dependency rule, SOLID with Flutter specific tells, and test driven development from the domain layer up.
Clean Architecture: three layers and one rule
Clean Architecture is usually explained as a set of folders. The folders are not the architecture. The direction the dependencies point is the architecture.
presentation -> domain <- data| Layer | Contains | Knows about |
|---|---|---|
| Domain | Entities, use cases, repository interfaces | Nothing |
| Data | Repository implementations, data sources, DTOs, mappers | Domain |
| Presentation | Widgets, BLoC or Cubit, view models | Domain |
Source code dependencies point inward, toward the domain. The domain never imports outward. Data and presentation both depend on domain, and neither depends on the other.
There is a single test for whether a codebase actually has this: remove every Flutter import from
domain/ and it still compiles. Pure Dart, no package:flutter, no dio, no sqflite. If that
fails, it is a folder structure, not a domain layer.
The payoff is that business rules become testable without a widget tree, a device, or a network.


The repository boundary is where the inversion happens
The domain needs data but must not know where data comes from. So the interface lives in domain and the implementation lives in data.
// domain/repositories/order_repository.dart pure Dart
abstract class OrderRepository {
Future<List<Order>> fetchOpenOrders();
Future<void> cancel(OrderId id);
}// data/repositories/order_repository_impl.dart
class OrderRepositoryImpl implements OrderRepository {
OrderRepositoryImpl(this._remote, this._local);
final OrderRemoteDataSource _remote;
final OrderLocalDataSource _local;
@override
Future<List<Order>> fetchOpenOrders() async {
try {
final dtos = await _remote.getOpenOrders();
await _local.cacheOrders(dtos);
return dtos.map((d) => d.toEntity()).toList();
} on SocketException {
final cached = await _local.readOrders();
if (cached.isEmpty) throw const NetworkFailure();
return cached.map((d) => d.toEntity()).toList();
}
}
}At compile time data depends on domain. At runtime the domain receives a data object through
injection. The arrow is flipped relative to the call direction, and that flip is the whole trick.
What a repository is actually for
Not wrapping the API. A repository does three jobs:
- Hides the source. The caller cannot tell network from cache from database from fake.
- Owns the cache policy. Cache first, network first, stale while revalidate. That decision lives here, not in the BLoC and not in the data source.
- Translates. DTO to entity, and transport errors to domain failures.
A repository with exactly one method per endpoint, no cache policy, and no error translation is an API client wearing a different name.
Use cases, and whether you need them
A use case is one application specific action with one public method.
class CancelOrder {
CancelOrder(this._repo);
final OrderRepository _repo;
Future<Result<void>> call(OrderId id) => _repo.cancel(id);
}It gives every action a named, testable unit, holds logic that spans more than one repository, and keeps the BLoC thin.
It is also ceremony when it is a one line pass through. A defensible position: use cases where there is orchestration to hold, direct repository calls from the BLoC where there is not, and the repository interface stays in domain either way. The interface is the part that buys testability. The extra file is not.
One action through the layers
Widget
adds CancelOrderRequested to the Bloc
Bloc
emits CancelInProgress
calls the CancelOrder use case
Use case
calls OrderRepository.cancel(id) interface, in domain
RepositoryImpl in data
calls the remote data source
maps DTO to entity, maps errors to failures
updates the local cache
Bloc
emits CancelSuccess or CancelFailure
Widget
BlocBuilder rebuilds

Two things happen at the data boundary and nowhere else:
- Errors change shape. A
DioExceptionbecomes a domainFailure. Domain and presentation never see an HTTP type. - Data changes shape. A JSON DTO becomes an entity. Presentation never sees a
fromJson.
DTO and entity, and when the mapping earns its keep
A DTO mirrors the wire: fromJson, nullable fields, server naming. An entity is what the app
reasons about: non-nullable where the app requires it, domain naming, no serialization.
The mapping is where a backend returning "amount": "0.00042" as a string gets absorbed, and where a
field that is nullable on the wire but must not be nullable in the app gets resolved. Without the
boundary, every nullable server field leaks into every widget.
For anything handling money, this boundary is also where the type gets fixed: the wire gives a
string, the entity holds a Decimal or integer minor units, and never a double.
Skip the mapping when the shapes are identical and you own the endpoint. Two identical classes plus a mapper is real cost for no benefit.
Folder layout: feature first
lib/
features/
orders/
domain/ entities, repositories (abstract), usecases
data/ models, datasources, repositories (impl)
presentation/ bloc, pages, widgets
wallet/
...
core/
error/ Failure types
network/ Dio client, interceptors
di/ registrationsLayer first, meaning lib/domain/, lib/data/, lib/presentation/ with every feature inside each,
collapses at scale. One feature's files scatter across three trees and nothing can be lifted into its
own package later. Feature first matches both the unit of work and the unit of ownership.
Dependency injection makes the inversion real
Something has to hand OrderRepositoryImpl to code that only knows OrderRepository.
final sl = GetIt.instance;
void registerOrders() {
sl.registerLazySingleton<OrderRepository>(
() => OrderRepositoryImpl(sl(), sl()),
);
sl.registerFactory(() => CancelOrder(sl()));
sl.registerFactory(() => OrderBloc(sl()));
}Note the lifetimes. Singleton for repositories and clients holding shared state or connections. Factory for BLoCs and use cases, so each screen gets a fresh instance.
Without injection you construct implementations inside classes that should not know they exist, and the dependency rule breaks quietly.
When Clean Architecture is the wrong call
Worth being honest about, since the cost is real: more files, more indirection, an onboarding tax, and a slower first two weeks on any feature.
Not worth it on a prototype, a single screen utility, a two week app, or a codebase with one developer and no test suite.
Worth it on long lived apps, more than two engineers, codebases that must survive backend changes, and anything where the business rules rather than the UI are the product.
The part that reliably pays for itself is the dependency rule plus repository interfaces. The part that often does not is a use case file for every getter.
SOLID, with the Flutter tell for each
Definitions are cheap. What matters in a Flutter codebase is the smell each principle names.
S: Single Responsibility
One reason to change. A widget that fetches, parses, and renders has three: the API, the model, and the design.
Tell: a StatefulWidget calling http.get in initState.
O: Open Closed
Open to extension, closed to modification. Adding a payment method should add a class, not edit a
switch that every existing method also runs through.
abstract class PaymentMethod {
Future<Result> pay(Money amount);
}
class UpiPayment implements PaymentMethod { /* ... */ }
class CardPayment implements PaymentMethod { /* ... */ }Tell: a growing switch on an enum inside build.
L: Liskov Substitution
A subtype must be usable wherever the supertype is, without surprises. If one OrderRepository
implementation throws offline while the contract promises cached data, every caller now needs to know
which implementation it holds, and the abstraction has stopped abstracting.
Tell: a test double whose behavior differs enough from the real thing that green tests still ship bugs.
I: Interface Segregation
Many small interfaces beat one fat one. A repository with fourteen methods forces every implementation and every double to handle all fourteen.
Tell: a mock overriding ten methods the test never calls.
D: Dependency Inversion
Depend on abstractions, and the abstraction belongs to the high level module. This is the one that carries Clean Architecture, and the only one of the five that changes the shape of a codebase rather than the shape of a class.
Tell: import 'package:dio/dio.dart' anywhere under domain/.
Test driven development
Write the test first, watch it fail, then write the code that makes it pass. The order is the point: a test you wrote after the code only proves the code does what it already does.
The loop
Red. Write a failing test for behavior that does not exist, run it, and watch it fail. A test never seen failing is a test that cannot be trusted.
Green. Write the least code that passes. Ugly is fine here.
Refactor. Clean up while the test holds behavior still. This is the step that gets skipped, and it is the step the design comes from.
TDD is a design technique that leaves tests behind. Writing the test first forces you to use an API before building it, which is why it tends to produce smaller constructors and fewer hidden dependencies. Code that is hard to test is usually hard to use.
What to test at each layer
| Layer | Test type | Assert | Doubles |
|---|---|---|---|
| Entities, value objects | Unit | Business rules, equality, validation | None |
| Use cases | Unit | Orchestration, failure mapping | Mock repository |
| Repositories | Unit | Cache policy, error translation, fallback | Mock data sources |
| Data sources | Unit | Serialization, request shape | Mock HTTP client |
| BLoC | bloc_test | Emitted state sequence | Mock use case |
| Widgets | testWidgets | Render per state, interaction | Fake or seeded BLoC |
| App | integration_test | Critical journeys only | Real or staged backend |
The domain layer is both the cheapest to test and the most valuable, because it is pure Dart with no widget pump and no async plumbing.
A BLoC test
blocTest<OrderBloc, OrderState>(
'emits [InProgress, Failure] when cancelling fails',
setUp: () => when(() => cancelOrder(any()))
.thenAnswer((_) async => const Result.failure(NetworkFailure())),
build: () => OrderBloc(cancelOrder),
act: (bloc) => bloc.add(const CancelOrderRequested(orderId)),
expect: () => const [
OrderCancelInProgress(),
OrderCancelFailure(NetworkFailure()),
],
verify: (_) => verify(() => cancelOrder(orderId)).called(1),
);bloc_test handles async settling, which is the part that makes hand written BLoC tests flaky.
expect asserts the sequence rather than the final state, which is what catches a missing loading
state.
Mock, fake, stub
- Stub returns canned values. Fine for a data source that only needs to hand back JSON.
- Mock records calls so interactions can be verified. Use it when the call itself is the behavior, for example "cancelling must hit the repository exactly once".
- Fake is a working implementation with a shortcut, such as an in memory repository backed by a
Map. Best at the repository boundary, because it exercises real logic and does not break every time a method is added.
Over-mocking produces tests that assert the implementation rather than the behavior. They fail on every refactor, and then nobody refactors. Prefer fakes at the repository boundary and keep mocks for places where the interaction genuinely is the contract.
mocktail over mockito in new code: no code generation and no build_runner step.
Raising coverage on an existing codebase
Going from no tests to broad coverage works as a ratchet, not a sprint.
- Put a floor in CI that can only go up. New code must be tested, so the number climbs without freezing feature work.
- Test the new architecture as you migrate. Screens moving to Clean Architecture get tests as part of the move, so coverage follows the migration instead of being its own project.
- Domain first. Pure Dart, fastest to write, highest value per test, and the risky logic gets covered earliest.
- Characterisation tests before touching legacy. For code nobody understands, assert what it currently does, then refactor against that.
- Golden tests for the design system. A component library is high reuse, so a single golden covers a lot of surface.
- Integration tests only for the paths that move value. Checkout, auth, payments. They are slow and flaky, so spend them carefully.
flutter test --coverage
genhtml coverage/lcov.info -o coverage/htmlExclude generated files such as *.g.dart and *.freezed.dart from the report, or the number lies.
And the qualification that matters: coverage measures lines executed, not assertions made. A high percentage is evidence that code runs under test, not that it is correct.
The pyramid, and the shape it usually becomes
Many fast unit tests, fewer widget tests, very few integration tests.
The common failure is the inverted version: a pile of slow end to end tests and almost no unit tests. It feels thorough and behaves worse, because the suite takes twenty minutes, fails intermittently, and a red build stops meaning anything.
A useful rule: if a test needs a device, a network, or a pump and settle loop to assert a business rule, that rule is in the wrong layer. Push it into the domain and test it there.
The awkward things to test
- Time. Inject a clock, or use
fakeAsyncwithFakeAsync.elapse. Never callDateTime.now()inside logic. - Randomness. Inject
Randomand seed it in tests. - Platform channels.
TestDefaultBinaryMessengerBindingstubs channel responses, which is how a large native surface gets tested without a device. - Navigation. Assert the intent, or use a mock
NavigatorObserver. - Streams that never close. Close controllers in
tearDown, or the suite leaks and unrelated tests fail later.
Summary
Clean Architecture
dependencies point INWARD
domain has no Flutter import
repository INTERFACE in domain, IMPL in data
errors and DTOs translate at the data boundary
feature first folders, layer second
SOLID
S one reason to change
O add a class, do not edit a switch
L a subtype must not surprise the caller
I many small interfaces over one fat one
D depend on abstractions, owned by the caller
TDD
red, green, refactor, and the refactor is the point
domain tests are cheapest and most valuable
fakes at the repository boundary, mocks only for contracts
coverage is a CI ratchet, and it is not correctness