All Posts

Clean Architecture in Flutter: A Practical Guide

How I structure production Flutter apps using Clean Architecture — separating domain, data, and presentation layers with real code examples.

Series: Flutter Architecture Patterns Part 1
flutterclean-architecturedartbest-practices
Diagram of Flutter Clean Architecture layers

Why Architecture Matters in Flutter

Flutter lets you ship fast — sometimes too fast. Without intentional structure, widgets balloon into 500-line behemoths that own networking, business logic, and rendering all at once.

Clean Architecture draws three concentric layers:

  1. Domain — pure Dart: entities, use cases, repository interfaces
  2. Data — implements domain interfaces: remote sources, local cache, DTOs
  3. Presentation — Flutter widgets + state management (Riverpod / Bloc)

The rule: dependencies point inward. Domain knows nothing about Flutter or Firebase.

Folder Structure

lib/
├── core/               # Shared utilities, error types, network client
├── features/
│   └── projects/
│       ├── domain/
│       │   ├── entities/project.dart
│       │   ├── repositories/project_repository.dart
│       │   └── usecases/get_projects.dart
│       ├── data/
│       │   ├── datasources/projects_remote_source.dart
│       │   ├── models/project_model.dart
│       │   └── repositories/project_repository_impl.dart
│       └── presentation/
│           ├── pages/projects_page.dart
│           ├── widgets/project_card.dart
│           └── providers/projects_provider.dart
└── injection_container.dart

The Domain Layer

// entities/project.dart — plain Dart, zero Flutter imports
class Project {
  final String id;
  final String title;
  final String description;
  final List<String> stack;

  const Project({
    required this.id,
    required this.title,
    required this.description,
    required this.stack,
  });
}

// repositories/project_repository.dart — interface only
abstract class ProjectRepository {
  Future<List<Project>> getProjects();
}

// usecases/get_projects.dart
class GetProjects {
  final ProjectRepository _repo;
  GetProjects(this._repo);

  Future<List<Project>> call() => _repo.getProjects();
}

The use case is a single callable class — easy to test in pure Dart without any Flutter test machinery.

Testing the Use Case

void main() {
  late MockProjectRepository mockRepo;
  late GetProjects useCase;

  setUp(() {
    mockRepo = MockProjectRepository();
    useCase = GetProjects(mockRepo);
  });

  test('returns projects from repository', () async {
    when(() => mockRepo.getProjects()).thenAnswer((_) async => fakeProjects);
    final result = await useCase();
    expect(result, fakeProjects);
  });
}

No pumpWidget, no tester — pure business-logic test that runs in milliseconds.

Key Takeaways

  • Keep domain/ free of Flutter and third-party imports
  • Use cases = one public method, testable in isolation
  • Let DI wire everything together (get_it or Riverpod providers)
  • The data layer absorbs all API/DB churn so domain and UI stay stable