Skill: Clean Architecture
Overview
Clean Architecture splits a Flutter app into three layers with a one-way dependency rule: Presentation → Domain ← Data. Domain is plain Dart — it must not import 'package:flutter/…' and must not know about Dio, Drift, or Riverpod. Data implements the interfaces Domain declares. Presentation renders state produced by a Notifier that calls use cases. The payoff is that business rules are testable with dart test alone (no widget pump, no device) and the data source can be swapped without touching a widget.
Use Cases
- New feature packages where business rules outlive the current backend or UI.
- Codebases where widgets call
http/Diodirectly and logic is impossible to test. - Apps with several data sources per concept (remote, cache, platform channel).
- Multi-package (melos) repos that need enforceable module boundaries.
Best Practices
- Organize package by feature, layer inside it —
lib/features/articles/{domain,data,presentation}/. Shared plumbing lives inlib/core/. - Keep Domain free of every framework import. A quick grep for
package:flutterunderdomain/is a cheap architecture test; encode it as a lint or a CI grep. - Model entities as immutable classes (
finalfields,constconstructors,copyWith). - Express a use case as a small callable class (
class GetArticles { Future<…> call(…) }) so it reads like a function at the call site and still injects like an object. - Declare repository interfaces in Domain, implement them in Data. Domain never sees a DTO.
- Return typed failures (a sealed
Failureunion orResult<T, Failure>), not raw exceptions, across the Data → Domain boundary. - Wire everything at the composition root (
ProviderScopeoverrides) — seedependency_injection.md.
Anti-Patterns
- ❌
import 'package:flutter/material.dart'anywhere underdomain/. - ❌ Widgets calling a repository or
Diodirectly frombuild()orinitState(). - ❌ DTOs (
fromJson/toJsonclasses) leaking into Domain or Presentation. - ❌ A
models/folder shared by all layers — it couples the API shape to the UI. - ❌ Anemic use cases that only forward one call and add no rule — inline those instead of generating ceremony.
- ❌ Organizing top-level folders by layer (
lib/models/,lib/screens/), which scatters every feature across the tree.
Checklist
- [ ] Domain has zero Flutter/package imports; entities are immutable.
- [ ] Repository interfaces live in Domain; implementations live in Data.
- [ ] DTO ⇄ entity mapping happens in Data and nowhere else.
- [ ] Presentation talks to use cases, never to a data source.
- [ ] Failures are typed and exhaustively handled at the Presentation boundary.
- [ ] Dependencies are injected at the composition root, not constructed inline.
- [ ] Domain and use cases have unit tests that need no
WidgetTester.
Dart Examples
dart
// domain/entities/article.dart — pure Dart, no Flutter imports
class Article {
const Article({required this.id, required this.title, required this.publishedAt});
final String id;
final String title;
final DateTime publishedAt;
}
// domain/repositories/article_repository.dart — the interface Domain owns
abstract interface class ArticleRepository {
Future<List<Article>> fetchArticles({required int page});
}
// domain/usecases/get_articles.dart
class GetArticles {
const GetArticles(this._repository);
final ArticleRepository _repository;
Future<List<Article>> call({int page = 1}) => _repository.fetchArticles(page: page);
}dart
// data/models/article_dto.dart — the wire shape stays in Data
class ArticleDto {
const ArticleDto({required this.id, required this.title, required this.publishedAt});
factory ArticleDto.fromJson(Map<String, dynamic> json) => ArticleDto(
id: json['id'] as String,
title: json['title'] as String,
publishedAt: json['published_at'] as String,
);
final String id;
final String title;
final String publishedAt;
Article toEntity() => Article(
id: id,
title: title,
publishedAt: DateTime.parse(publishedAt),
);
}
// data/repositories/article_repository_impl.dart
class ArticleRepositoryImpl implements ArticleRepository {
const ArticleRepositoryImpl(this._remote);
final ArticleRemoteDataSource _remote;
@override
Future<List<Article>> fetchArticles({required int page}) async {
final dtos = await _remote.getArticles(page: page);
return dtos.map((dto) => dto.toEntity()).toList(growable: false);
}
}Common Interview Questions
- Why must Domain not import Flutter? (Testability, portability, and it stops UI concerns from driving business rules.)
- Where do DTO → entity mappers belong, and why not in Domain?
- When is a use case class worth the ceremony over calling the repository directly?
- How do you enforce the dependency rule in a Dart codebase? (Package boundaries in melos,
importlint rules, or a CI grep overdomain/.) - How does this layering change what you can unit-test without a device?
AI Implementation Notes
- Generate the three layers together: entity + repository interface + use case, then DTO + mapper + repository impl, then notifier + widget.
- Never let a generated widget import from
data/. If it needs data, add a use case. - Default to
abstract interface classfor repository contracts (Dart 3) so they cannot be extended accidentally. - Pair every use case with a unit test using a fake repository — no mocks needed.
- iOS counterpart:
../ios/clean_architecture.md. - Related:
state_management.md,repository_pattern.md,../../../standards/flutter_standards.md.