Flutter makes it dangerously easy to put everything in one widget — UI, business logic, and an HTTP call all tangled in a single build method. It works for a demo and collapses for a real app. Building Maarifah, an educational app, pushed me to adopt Clean Architecture properly. This is the structure I settled on and why each boundary earns its keep.
The Core Idea: Dependencies Point Inward
Clean Architecture organizes code into concentric layers. The rule that makes it work is simple: inner layers know nothing about outer layers. Your business rules don't import Flutter, don't know about HTTP, and don't care whether data comes from a server or a local file. Outer layers depend on inner ones, never the reverse.
In a Flutter project this collapses into three practical layers per feature:
lib/
└── features/
└── lessons/
├── presentation/ # widgets, state (UI)
├── domain/ # entities, use cases (rules)
└── data/ # models, repositories, sources
The Domain Layer: Pure Dart
The domain is the heart and it imports nothing from Flutter or any package. It holds entities (plain objects representing core concepts) and use cases (one class per action the app can perform):
// entity — no annotations, no JSON, pure
class Lesson {
final String id;
final String title;
final bool isCompleted;
const Lesson({ required this.id, required this.title,
this.isCompleted = false });
}
// use case — a single responsibility
class GetLessons {
final LessonRepository repo;
GetLessons(this.repo);
Future<List<Lesson>> call() => repo.getLessons();
}
Notice LessonRepository is referenced but not defined here — the domain declares an abstract repository interface and lets the data layer implement it. This inversion is what keeps the domain free of infrastructure.
The Data Layer: Where Reality Lives
The data layer implements the repository interface and deals with the messy outside world — JSON, databases, caching. Models extend entities and add serialization:
class LessonModel extends Lesson {
const LessonModel({ required super.id, required super.title,
super.isCompleted });
factory LessonModel.fromJson(Map<String, dynamic> json) =>
LessonModel(
id: json['id'],
title: json['title'],
isCompleted: json['is_completed'] ?? false,
);
}
Maarifah uses local data, so the repository reads from a bundled source and a local store rather than a network. The beauty of the boundary: the domain and UI have no idea the data is local. If I later switch to a remote API, only the data layer changes.
The Presentation Layer: UI and State
Widgets stay dumb. They render state and forward user intents to a state manager (Bloc, Riverpod, or a simple ChangeNotifier — the pattern is what matters, not the package). The state manager calls use cases and never touches a repository directly:
class LessonsNotifier extends ChangeNotifier {
final GetLessons getLessons;
LessonsState state = LessonsState.loading();
Future<void> load() async {
state = LessonsState.loading();
notifyListeners();
final lessons = await getLessons(); // use case
state = LessonsState.data(lessons);
notifyListeners();
}
}
Wiring It Together with Dependency Injection
Each layer depends on abstractions, so something has to supply the concrete implementations at startup. A service locator (like get_it) registers everything in one place:
final sl = GetIt.instance;
void init() {
// presentation
sl.registerFactory(() => LessonsNotifier(getLessons: sl()));
// domain
sl.registerLazySingleton(() => GetLessons(sl()));
// data
sl.registerLazySingleton<LessonRepository>(
() => LessonRepositoryImpl(localSource: sl()));
}
This single file becomes the map of how the whole app is assembled — and the seam where you swap real implementations for fakes in tests.
Why the Overhead Pays Off
Yes, this is more files than dumping logic in a widget. Here's what you buy:
- Testability. Use cases are pure functions over a mocked repository — fast unit tests with no Flutter test harness needed.
- Changeable infrastructure. Switch local storage for an API, or one state library for another, by touching a single layer.
- Parallel work. The domain interface is a contract — UI and data can be built against it independently.
- Readability. Each class does one thing. New contributors find logic where they expect it.
When NOT to Use It
Clean Architecture is overkill for a throwaway prototype or a three-screen utility. The ceremony only pays back when an app is going to grow and live for a while. For Maarifah — with lessons, progress tracking, and content that will expand — the structure has kept the codebase calm as features piled on. Match the architecture to the lifespan of the app, not to a checklist.
Closing Thought
The single principle worth remembering is the dependency rule: point dependencies inward, toward stable business logic, away from volatile frameworks and I/O. Everything else — the folder names, the DI container, the model-vs-entity split — is just bookkeeping in service of that one idea.