Back to Home
E.
Mobile & ArchitectureMarch 2, 20268 min read

Clean Architecture in Flutter: Domain-Driven Design, BLoC & Production Patterns

A comprehensive, code-driven guide to architecting scalable Flutter applications with Clean Architecture: decoupling Domain, Data, and Presentation with BLoC, dependency inversion, and offline-first repositories.

The Golden Dependency Rule: Source code dependencies must only point inwards towards the Domain layer. The Domain layer is pure Dart — it must never import Flutter widgets, HTTP libraries, or database drivers.

1. The Domain Layer: Pure Dart Business Core

The Domain layer represents the core business rules of your application. It contains Entities (pure immutable data classes), Use Cases (atomic single-responsibility actions), and Repository Contracts (abstract interfaces). Because it has zero external framework dependencies, it remains completely resilient to UI redesigns, database migrations, or third-party package deprecations.

lib/features/auth/domain/entities/user_entity.dart
Dart
import 'package:equatable/equatable.dart';

/// Pure domain entity: zero Flutter or third-party framework dependencies.
class UserEntity extends Equatable {
  final String id;
  final String email;
  final String displayName;
  final bool isVerified;
  final DateTime createdAt;

  const UserEntity({
    required this.id,
    required this.email,
    required this.displayName,
    this.isVerified = false,
    required this.createdAt,
  });

  @override
  List<Object?> get props => [id, email, displayName, isVerified, createdAt];
}
lib/features/auth/domain/usecases/get_user_profile_usecase.dart
Dart
import '../entities/user_entity.dart';
import '../repositories/user_repository.dart';

/// Encapsulates a single atomic business action.
class GetUserProfileUseCase {
  final UserRepository _repository;

  const GetUserProfileUseCase(this._repository);

  Future<Result<UserEntity, Failure>> execute(String userId) async {
    if (userId.trim().isEmpty) {
      return Error(ValidationFailure('User ID cannot be empty.'));
    }
    return await _repository.getUserProfile(userId);
  }
}
Official BLoC Architecture Guide

In-depth breakdown of Data, Repository, and BLoC layer decoupling by Felix Angelov and the Flutter team.

https://bloclibrary.dev/architecture/

2. The Data Layer: Models, DataSources & Offline-First Strategy

The Data layer implements the repository contracts defined in the Domain layer. It coordinates Remote Data Sources (Dio, Supabase, Firebase) and Local Data Sources (Drift SQLite, Isar, SharedPreferences). Data Models extend Domain Entities to add serialization (fromJson / toJson) without polluting pure domain contracts.

lib/features/auth/data/models/user_model.dart
Dart
import '../../domain/entities/user_entity.dart';

/// Data model extending domain entity with JSON serialization logic.
class UserModel extends UserEntity {
  const UserModel({
    required super.id,
    required super.email,
    required super.displayName,
    super.isVerified,
    required super.createdAt,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      email: json['email'] as String,
      displayName: json['display_name'] as String,
      isVerified: json['is_verified'] as bool? ?? false,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'email': email,
    'display_name': displayName,
    'is_verified': isVerified,
    'created_at': createdAt.toIso8601String(),
  };
}
lib/features/auth/data/repositories/user_repository_impl.dart
Dart
import '../../domain/entities/user_entity.dart';
import '../../domain/repositories/user_repository.dart';
import '../datasources/user_remote_datasource.dart';
import '../datasources/user_local_datasource.dart';

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;
  final UserLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  UserRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future<Result<UserEntity, Failure>> getUserProfile(String userId) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteUser = await remoteDataSource.fetchUser(userId);
        await localDataSource.cacheUser(remoteUser);
        return Success(remoteUser);
      } on ServerException catch (e) {
        return Error(ServerFailure(e.message));
      }
    } else {
      // Resilient offline-first fallback
      final cached = await localDataSource.getLastCachedUser(userId);
      if (cached != null) return Success(cached);
      return Error(NetworkFailure('Aucune connexion et aucune donnée en cache.'));
    }
  }
}
Flutter Architectural Overview (Official Docs)

Google's official documentation on Flutter engine architecture, reactive widget trees, and data-flow patterns.

https://docs.flutter.dev/resources/architectural-overview

3. The Presentation Layer: State Management & Reactive UI

