← Back to Portfolio Muhammad Shafeeq
Architecture

Clean Architecture in Flutter: A Practical Guide

Clean code on a laptop screen

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