Lesson 20 of 77 advanced

Clean Architecture Fundamentals for Flutter

The Answer That Gets You Hired at Senior Level

Open interactive version (quiz + challenge)

Real-world analogy

Clean Architecture is like a well-run hospital. The doctors (domain layer) have no idea if they're using paper charts or an iPad — they just follow medical protocols (business rules). The nurses (use cases) coordinate the doctors' work. The receptionists (data layer) handle intake from outside — phone calls (APIs) or physical walk-ins (local database). The patients (UI) interact with the front desk, never directly with the doctors' medical equipment.

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

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. 1. User entity: pure Dart, no imports beyond core Dart — zero Flutter dependencies
  2. 2. Sealed Failure classes: typed domain errors, not generic exceptions
  3. 3. AuthRepository interface lives in DOMAIN — abstract contract, no implementation
  4. 4. LoginUseCase: validates inputs (domain logic), calls repository interface
  5. 5. LoginParams: typed parameter object — extensible, avoids primitive obsession
  6. 6. Either: explicit success/failure return — no exception propagation across layers
  7. 7. UserModel extends User: adds fromJson/toJson — data layer concern only
  8. 8. ApiAuthRepository implements AuthRepository — satisfies the domain contract
  9. 9. Catches specific exceptions and maps them to domain Failure types
  10. 10. AuthBloc depends on LoginUseCase — never on ApiAuthRepository or ApiClient
  11. 11. getIt.registerLazySingleton(() => ApiAuthRepository(...)): registers interface → implementation
  12. 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?
What architecture rule is violated? What is the impact?
Show answer
The domain layer imports 'package:dio/dio.dart' — a data layer concern. This violates the Dependency Rule: inner layers must not depend on outer layers. Impact: (1) the domain layer is coupled to Dio's API (can't swap HTTP client without changing domain), (2) unit testing requires a real or mocked Dio instance, (3) the domain can't be used as a standalone Dart package. Fix: define abstract AuthRepository in domain, implement with Dio in data layer, inject the interface into GetUserUseCase.

Explain like I'm 5

Clean Architecture is like a burger restaurant. The menu (domain layer) says 'we sell cheeseburgers' — it doesn't say if we grill on gas or charcoal. The kitchen (data layer) decides HOW to make the burger — grill, fryer, whatever. The waiter (presentation/BLoC) takes the customer's order and tells the kitchen. If you switch from gas to charcoal grills (swap SQLite for Hive), the menu doesn't change. The customer doesn't care. Only the kitchen changes.

Fun fact

Clean Architecture was created by Robert C. Martin in 2012, adapting earlier architectures (Hexagonal/Ports & Adapters by Alistair Cockburn, Onion Architecture by Jeffrey Palermo). In Flutter, it was popularized by Reso Coder's tutorial series. The key insight that makes it work in Flutter is: 'If you can delete your lib/features/x/data/ folder and replace it with a completely different implementation in an afternoon, your architecture is clean.'

Hands-on challenge

Implement a GetTransactionHistoryUseCase that fetches transactions, filters out pending ones (business rule), and sorts by date descending. The domain entity Transaction has id, amount, status, date. Write the use case, the repository interface, and a unit test that mocks the repository and verifies the filtering logic.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery