Clean Architecture Fundamentals for Flutter
The Answer That Gets You Hired at Senior Level
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Clean Architecture organizes Flutter apps into three layers: Presentation (UI + BLoC), Domain (entities + use cases + repository interfaces), and Data (implementations + models). The dependency rule — inner layers know nothing about outer layers — decouples business logic from UI and infrastructure. This enables independent testing, maintainability, and the ability to swap implementations.
Real-world relevance
In an offline-first field app, the SyncUseCase in the domain layer calls abstract SyncRepository.push() and pull(). The data layer implements this with SQLite for local storage and HTTP for remote sync — the domain never knows which is used. The BLoC calls SyncUseCase, which the team tests with a mock repository in 100ms flat. Swapping the local database from SQLite to Hive requires only changing the data layer.
Key points
- The Dependency Rule — The Core Principle — The most important rule: dependencies point INWARD. Outer layers can depend on inner layers, but inner layers CANNOT depend on outer layers. Domain (innermost) knows nothing about Flutter, Dart IO, HTTP, or databases. Data layer implements interfaces defined by Domain. Presentation depends on Domain use cases, not data sources.
- Three Layers: Presentation, Domain, Data — Presentation: Flutter widgets, BLoC/Cubit, ViewModels. Only knows Domain use cases and entities. Domain: entities (pure Dart business objects), use cases (single-purpose business actions), repository interfaces (abstract contracts). Data: repository implementations, API clients, local database, data models with fromJson/toJson.
- Domain Layer — Pure Dart, Zero Flutter — The domain layer has NO Flutter imports, NO http imports, NO sqflite imports. It can be compiled as a pure Dart library. This is the test: if you can write domain tests with just 'dart test' and no Flutter SDK, your domain layer is clean. Entities model the business, use cases execute business rules.
- Entities vs Data Models — Entity: domain object with business identity. class User { final String id; final String name; }. No fromJson, no toJson — that's data layer concern. Data model: class UserModel extends User { factory UserModel.fromJson(Map json); Map toJson(); }. The model converts between JSON and entities. Interviewers love this distinction.
- Use Cases — Single Responsibility Business Actions — Each use case does ONE business operation: GetUserById, SubmitPayment, MarkAttendance, LogoutUser. It takes parameters (call with params object), calls the repository, returns an Entity or Result type. Use cases are the application's API — BLoC calls use cases, never repositories directly. This isolation makes business logic trivially testable.
- Repository Pattern — The Boundary — The repository interface lives in the DOMAIN layer (abstract class UserRepository). The implementation lives in the DATA layer (class ApiUserRepository implements UserRepository). Domain defines WHAT data it needs. Data layer decides HOW to get it (API? Cache? SQLite?). This inversion of control enables swapping implementations without touching business logic.
- Dependency Injection — Wiring the Layers — Use GetIt (service locator) or Riverpod to wire layers. Register: ApiUserRepository as UserRepository (interface). Inject into use cases. Inject use cases into BLoC. In tests, register MockUserRepository. The DI container is the only place that knows about concrete implementations. Everything else depends on abstractions.
- Folder Structure — Feature-First vs Layer-First — Layer-first: lib/data/, lib/domain/, lib/presentation/. All features in each layer. Feature-first: lib/features/auth/, lib/features/payment/, each with data/domain/presentation subdirectories. For teams: feature-first scales better (each team owns a feature folder). For small apps: layer-first is simpler. Interview: explain the tradeoffs.
- Result Type — Handling Errors Cleanly — Use cases return Either (dartz package) or a custom Result sealed class. Failures are domain concepts: NetworkFailure, AuthFailure, ValidationFailure. The BLoC pattern-matches on the result: if Success → emit Loaded state, if Failure → emit Error state. This avoids exception-based flow control across layer boundaries.
- Interview-Safe Explanation Template — 'I structure Flutter apps in three layers: presentation (widgets + BLoC), domain (use cases + entities + repository interfaces), and data (implementations + models). Dependencies point inward — domain has no Flutter or HTTP imports. BLoC calls use cases, use cases call repository interfaces, implementations in the data layer satisfy those interfaces. This makes every layer independently testable and swappable.'
Code example
// Clean Architecture in Flutter
// ============================================================
// DOMAIN LAYER — lib/features/auth/domain/
// Pure Dart — NO Flutter, NO http, NO sqflite imports
// ============================================================
// --- Entity: Business object ---
class User {
final String id;
final String email;
final String name;
final UserRole role;
const User({
required this.id,
required this.email,
required this.name,
required this.role,
});
}
enum UserRole { admin, teacher, student }
// --- Failure: Domain-level error types ---
sealed class Failure {
final String message;
const Failure(this.message);
}
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'Network error occurred']);
}
class AuthFailure extends Failure {
const AuthFailure([super.message = 'Authentication failed']);
}
class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
// --- Repository Interface: Defined in Domain, Implemented in Data ---
abstract class AuthRepository {
// Returns Right(User) on success, Left(Failure) on error
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> getCurrentUser();
Future<Either<Failure, void>> logout();
}
// --- Use Case: Single business operation ---
class LoginUseCase {
final AuthRepository _repository;
const LoginUseCase(this._repository);
// Params object: type-safe, extensible
Future<Either<Failure, User>> call(LoginParams params) async {
// Validate inputs in the domain layer
if (params.email.isEmpty) {
return const Left(ValidationFailure('Email is required'));
}
if (params.password.length < 8) {
return const Left(ValidationFailure('Password too short'));
}
// Delegate to repository
return _repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
const LoginParams({required this.email, required this.password});
}
// Simple Either implementation (or use dartz package)
sealed class Either<L, R> {
const Either();
}
class Left<L, R> extends Either<L, R> {
final L value;
const Left(this.value);
}
class Right<L, R> extends Either<L, R> {
final R value;
const Right(this.value);
}
// ============================================================
// DATA LAYER — lib/features/auth/data/
// Knows about HTTP, SQLite, local storage
// ============================================================
// --- Data Model: Entity + serialization ---
class UserModel extends User {
const UserModel({
required super.id,
required super.email,
required super.name,
required super.role,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
role: UserRole.values.byName(json['role'] as String),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'role': role.name,
};
// Convert to Domain entity (often the model IS the entity via inheritance)
User toEntity() => User(id: id, email: email, name: name, role: role);
}
// --- Repository Implementation: Satisfies the domain contract ---
class ApiAuthRepository implements AuthRepository {
final AuthApiClient _apiClient;
final AuthLocalCache _localCache;
const ApiAuthRepository({
required AuthApiClient apiClient,
required AuthLocalCache localCache,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
try {
final response = await _apiClient.login(email, password);
final user = UserModel.fromJson(response);
await _localCache.saveUser(user); // Cache for offline use
return Right(user.toEntity());
} on UnauthorizedException {
return const Left(AuthFailure('Invalid credentials'));
} on SocketException {
return const Left(NetworkFailure());
} catch (e) {
return Left(NetworkFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, User>> getCurrentUser() async {
try {
final cached = await _localCache.getUser();
if (cached != null) return Right(cached.toEntity());
return const Left(AuthFailure('No session found'));
} catch (e) {
return Left(NetworkFailure(e.toString()));
}
}
@override
Future<Either<Failure, void>> logout() async {
try {
await _apiClient.logout();
await _localCache.clearUser();
return const Right(null);
} catch (e) {
return Left(NetworkFailure(e.toString()));
}
}
}
// ============================================================
// PRESENTATION LAYER — lib/features/auth/presentation/
// Knows about Flutter, BLoC, Widgets
// ============================================================
import 'package:flutter_bloc/flutter_bloc.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase _loginUseCase; // Depends on USE CASE, not repository
final GetCurrentUserUseCase _getCurrentUser;
AuthBloc({
required LoginUseCase loginUseCase,
required GetCurrentUserUseCase getCurrentUser,
}) : _loginUseCase = loginUseCase,
_getCurrentUser = getCurrentUser,
super(const AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<CheckAuthStatus>(_onCheckAuthStatus);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
final result = await _loginUseCase(
LoginParams(email: event.email, password: event.password),
);
switch (result) {
case Right(:final value):
emit(AuthAuthenticated(user: value));
case Left(:final value):
emit(AuthError(message: value.message));
}
}
Future<void> _onCheckAuthStatus(
CheckAuthStatus event,
Emitter<AuthState> emit,
) async {
final result = await _getCurrentUser();
switch (result) {
case Right(:final value): emit(AuthAuthenticated(user: value));
case Left(): emit(const AuthUnauthenticated());
}
}
}
// ============================================================
// DEPENDENCY INJECTION — lib/injection.dart
// The only place that knows about concrete implementations
// ============================================================
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
Future<void> configureDependencies() async {
// Data layer
getIt.registerLazySingleton<AuthApiClient>(() => AuthApiClientImpl());
getIt.registerLazySingleton<AuthLocalCache>(() => HiveAuthLocalCache());
// Repositories: register interface → implementation
getIt.registerLazySingleton<AuthRepository>(
() => ApiAuthRepository(
apiClient: getIt(),
localCache: getIt(),
),
);
// Use cases
getIt.registerFactory(() => LoginUseCase(getIt()));
getIt.registerFactory(() => GetCurrentUserUseCase(getIt()));
// BLoC — registered as factory (new instance per screen)
getIt.registerFactory(
() => AuthBloc(
loginUseCase: getIt(),
getCurrentUser: getIt(),
),
);
}
// Placeholder types to allow compilation
class UnauthorizedException implements Exception {}
class SocketException implements Exception {}
abstract class AuthApiClient { Future<Map<String,dynamic>> login(String e, String p); Future<void> logout(); }
abstract class AuthLocalCache { Future<void> saveUser(UserModel u); Future<UserModel?> getUser(); Future<void> clearUser(); }
class AuthApiClientImpl implements AuthApiClient { @override Future<Map<String,dynamic>> login(String e, String p) async => {}; @override Future<void> logout() async {} }
class HiveAuthLocalCache implements AuthLocalCache { @override Future<void> saveUser(UserModel u) async {} @override Future<UserModel?> getUser() async => null; @override Future<void> clearUser() async {} }
class GetCurrentUserUseCase { final AuthRepository r; const GetCurrentUserUseCase(this.r); Future<Either<Failure, User>> call() => r.getCurrentUser(); }
sealed class AuthEvent extends Equatable { const AuthEvent(); @override List<Object?> get props => []; }
class LoginRequested extends AuthEvent { final String email; final String password; const LoginRequested({required this.email, required this.password}); @override List<Object> get props => [email, password]; }
class CheckAuthStatus extends AuthEvent { const CheckAuthStatus(); }
sealed class AuthState extends Equatable { const AuthState(); @override List<Object?> get props => []; }
class AuthInitial extends AuthState { const AuthInitial(); }
class AuthLoading extends AuthState { const AuthLoading(); }
class AuthAuthenticated extends AuthState { final User user; const AuthAuthenticated({required this.user}); @override List<Object> get props => [user]; }
class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
class AuthError extends AuthState { final String message; const AuthError({required this.message}); @override List<Object> get props => [message]; }Line-by-line walkthrough
- 1. User entity: pure Dart, no imports beyond core Dart — zero Flutter dependencies
- 2. Sealed Failure classes: typed domain errors, not generic exceptions
- 3. AuthRepository interface lives in DOMAIN — abstract contract, no implementation
- 4. LoginUseCase: validates inputs (domain logic), calls repository interface
- 5. LoginParams: typed parameter object — extensible, avoids primitive obsession
- 6. Either: explicit success/failure return — no exception propagation across layers
- 7. UserModel extends User: adds fromJson/toJson — data layer concern only
- 8. ApiAuthRepository implements AuthRepository — satisfies the domain contract
- 9. Catches specific exceptions and maps them to domain Failure types
- 10. AuthBloc depends on LoginUseCase — never on ApiAuthRepository or ApiClient
- 11. getIt.registerLazySingleton(() => ApiAuthRepository(...)): registers interface → implementation
- 12. configureDependencies() is the only place in the app that knows about concrete classes
Spot the bug
// In the domain layer:
import 'package:dio/dio.dart';
class GetUserUseCase {
final Dio _dio;
GetUserUseCase(this._dio);
Future<User> call(String userId) async {
final response = await _dio.get('/users/$userId');
return User.fromJson(response.data);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Clean Architecture with Flutter (Reso Coder)
- Flutter Architecture Samples (GitHub)
- GetIt Package (pub.dev)
- dartz (Either type) (pub.dev)