The Presentation layer contains widgets, screens, and state managers (BLoC, Cubit, or Riverpod). The UI triggers events on the BLoC, which calls the UseCase and yields immutable states (Initial, Loading, Loaded, Error). Widgets consume state with modern Dart 3 pattern matching, completely oblivious to whether the data came from Supabase or local SQLite.

lib/features/auth/presentation/bloc/user_profile_bloc.dart
Dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_user_profile_usecase.dart';
import '../../domain/entities/user_entity.dart';

// Events
sealed class UserProfileEvent {}
class LoadUserProfile extends UserProfileEvent {
  final String userId;
  LoadUserProfile(this.userId);
}

// States
sealed class UserProfileState {}
class UserProfileInitial extends UserProfileState {}
class UserProfileLoading extends UserProfileState {}
class UserProfileLoaded extends UserProfileState {
  final UserEntity user;
  UserProfileLoaded(this.user);
}
class UserProfileError extends UserProfileState {
  final String message;
  UserProfileError(this.message);
}

// BLoC
class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
  final GetUserProfileUseCase getUserProfile;

  UserProfileBloc({required this.getUserProfile}) : super(UserProfileInitial()) {
    on<LoadUserProfile>((event, emit) async {
      emit(UserProfileLoading());
      final result = await getUserProfile.execute(event.userId);
      switch (result) {
        case Success(value: final user):
          emit(UserProfileLoaded(user));
        case Error(failure: final failure):
          emit(UserProfileError(failure.message));
      }
    });
  }
}
lib/features/auth/presentation/pages/user_profile_page.dart
Dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/user_profile_bloc.dart';

class UserProfilePage extends StatelessWidget {
  final String userId;
  const UserProfilePage({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profil')),
      body: BlocBuilder<UserProfileBloc, UserProfileState>(
        builder: (context, state) => switch (state) {
          UserProfileInitial() => const Center(child: Text('En attente...')),
          UserProfileLoading() => const Center(child: CircularProgressIndicator()),
          UserProfileLoaded(:final user) => ListView(
            padding: const EdgeInsets.all(24),
            children: [
              CircleAvatar(radius: 40, child: Text(user.displayName[0])),
              const SizedBox(height: 16),
              Text(user.displayName, style: Theme.of(context).textTheme.headlineSmall),
              Text(user.email, style: Theme.of(context).textTheme.bodyMedium),
            ],
          ),
          UserProfileError(:final message) => Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(message, style: const TextStyle(color: Colors.red)),
                const SizedBox(height: 12),
                ElevatedButton(
                  onPressed: () => context.read<UserProfileBloc>().add(LoadUserProfile(userId)),
                  child: const Text('Réessayer'),
                ),
              ],
            ),
          ),
        },
      ),
    );
  }
}

4. Dependency Injection & Unit Testing Rigor

By strictly respecting Dependency Inversion (the 'D' in SOLID), classes depend on abstract contracts rather than concrete implementations. Mocking a repository takes 3 lines with Mocktail, enabling lightning-fast unit test execution for UseCases and BLoCs without ever spinning up an emulator.

test/features/auth/domain/usecases/get_user_profile_test.dart
Dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  late GetUserProfileUseCase useCase;
  late MockUserRepository mockRepository;

  setUp(() {
    mockRepository = MockUserRepository();
    useCase = GetUserProfileUseCase(mockRepository);
  });

  test('should return UserEntity when repository call succeeds', () async {
    final tUser = UserEntity(
      id: 'usr_1',
      email: 'contact@alex.dev',
      displayName: 'Alex Traore',
      createdAt: DateTime(2026, 1, 1),
    );

    when(() => mockRepository.getUserProfile('usr_1'))
        .thenAnswer((_) async => Success(tUser));

    final result = await useCase.execute('usr_1');

    expect(result, isA<Success<UserEntity, Failure>>());
    verify(() => mockRepository.getUserProfile('usr_1')).called(1);
    verifyNoMoreInteractions(mockRepository);
  });
}
Very Good Flutter Architecture Guidelines

Production architecture standards from Very Good Ventures, authors of Very Good CLI and leading contributors to the Flutter ecosystem.

https://verygood.ventures/blog/very-good-flutter-architecture
Next Article

2026 Web Architecture: Server Actions, Streaming SSR & Autonomous AI Agents

Read Article