November 12, 2025 · 3 min read
Clean Architecture in Flutter: Layers That Scale
Clean Architecture without the dogma: the one dependency rule that matters, and how to structure domain, data, and presentation in a Flutter app that has to grow.
Clean Architecture gets a bad rap in the Flutter community for being "too much boilerplate." Used dogmatically on a three-screen app, it is. But on anything that has to live and grow, the discipline pays for itself every time requirements change. Here's how I structure it.
The one rule that matters
Dependencies point inward. The domain is the center and knows nothing about Flutter, HTTP, or databases. Outer layers depend on inner layers, never the reverse.
presentation → domain ← data
(UI/Bloc) (entities, (repos impl,
use cases, API, DB)
contracts)
The domain defines interfaces; the data layer implements them. That inversion is what keeps your business logic pure and testable.
Domain: entities and use cases
Entities are plain Dart — no json, no framework. Use cases are single, verb-named actions.
// domain/entities/article.dart
class Article {
const Article({required this.id, required this.title, required this.body});
final String id;
final String title;
final String body;
}
// domain/repositories/article_repository.dart (a contract, not an impl)
abstract interface class ArticleRepository {
Future<List<Article>> getPublished();
}
// domain/usecases/get_published_articles.dart
class GetPublishedArticles {
const GetPublishedArticles(this._repo);
final ArticleRepository _repo;
Future<List<Article>> call() => _repo.getPublished();
}
Notice the use case depends on the abstract ArticleRepository. The domain compiles with zero knowledge of where articles come from.
Data: models and repository implementations
The data layer implements the domain contract and deals with the messy outside world — DTOs, JSON, error mapping.
// data/models/article_model.dart
class ArticleModel extends Article {
const ArticleModel({required super.id, required super.title, required super.body});
factory ArticleModel.fromJson(Map<String, dynamic> json) => ArticleModel(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
);
}
// data/repositories/article_repository_impl.dart
class ArticleRepositoryImpl implements ArticleRepository {
ArticleRepositoryImpl(this._remote);
final ArticleRemoteDataSource _remote;
@override
Future<List<Article>> getPublished() async {
final rows = await _remote.fetchPublished();
return rows.map(ArticleModel.fromJson).toList();
}
}
Keeping ArticleModel separate from Article means a change in your API's JSON shape stops at the data layer — the domain and UI never notice.
Presentation: Bloc as the boundary
The UI talks to a Bloc/Cubit that calls use cases and emits states. Widgets render state; they don't fetch.
sealed class ArticlesState {}
class ArticlesLoading extends ArticlesState {}
class ArticlesLoaded extends ArticlesState {
ArticlesLoaded(this.articles);
final List<Article> articles;
}
class ArticlesError extends ArticlesState {
ArticlesError(this.message);
final String message;
}
class ArticlesCubit extends Cubit<ArticlesState> {
ArticlesCubit(this._getPublished) : super(ArticlesLoading());
final GetPublishedArticles _getPublished;
Future<void> load() async {
emit(ArticlesLoading());
try {
emit(ArticlesLoaded(await _getPublished()));
} catch (e) {
emit(ArticlesError('Could not load articles'));
}
}
}
Wiring it together
Composition happens once, at the edge, with a service locator like get_it. Nothing inside the layers references get_it — they receive their dependencies through constructors.
final sl = GetIt.instance;
void configureDependencies() {
sl.registerLazySingleton<ArticleRemoteDataSource>(() => ArticleRemoteDataSourceImpl(sl()));
sl.registerLazySingleton<ArticleRepository>(() => ArticleRepositoryImpl(sl()));
sl.registerFactory(() => GetPublishedArticles(sl()));
sl.registerFactory(() => ArticlesCubit(sl()));
}
When it's worth it
Reach for the full structure when the app has real domain logic, multiple data sources, or a team. For a weekend prototype, a repository plus a Cubit is plenty. The value isn't the folders — it's the dependency rule. Keep your business logic ignorant of Flutter and your codebase stays testable and easy to change, no matter how big it gets.