Brain
EngineeringFlutter

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
LayerContainsKnows about
DomainEntities, use cases, repository interfacesNothing
DataRepository implementations, data sources, DTOs, mappersDomain
PresentationWidgets, BLoC or Cubit, view modelsDomain

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.

Three layers side by side. Presentation on the left holds widgets, BLoC, and view models. Domain in the middle holds entities, use cases, and repository interfaces, and is pure Dart with no Flutter, Dio, or sqflite import. Data on the right holds repository implementations, data sources, and DTOs. Arrows from both Presentation and Data point inward to Domain, and there is no arrow between Presentation and Data.

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:

  1. Hides the source. The caller cannot tell network from cache from database from fake.
  2. 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.
  3. 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
One action travelling down through the layers. The widget adds an event to the Bloc and the Bloc emits state back to the widget. The Bloc calls a use case, the use case calls the repository interface, and the repository implementation fetches from a remote data source and reads and writes a local cache. The repository implementation is where DTOs map to entities and errors map to failures.

Two things happen at the data boundary and nowhere else:

  • Errors change shape. A DioException becomes a domain Failure. 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/               registrations

Layer 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

LayerTest typeAssertDoubles
Entities, value objectsUnitBusiness rules, equality, validationNone
Use casesUnitOrchestration, failure mappingMock repository
RepositoriesUnitCache policy, error translation, fallbackMock data sources
Data sourcesUnitSerialization, request shapeMock HTTP client
BLoCbloc_testEmitted state sequenceMock use case
WidgetstestWidgetsRender per state, interactionFake or seeded BLoC
Appintegration_testCritical journeys onlyReal 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.

  1. Put a floor in CI that can only go up. New code must be tested, so the number climbs without freezing feature work.
  2. 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.
  3. Domain first. Pure Dart, fastest to write, highest value per test, and the risky logic gets covered earliest.
  4. Characterisation tests before touching legacy. For code nobody understands, assert what it currently does, then refactor against that.
  5. Golden tests for the design system. A component library is high reuse, so a single golden covers a lot of surface.
  6. 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/html

Exclude 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 fakeAsync with FakeAsync.elapse. Never call DateTime.now() inside logic.
  • Randomness. Inject Random and seed it in tests.
  • Platform channels. TestDefaultBinaryMessengerBinding stubs 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

On this page