---
title: "Architecture and Testing"
description: "Clean Architecture layers and the dependency rule, SOLID with Flutter specific tells, and test driven development from the domain layer up."
tags: [flutter, dart, architecture, clean-architecture, solid, tdd, testing]
---

## 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.**

```text
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**.

```dart
// domain/repositories/order_repository.dart   pure Dart
abstract class OrderRepository {
  Future<List<Order>> fetchOpenOrders();
  Future<void> cancel(OrderId id);
}
```

```dart
// 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.

```dart
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

```text
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 `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

```text
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`.

```dart
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.

```dart
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

```dart
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.

```bash
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

```text
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
```
