Clean architecture in Flutter is one of those topics where the dogma has outpaced the pragmatism. I have seen teams create seven folders and four abstraction layers for a todo app, and I have seen teams ship $10K MRR products with a flat lib/ folder. The truth sits in the middle: clean architecture is a tool, not a religion. This guide shows the pragmatic version I actually use in production Flutter apps.
We will walk through the four layers (Domain, Data, Presentation, Infrastructure), the Dart folder structure that keeps it navigable, and a complete code example — entity + repository interface + concrete Firebase implementation + use case + BLoC — for a feature. Then we will compare it honestly with MVVM and feature-first approaches so you can pick what fits your team.
What Clean Architecture Actually Means in Flutter
Uncle Bob's original clean architecture is language-agnostic. For Flutter, it boils down to three principles:
- Dependencies point inward. UI depends on business logic. Business logic does not depend on UI.
- Business logic is pure Dart. No Flutter imports, no Firebase imports. You should be able to run your domain tests with
dart test(no Flutter). - External dependencies are behind interfaces. Firebase, HTTP, SharedPreferences — all accessed through repository abstractions you define.
Get those three right and the folder structure follows naturally. Violate any one and you end up with spaghetti that breaks when you swap Firebase for Supabase or add a new state management layer.
The Four Layers
| Layer | Responsibility | Depends On | Pure Dart? |
|---|---|---|---|
| Domain | Entities, repository interfaces, use cases | Nothing | Yes |
| Data | Concrete repository impls, data sources, DTOs | Domain | No (Firebase, HTTP) |
| Presentation | BLoCs, widgets, routes | Domain | No (Flutter) |
| Infrastructure | DI setup, config, app entry | All | No |
Notice what does not appear: presentation does not depend on data. Widgets never import Firestore. That is the rule that saves you when you migrate backends.
Folder Structure
There are two flavors: layer-first (top-level folders are layers) or feature-first with layers inside (top-level folders are features; layers live inside). Feature-first wins for any project above trivial size — it keeps related code together and navigation scales.
lib/
├── core/
│ ├── di/ # get_it setup
│ ├── error/ # Failure types, exception mapping
│ ├── network/ # http client, interceptors
│ └── theme/ # Material 3 design tokens
│
├── features/
│ ├── auth/
│ │ ├── domain/
│ │ │ ├── entities/ # User
│ │ │ ├── repositories/ # AuthRepository (abstract)
│ │ │ └── usecases/ # SignIn, SignOut, GetCurrentUser
│ │ ├── data/
│ │ │ ├── datasources/ # FirebaseAuthRemote, AuthLocal
│ │ │ ├── models/ # UserModel (DTO with fromFirestore)
│ │ │ └── repositories/ # AuthRepositoryImpl
│ │ └── presentation/
│ │ ├── bloc/ # AuthBloc, events, states
│ │ ├── pages/ # SignInPage, SignUpPage
│ │ └── widgets/
│ │
│ ├── notes/ # Same layered structure
│ └── billing/ # Same layered structure
│
└── main.dartEach feature is self-contained. If you delete features/notes/, nothing else breaks (assuming you did not leak notes into other features, which the abstractions prevent).
Full Code Example: Sign-In Flow
Let us walk the full stack for a single flow — email sign-in. This is the minimum viable example of clean architecture in Flutter that you can copy into any project.
Step 1: Domain Entity (Pure Dart)
// lib/features/auth/domain/entities/user.dart
class User {
final String id;
final String email;
final String? displayName;
final DateTime createdAt;
const User({
required this.id,
required this.email,
this.displayName,
required this.createdAt,
});
}No Firebase. No Flutter. Just a plain Dart class. You can unit-test logic that consumes this without mocking anything.
Step 2: Repository Interface (Domain)
// lib/features/auth/domain/repositories/auth_repository.dart
import '../entities/user.dart';
import '../../../../core/error/failure.dart';
import 'package:dartz/dartz.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> signInWithEmail(String email, String password);
Future<Either<Failure, User>> signUpWithEmail(String email, String password);
Future<Either<Failure, void>> signOut();
Stream<User?> authStateChanges();
}The Either<Failure, T> pattern (from the dartz package) makes error handling explicit — the caller sees every possible failure at the type level. Alternatives: return Result<T, E> with your own sealed class, or throw typed exceptions.
Step 3: Use Case (Domain)
// lib/features/auth/domain/usecases/sign_in_with_email.dart
class SignInWithEmail {
final AuthRepository _repo;
SignInWithEmail(this._repo);
Future<Either<Failure, User>> call({
required String email,
required String password,
}) {
if (!_isValidEmail(email)) {
return Future.value(Left(ValidationFailure('Invalid email')));
}
if (password.length < 8) {
return Future.value(Left(ValidationFailure('Password too short')));
}
return _repo.signInWithEmail(email, password);
}
bool _isValidEmail(String e) =>
RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(e);
}Use cases own the business rules. Email format, password length, any cross-repository orchestration — all lives here. The BLoC stays thin; the repository stays dumb.
Step 4: Concrete Repository (Data Layer)
// lib/features/auth/data/repositories/auth_repository_impl.dart
import 'package:firebase_auth/firebase_auth.dart' as fb;
class AuthRepositoryImpl implements AuthRepository {
final fb.FirebaseAuth _firebaseAuth;
AuthRepositoryImpl(this._firebaseAuth);
@override
Future<Either<Failure, User>> signInWithEmail(String email, String password) async {
try {
final cred = await _firebaseAuth.signInWithEmailAndPassword(
email: email, password: password,
);
final fbUser = cred.user!;
return Right(User(
id: fbUser.uid,
email: fbUser.email!,
displayName: fbUser.displayName,
createdAt: fbUser.metadata.creationTime ?? DateTime.now(),
));
} on fb.FirebaseAuthException catch (e) {
return Left(_mapFirebaseError(e));
} catch (_) {
return Left(UnexpectedFailure());
}
}
Failure _mapFirebaseError(fb.FirebaseAuthException e) {
switch (e.code) {
case 'user-not-found':
case 'wrong-password':
case 'invalid-credential':
return AuthFailure('Email or password incorrect');
case 'network-request-failed':
return NetworkFailure();
default:
return AuthFailure(e.message ?? 'Sign-in failed');
}
}
@override
Stream<User?> authStateChanges() =>
_firebaseAuth.authStateChanges().map((fbUser) {
if (fbUser == null) return null;
return User(
id: fbUser.uid,
email: fbUser.email!,
displayName: fbUser.displayName,
createdAt: fbUser.metadata.creationTime ?? DateTime.now(),
);
});
@override
Future<Either<Failure, User>> signUpWithEmail(String email, String password) async {
// ...similar structure
throw UnimplementedError();
}
@override
Future<Either<Failure, void>> signOut() async {
try {
await _firebaseAuth.signOut();
return const Right(null);
} catch (_) {
return Left(UnexpectedFailure());
}
}
}This is the only file that imports Firebase. Swap Firebase for Supabase later? Create a new SupabaseAuthRepositoryImpl, register it in DI, done. Nothing else changes.
Step 5: BLoC (Presentation)
// lib/features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final SignInWithEmail _signIn;
final SignOut _signOut;
AuthBloc(this._signIn, this._signOut) : super(AuthInitial()) {
on<SignInRequested>((event, emit) async {
emit(AuthLoading());
final result = await _signIn(email: event.email, password: event.password);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
});
on<SignOutRequested>((event, emit) async {
await _signOut();
emit(AuthUnauthenticated());
});
}
}The BLoC consumes use cases, not repositories. This is the step most developers skip, and it is the step that makes the whole architecture pay off. Use cases encode the business rules; BLoCs encode the UI state machine. Keep them separate.
Step 6: Wire with Dependency Injection
// lib/core/di/injection.dart
import 'package:get_it/get_it.dart';
final sl = GetIt.instance;
Future<void> configureDependencies() async {
// External
sl.registerLazySingleton(() => FirebaseAuth.instance);
// Data
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(sl()),
);
// Domain
sl.registerFactory(() => SignInWithEmail(sl()));
sl.registerFactory(() => SignOut(sl()));
// Presentation
sl.registerFactory(() => AuthBloc(sl(), sl()));
}One file registers every dependency. Tests swap sl.registerLazySingleton<AuthRepository> with a mock in one line. This is the payoff: every layer is testable in isolation.
Clean Architecture vs MVVM
MVVM (Model-View-ViewModel) is the pattern most iOS developers know. It is simpler than clean architecture: one ViewModel per screen, direct access to data sources. Here is the honest comparison.
| Aspect | Clean Architecture | MVVM |
|---|---|---|
| Layers | 3-4 (Domain / Data / Presentation / Infra) | 2 (ViewModel / View) |
| Boilerplate | More | Less |
| Testability | Excellent — domain is pure Dart | Good — ViewModels are testable |
| Swappable backends | Easy (repository interface) | Harder (ViewModel couples to data source) |
| Team onboarding | Slower | Faster |
| Best for | Long-lived products, multi-backend | MVPs, single-backend apps |
My rule of thumb: if the project will live longer than 12 months or might swap backends (Firebase to Supabase, REST to GraphQL), use clean architecture. If it is a weekend MVP, MVVM is fine.
Clean Architecture vs Feature-First (No Layers)
Feature-first without layered separation — just features/notes/notes_page.dart + notes_service.dart — is the Very Good Core approach. It is simpler, faster to navigate, and still gives you good testability if services are abstracted.
| Aspect | Clean Architecture | Feature-First Flat |
|---|---|---|
| Files per feature | 8-15 | 3-5 |
| Navigation cost | Higher (deep folders) | Lower (flat folders) |
| Testability | Best-in-class | Good if services are abstracted |
| Backend portability | Excellent | Moderate |
| Team fit | Large teams, many devs | Solo / small teams |
For solo indies, flat feature-first is pragmatic. For 3+ developers on a long-lived product, full clean architecture pays off in reduced regression risk.
How The Flutter Kit Does Clean Architecture (Without the Dogma)
The Flutter Kit uses clean-arch principles without drowning in layers. We use BLoC for state, repositories for data access, services for cross-cutting concerns (notifications, analytics), and get_it for DI. Every repository has an interface; every concrete impl is swappable. But we do not add a separate use case layer unless a feature actually has business logic worth extracting.
The result: you get the testability and portability benefits of clean architecture, without the 12-file feature explosion of the dogmatic version. For indie developers, this is the sweet spot. If you need the full layered version later, the repository abstractions make the refactor trivial — just extract use cases between BLoC and repository.
When NOT to Use Clean Architecture
- Weekend MVPs. You are not shipping to real users yet. Ship first, refactor later.
- Solo developer + 1 backend + no plan to migrate. Flat feature-first is fine. Do not pre-optimize.
- UI-heavy apps with minimal business logic. A design portfolio app does not need use cases.
- You do not have the discipline to maintain it. A half-applied clean arch is worse than no architecture — mixed conventions confuse everyone.
When You Absolutely Should Use It
- 3+ developers on the same Flutter project
- Backend might change (Firebase → Supabase, REST → GraphQL, on-prem → cloud)
- Multi-platform (mobile + web + desktop) with shared business logic
- Regulatory or compliance requirements that demand testable business rules (healthtech, fintech)
- Codebase expected to live 3+ years
Shipping on a Clean Foundation
Building a production Flutter app with clean architecture from scratch takes 100-200 hours before you have your first feature. Skip it by starting from The Flutter Kit — BLoC with repository abstractions, DI configured, Firebase wired behind interfaces, Material 3 design system, auth + RevenueCat + AI all following the same patterns you can extend or swap.
$69 one-time, unlimited commercial apps, lifetime updates. Grab it on the checkout page or see the features.
The Bottom Line
Clean architecture in Flutter is a tool, not a religion. The four layers exist to solve real problems — testability, portability, and team scale. If you do not have those problems, you do not need the full version. If you do, lean into it but keep pragmatism in mind: repositories over use cases if logic is thin, feature-first folders over pure layer-first, and always DI through interfaces so your data layer is swappable.
Nail the three principles — dependencies point inward, business logic is pure Dart, external dependencies are behind interfaces — and you have a production-ready Flutter foundation that will outlast any backend you pick today.