TDD and Clean Architecture in Flutter
Every Flutter app I've shipped past a prototype ends up in the same three layers. Not because Clean Architecture is dogma — because it's the only structure that survives a backend API changing shape six months into the project without a rewrite.
The layers
- Presentation — widgets, state management (Cubit/Bloc), nothing that knows about HTTP or SQL.
- Domain — use cases and entities, pure Dart, zero Flutter imports. This is the layer that doesn't change when the backend does.
- Data — repositories implementing the domain's interfaces, talking to REST APIs, Firebase, or local storage.
Dependencies point inward: data depends on domain, presentation depends on domain, domain depends on nothing.
// domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
}// domain/usecases/login_usecase.dart class LoginUseCase { final AuthRepository repository; LoginUseCase(this.repository);
Future<Either<Failure, User>> call(String email, String password) => repository.login(email, password); } ```
The use case doesn't know if AuthRepository hits Firebase Auth or a JWT endpoint. That's what let Hodiya Backend's auth swap from a first-pass implementation to refresh-token rotation without touching a single widget.
Red, green, refactor — starting from the use case
TDD in Flutter earns its keep at the domain layer, where there's no widget tree to mock. I write the use case test first, against a fake repository:
test('returns User when credentials are valid', () async {
when(mockRepository.login(any, any))
.thenAnswer((_) async => Right(testUser));final result = await loginUseCase('a@b.com', 'password');
expect(result, Right(testUser)); }); ```
Red, then the minimum LoginUseCase code to pass, then refactor. The same loop moves outward to the Cubit (mock the use case) and, sparingly, to widget tests for the screens that carry real logic — form validation, error states — not every screen.
Where it pays off
- Backend swaps don't cascade. Domain interfaces stay put; only the data-layer implementation changes.
- Tests stay fast. Domain and Cubit tests run in milliseconds with no widget pump, no device.
- New features slot in predictably. Same three folders, every time, so nobody has to relearn the shape of the app.
The discipline costs more typing up front — an interface and a use case for what could've been a direct API call in the widget. On a throwaway prototype, skip it. On anything a client depends on for the next year, it's the cheapest insurance available.