← Flutter Reference

Flutter Clean Architecture: A Complete Guide

Architecture · Advanced

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

Clean Architecture in Flutter is not about layers for the sake of layers — it is about making change cheap and testing easy. On iStoria, a 5M+ user education app with 50+ modules, Clean Architecture is what lets a 4-engineer squad ship weekly without a tangled rewrite every quarter.

The core idea is the dependency rule: dependencies point inward. The domain layer knows nothing about Flutter, HTTP, or SQLite. The data layer implements the domain's contracts. The presentation layer talks to the domain through use cases. Each boundary is an interface, which means you can swap the database, the HTTP client, or the state management library without touching the business rules.

This guide is the pattern I use in production — not the textbook version that spawns six use cases per feature. It is trimmed to what actually pays off at scale: testable domain logic, swappable data sources, and a presentation layer that is dumb on purpose.

What Clean Architecture Actually Solves

Before adopting Clean Architecture, our codebase had a common sickness: business logic lived inside widget trees and API callbacks. Changing the database schema broke the UI. Testing meant booting the whole app. Onboarding a new engineer took weeks because there was no single place to look for "the rules of the business."

Clean Architecture forces three separations:

1. Domain — the business rules. Entities, value objects, repository contracts (abstract interfaces), and use cases. Pure Dart. No Flutter, no I/O. 2. Data — the implementation of those contracts. Repositories that coordinate remote and local data sources, DTOs, mappers, and the actual HTTP/database clients. 3. Presentation — the UI and state management. Widgets, BLoC/Cubit, routing. It depends on use cases, never on repositories directly.

The dependency rule is non-negotiable: inner layers never import from outer layers. The domain layer has zero knowledge of flutter, dio, drift, or bloc. This is what makes the domain testable in isolation and portable across state management choices.

Layers in Detail

Domain Layer

The domain layer holds the contracts and the rules. A repository is defined here as an abstract class — the data layer will implement it. Use cases (or "interactors") orchestrate one business operation each.

Keep use cases thin. If a use case is just repository.get(), skip it and call the repository directly from the BLoC. We learned this the hard way: dozens of pass-through use cases added ceremony without value. A use case earns its existence when it coordinates multiple repositories or enforces a business rule the repository should not know about.

Data Layer

Repositories here implement the domain's abstract contracts. They depend on remote and local data sources, map DTOs to domain entities, and decide cache strategy. The key principle: the domain defines getLessons() returns List<Lesson>. The data layer decides whether that comes from the API, Drift, or PowerSync's sync engine. The domain never knows.

Mappers live here, not in entities. A LessonModel (DTO) has a toEntity() method. The domain entity has no fromJson. This keeps the domain pure.

Presentation Layer

The presentation layer consumes use cases and renders state. At iStoria we use BLoC and Cubit — Cubit for simple feature states, BLoC for features with complex event flows or analytics requirements. The BLoC depends on use cases (or repositories for simple features), never on Dio, Drift, or any data-layer concrete class.

Widgets subscribe to the BLoC's state stream and render pure functions of state. No business logic in widgets. No if (response.statusCode == 200) in a build() method.

The Dependency Rule in Practice

Here is the litmus test: can you delete the entire data and presentation layer and still compile the domain? If yes, your boundaries are clean. If the domain imports dart:io or flutter/foundation.dart, the boundary is broken.

