After building several Flutter apps, the one decision that changed how maintainable my code felt was switching from a flat folder structure to Clean Architecture. This guide walks you through exactly how I apply it in real projects — not just theory.
Why Architecture Matters in Flutter
Flutter makes it dangerously easy to write everything inside a single StatefulWidget. It works — until the app grows. Business logic leaks into UI code, testing becomes painful, and onboarding a new developer turns into a week-long exercise in archaeology.
Clean Architecture solves this by enforcing one rule: dependencies only point inward. The UI depends on domain logic; domain logic never knows about the UI or Firebase or any specific database.
The Three Layers
1. Presentation Layer
Everything the user sees and interacts with — your widgets, screens, and controllers/view models. This layer is allowed to know about the domain layer but nothing else. In practice with GetX, this means your Controller calls use cases, not Firestore directly.
// presentation/controllers/doctor_controller.dart
class DoctorController extends GetxController {
final GetDoctorsUseCase getDoctors;
DoctorController({required this.getDoctors});
final doctors = <Doctor>[].obs;
Future<void> fetchDoctors() async {
final result = await getDoctors.call();
doctors.assignAll(result);
}
}
2. Domain Layer
The heart of your app — pure Dart, no Flutter, no Firebase. It defines your entities (data models), repository interfaces (contracts), and use cases (single-responsibility actions).
// domain/usecases/get_doctors_usecase.dart
class GetDoctorsUseCase {
final DoctorRepository repository;
GetDoctorsUseCase(this.repository);
Future<List<Doctor>> call() => repository.getDoctors();
}
3. Data Layer
This is where Firebase, REST APIs, and local databases live. The data layer implements the repository interfaces defined in domain. Swapping Firebase for a REST API? Only this layer changes.
// data/repositories/doctor_repository_impl.dart
class DoctorRepositoryImpl implements DoctorRepository {
final FirebaseFirestore firestore;
DoctorRepositoryImpl(this.firestore);
@override
Future<List<Doctor>> getDoctors() async {
final snapshot = await firestore.collection('doctors').get();
return snapshot.docs.map((d) => DoctorModel.fromJson(d.data())).toList();
}
}
Folder Structure I Use
lib/
├── core/ # shared utils, constants, theme
├── features/
│ └── doctors/
│ ├── data/
│ │ ├── models/
│ │ └── repositories/
│ ├── domain/
│ │ ├── entities/
│ │ ├── repositories/ ← interfaces only
│ │ └── usecases/
│ └── presentation/
│ ├── controllers/
│ └── screens/
└── main.dart
Dependency Injection with GetX
I wire everything together in a Binding class so no layer manually constructs another:
class DoctorsBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<DoctorRepository>(
() => DoctorRepositoryImpl(FirebaseFirestore.instance),
);
Get.lazyPut(() => GetDoctorsUseCase(Get.find()));
Get.lazyPut(() => DoctorController(getDoctors: Get.find()));
}
}
The Payoff
On the PDBC doctor app, this structure meant I could write unit tests for every use case without mocking Firebase — just pure Dart. When the client changed the API endpoint mid-project, I updated one file in the data layer and everything else kept working. That's the promise Clean Architecture delivers.
"The architecture of a system is the set of significant decisions about its organization." — Grady Booch
Start with the domain layer — define your entities and use cases first, before writing a single widget. It forces clarity about what your app actually does, separate from how it looks or where it stores data.
Key Takeaways
- Dependencies always point inward (Presentation → Domain ← Data)
- The domain layer is pure Dart — no Flutter SDK imports
- Use cases are single-responsibility: one class, one action
- Bindings handle DI cleanly with GetX
- Swapping backends requires changing only the data layer