The Core Objective: Inversion of Control and Isolation
The goal of Clean Architecture is not to maximize the number of folders in your project. It is to enforce a single fundamental rule: The Dependency Inversion Principle. High-level business rules must not depend on low-level implementation details; both must depend on abstractions.
In practical Flutter terms, your core business logic (e.g., calculating checkout totals, validating coupon codes, or managing user sessions) must never know or care whether your mobile app is using Firebase, a custom FastAPI backend, SQLite, or raw in-memory arrays. Your domain logic should be pure, framework-agnostic Dart that can run in a standalone CLI script without importing `package:flutter`.
The Pragmatic Three-Layer Architecture
For production mobile applications, I organize code into three distinct, decoupled layers:
- 1. Presentation Layer: Flutter Widgets, Screens, UI animations, theme styling, and State Management Notifiers/Blocs. This layer only communicates with the Domain layer.
- 2. Domain Layer: Pure Dart Entities, UseCases (optional for complex domain rules), and abstract Repository interfaces. This layer has zero external dependencies and zero Flutter imports.
- 3. Data Layer: Concrete Repository implementations, Remote DataSources (Dio, Http, GraphQL), Local DataSources (Isar, Hive, SecureStorage), and DTO/JSON Mappers.
// DOMAIN LAYER (Pure Dart - No Flutter)
class UserEntity {
final String id;
final String email;
final bool isPremium;
const UserEntity({required this.id, required this.email, required this.isPremium});
}
abstract class AuthRepository {
Future<Either<Failure, UserEntity>> loginWithCredentials(String email, String password);
}
// DATA LAYER (Implementation Detail)
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteSource;
final AuthLocalDataSource localSource;
AuthRepositoryImpl({required this.remoteSource, required this.localSource});
@override
Future<Either<Failure, UserEntity>> loginWithCredentials(String email, String password) async {
try {
final userDto = await remoteSource.login(email, password);
await localSource.saveAuthToken(userDto.token);
return Right(userDto.toEntity());
} on NetworkException catch (e) {
return Left(ServerFailure(e.message));
}
}
}Feature-First vs Layer-First Organization
A critical mistake is structuring your project by technical layers at the root: `/presentation`, `/domain`, `/data`. When an application scales past 30 screens, navigating between layers requires endless scrolling across distant folders.
Instead, structure your project 'Feature-First'. Each business capability gets its own dedicated folder containing its presentation, domain, and data layers. When a feature is completed, it is a self-contained module that can be edited, tested, or removed without impacting unrelated code.
lib/
├── core/ # Shared utilities, theme tokens, network clients
│ ├── network/
│ ├── theme/
│ └── error/
├── features/
│ ├── auth/ # Feature module
│ │ ├── data/ # Models, Remote/Local DataSources, RepositoriesImpl
│ │ ├── domain/ # Entities, Repository Interfaces
│ │ └── presentation/ # Widgets, Screens, Controllers
│ ├── checkout/
│ └── profile/
└── main.dartWhen to Omit UseCases (Avoiding Academic Overkill)
In dogmatic Clean Architecture, every interaction requires a UseCase class: `GetUserDetailsUseCase`, `UpdateCartUseCase`. But if all your UseCase does is call `return repository.getUserDetails();`, it is merely adding boilerplate indirection without providing value.
My pragmatic rule: Omit single-line pass-through UseCases. Allow your presentation state controllers (Blocs or Notifiers) to call Repository methods directly. Only introduce a dedicated UseCase when you need to coordinate multiple repositories or execute complex, multi-step business transactions.
Dependency Injection: Service Locator vs Provider Container
Clean Architecture relies heavily on the Dependency Inversion Principle: high-level presentation and domain modules must not depend on low-level network clients or SQLite database classes directly. Both must depend on abstractions.
In Flutter, there are two primary patterns to inject these dependencies: GetIt (a compile-agnostic Service Locator) and Riverpod ProviderContainer. Using GetIt, you register repository contracts as lazy singletons in a central injection container. In testing environments, you swap real API implementations with Mockito or Fake classes without altering a single widget.
// Core Dependency Injection Setup using GetIt
final sl = GetIt.instance;
void setupServiceLocator() {
// External clients
sl.registerLazySingleton<Dio>(() => Dio(BaseOptions(baseUrl: 'https://api.domain.com')));
// Data sources & Repositories
sl.registerLazySingleton<AuthRemoteDataSource>(() => AuthRemoteDataSourceImpl(sl()));
sl.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl(sl()));
// Presentation State
sl.registerFactory(() => AuthBloc(sl()));
}Final Thoughts
Clean Architecture is a set of principles designed to serve your team, not an academic religion. Structure your code so that features are isolated, business logic is testable, and third-party dependencies can be replaced without rewriting your UI.
Key Takeaways
- Domain logic must remain pure Dart with zero dependencies on UI or networking frameworks.
- Organize files by feature first to keep related logic co-located.
- Avoid unnecessary one-line UseCase boilerplate; let repositories handle straightforward data access.
- Use abstract contracts to decouple mobile presentation from API and database implementations.
- Dependency injection via GetIt or Riverpod enables flawless automated unit testing with mock data.
Frequently Asked Questions
Isn't Clean Architecture too complex for a small startup MVP?
A full three-tier Clean Architecture setup can be overkill for a two-week prototype. However, adhering to the basic separation of concerns—keeping networking calls out of widget build methods—prevents the catastrophic refactoring rewrite that inevitably happens once the MVP gains traction.
Where should Data Transfer Objects (DTOs) and JSON serialization live?
DTOs with fromJson() and toJson() methods belong strictly in the Data layer. They map remote API JSON schemas into clean Domain Entities that contain zero serialization logic or network dependencies.
How do I handle error mapping between Data and Domain layers?
DataSources catch raw network exceptions (like DioException or SocketException) and throw typed ServerException or CacheException. The Repository layer catches these and transforms them into user-friendly Failure value objects returned via Dart 3 records: (Failure?, User?).