At iStoria, we enforce this with import linting. The domain package has a analysis_options.yaml that bans package:flutter/, package:dio/, package:drift/*. A CI check fails the build if someone sneaks a Flutter import into the domain. This is more valuable than any architecture diagram.

When to Use It vs. Skip It

Clean Architecture has a cost: indirection. For a prototype or a throwaway feature, three layers per feature is overkill. We use a lighter pattern for experiments — a repository and a Cubit, no separate domain package. When the feature stabilizes and acquires business rules worth protecting, we promote it to the full layered structure.

The rule of thumb: use Clean Architecture when the business logic is complex enough that you would be afraid to change it without tests. If the feature is CRUD over an API with no business rules, a repository + Cubit is enough.

Testing Strategy

The layered structure pays for itself in testing. The domain is tested with pure unit tests — no mocks of Flutter, HTTP, or databases. The data layer is tested with fakes for remote/local sources. The presentation layer is tested with mocked use cases and widget tests.

This is the single biggest payoff: you can test the business rules in milliseconds, without a device, without a network mock, without waiting for a build. On a 50+ module codebase, that speed is what keeps the team shipping.

Recommended folder structure

lib/
├── core/                    # shared utilities, theme, routing, DI container
│   ├── error/
│   │   ├── failures.dart
│   │   └── exceptions.dart
│   ├── usecases/
│   │   └── usecase.dart     # abstract UseCase<Type, Params>
│   └── utils/
├── features/
│   └── lesson/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── lesson.dart
│       │   ├── repositories/
│       │   │   └── lesson_repository.dart   # abstract contract
│       │   └── usecases/
│       │       └── get_lessons.dart
│       ├── data/
│       │   ├── datasources/
│       │   │   ├── lesson_remote_datasource.dart
│       │   │   └── lesson_local_datasource.dart
│       │   ├── models/
│       │   │   └── lesson_model.dart        # DTO + toEntity/fromJson
│       │   └── repositories/
│       │       └── lesson_repository_impl.dart
│       └── presentation/
│           ├── bloc/
│           │   ├── lesson_bloc.dart
│           │   ├── lesson_event.dart
│           │   └── lesson_state.dart
│           ├── pages/
│           │   └── lesson_page.dart
│           └── widgets/
│               └── lesson_card.dart

Code example

// domain/entities/lesson.dart — PURE DART, no Flutter imports
class Lesson {
  final String id;
  final String title;
  final int durationMinutes;
  final bool isCompleted;
  final String courseId;

  const Lesson({
    required this.id,
    required this.title,
    required this.durationMinutes,
    required this.isCompleted,
    required this.courseId,
  });

  Lesson copyWith({
    String? title,
    int? durationMinutes,
    bool? isCompleted,
  }) {
    return Lesson(
      id: id,
      title: title ?? this.title,
      durationMinutes: durationMinutes ?? this.durationMinutes,
      isCompleted: isCompleted ?? this.isCompleted,
      courseId: courseId,
    );
  }
}

// domain/repositories/lesson_repository.dart — CONTRACT only, no implementation
abstract class LessonRepository {
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId);
  Future<Either<Failure, Lesson>> markComplete(String lessonId);
  Stream<List<Lesson>> watchLessons(String courseId);
}

// data/repositories/lesson_repository_impl.dart — THE IMPLEMENTATION
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remoteDatasource;
  final LessonLocalDatasource localDatasource;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remoteDatasource,
    required this.localDatasource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteLessons = await remoteDatasource.fetchLessons(courseId);
        await localDatasource.cacheLessons(courseId, remoteLessons);
        return Right(remoteLessons.map((m) => m.toEntity()).toList());
      } on ServerException {
        // Fall through to local — offline-first cache strategy
      }
    }
    try {
      final localLessons = await localDatasource.getCachedLessons(courseId);
      return Right(localLessons.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Stream<List<Lesson>> watchLessons(String courseId) {
    return localDatasource
        .watchCachedLessons(courseId)
        .map((models) => models.map((m) => m.toEntity()).toList());
  }
}

Implementation checklist

  • Start with the domain layer: define entities and abstract repository contracts before writing any implementation.
  • Create the data layer: implement repository contracts with remote and local data sources, DTOs, and mappers.
  • Wire up dependency injection so the presentation layer receives abstract types, never concrete implementations.
  • Build the presentation layer with BLoC or Cubit depending on the feature's event complexity.
  • Write unit tests for the domain layer first — they should run with zero Flutter or network dependencies.
  • Add import linting that bans Flutter and I/O packages in the domain layer, enforced in CI.
  • Promote features from lightweight (repository + Cubit) to full Clean Architecture only when business logic justifies it.

Common mistakes

  • Creating a pass-through use case for every single repository call — use cases should earn their existence by coordinating or enforcing rules.
  • Putting fromJson / toJson on domain entities instead of on data-layer DTO models, leaking serialization concerns inward.
  • Having the BLoC import Dio or Drift directly, bypassing the repository abstraction and coupling state to infrastructure.
  • Building all three layers for trivial CRUD features with zero business logic, adding ceremony without value.
  • Skipping import linting — without CI enforcement, the dependency rule erodes within weeks as shortcuts accumulate.

Related definitions

Related reading

Related case studies

FAQ

Is Clean Architecture overkill for a small Flutter app?

For a prototype or an app with little business logic, yes. Start with a repository + Cubit per feature. Promote to full layered Clean Architecture when the business rules become complex enough that you need to test them in isolation and protect them from UI or data-source changes.

Should every repository method have its own use case?

No. Use cases exist to coordinate multiple repositories or enforce a business rule. If a use case is just forwarding a call to a single repository method, skip it and call the repository directly from the BLoC. Pass-through use cases add ceremony without value.

How does Clean Architecture work with BLoC?

The BLoC lives in the presentation layer and depends on use cases or repositories (abstract contracts from the domain). The BLoC never imports concrete data-layer classes. This means you can test the BLoC with mocked use cases and swap the state management library without touching business rules.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work