Clean Architecture với Bloc trong Flutter: Hướng dẫn chi tiết tổ chức dự án (2026)

Flutter, Dart, Clean Architecture, Bloc, State Management, Mobile Development, Flutter Tutorial, App Architecture, Repository Pattern, Dependency Injection, Use Case, Flutter Bloc, Domain Driven Design, Flutter Clean Architecture, Flutter Folder Structure

QuangHN-Dev

Clean Architecture là gì và tại sao nên dùng trong Flutter?

Định nghĩa Clean Architecture (Robert C. Martin)

Clean Architecture là một kiến trúc phần mềm được Robert C. Martin (Uncle Bob) giới thiệu, tập trung vào việc tách biệt các mối quan tâm (separation of concerns) qua các lớp (layer). Nguyên tắc cốt lõi là quy tắc phụ thuộc (Dependency Rule): các lớp bên trong không được biết gì về lớp bên ngoài, chiều phụ thuộc chỉ hướng từ ngoài vào trong.

Trong Flutter, Clean Architecture thường được chia thành 3 lớp chính:

  • Presentation Layer (tầng hiển thị): UI, widgets, state management (Bloc/Cubit).
  • Domain Layer (tầng nghiệp vụ): business logic thuần túy, entities, use cases.
  • Data Layer (tầng dữ liệu): repository implementations, API calls, local storage.

Lợi ích: Testability, Maintainability, Scalability

Lợi ích Mô tả
Testability Domain layer là pure Dart, không phụ thuộc Flutter, nên unit test use cases dễ dàng, không cần mock Flutter components.
Maintainability Thay đổi một phần (ví dụ đổi Dio sang http) chỉ ảnh hưởng Data layer, không làm ảnh hưởng UI hay business logic.
Scalability Dự án lớn dần với nhiều features, mỗi feature có cấu trúc riêng, dễ thêm mới, tránh “spaghetti code”.

Khi nào nên – và không nên – dùng Clean Architecture?

Nên dùng Không nên dùng
Dự án trung bình đến lớn (≥5 màn hình) Dự án nhỏ (1-2 màn hình, ít logic)
Nhiều business logic phức tạp Ứng dụng đơn giản, gần như chỉ hiển thị dữ liệu
Cần bảo trì lâu dài và mở rộng thường xuyên Prototype hoặc MVP cần ra mắt nhanh
Làm việc nhóm, cần phân tách trách nhiệm Làm cá nhân, dự án ngắn hạn

💡 Lời khuyên: Clean Architecture không phải là “silver bullet”. Hãy bắt đầu với một feature nhỏ (như Authentication) để làm quen, sau đó áp dụng cho toàn bộ dự án nếu thấy phù hợp.


Kiến trúc tổng quan: 3 layer và quy tắc phụ thuộc

Sơ đồ luồng dữ liệu

[UI] → [Bloc] → [Use Case] → [Repository Interface] ← [Repository Impl]
                                         ↑
                                   [API / Local DB]
  • Presentation phụ thuộc vào Domain (gọi Use Cases, dùng Entities).
  • Data phụ thuộc vào Domain (implement Repository interfaces).
  • Domain không phụ thuộc vào bất kỳ layer nào khác.

Domain layer là “trái tim” của ứng dụng, hoàn toàn độc lập và được bảo vệ khỏi những thay đổi bên ngoài.


Các package cần thiết cho Clean Architecture + Bloc

Để bắt đầu, bạn cần thêm các package sau vào pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  # State management
  flutter_bloc: ^9.1.1
  equatable: ^2.0.7
  # Dependency injection
  get_it: ^9.2.1
  # Network
  dio: ^5.9.0
  # Immutable classes & serialization
  freezed_annotation: ^3.2.0
  json_annotation: ^4.9.0
  # Functional programming (Either)
  dartz: ^1.0.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^3.2.5
  json_serializable: ^6.9.0
  # Testing
  bloc_test: ^11.0.0
  mocktail: ^1.0.0

Sau khi thêm, chạy flutter pub getdart run build_runner build để generate code cho Freezed/JsonSerializable.


Tổ chức folder structure

Cấu trúc tổng thể: feature-first

lib/
├── core/                         # Shared across app
│   ├── errors/
│   │   ├── failures.dart
│   │   └── exceptions.dart
│   ├── network/
│   │   ├── api_client.dart
│   │   └── network_info.dart
│   ├── constants/
│   └── di/
│       └── injection.dart
├── features/
│   ├── auth/                     # Feature: Authentication
│   │   ├── data/
│   │   ├── domain/
│   │   └── presentation/
│   ├── home/                     # Feature: Home
│   └── profile/                  # Feature: Profile
└── main.dart

Cấu trúc bên trong mỗi feature

features/auth/
├── data/
│   ├── datasources/
│   │   ├── auth_remote_datasource.dart
│   │   └── auth_local_datasource.dart      # Ví dụ thêm local
│   ├── models/
│   │   ├── user_dto.dart
│   │   ├── user_dto.freezed.dart
│   │   └── user_dto.g.dart
│   ├── repositories/
│   │   └── auth_repository_impl.dart
│   └── mappers/
│       └── user_mapper.dart
├── domain/
│   ├── entities/
│   │   └── user.dart
│   ├── repositories/
│   │   └── auth_repository.dart          (interface)
│   └── usecases/
│       ├── login_usecase.dart
│       └── logout_usecase.dart
└── presentation/
    ├── bloc/
    │   ├── auth_bloc.dart
    │   ├── auth_event.dart
    │   └── auth_state.dart
    ├── pages/
    │   └── login_page.dart
    └── widgets/
        └── login_form.dart

Xây dựng Domain Layer

Entity – đối tượng nghiệp vụ cốt lõi

// features/auth/domain/entities/user.dart

class User {
  final String id;
  final String email;
  final String displayName;
  final String? avatarUrl;

  const User({
    required this.id,
    required this.email,
    required this.displayName,
    this.avatarUrl,
  });

  User copyWith({
    String? id,
    String? email,
    String? displayName,
    String? avatarUrl,
  }) {
    return User(
      id: id ?? this.id,
      email: email ?? this.email,
      displayName: displayName ?? this.displayName,
      avatarUrl: avatarUrl ?? this.avatarUrl,
    );
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is User && runtimeType == other.runtimeType && id == other.id;

  @override
  int get hashCode => id.hashCode;
}

Giải thích: Entity là class đơn giản, không có dependency Flutter. Bạn có thể dùng Freezed để generate immutable class và copyWith tự động, nhưng với entity, viết tay cũng đủ.

Repository interface – hợp đồng cho Data layer

// features/auth/domain/repositories/auth_repository.dart

import 'package:dartz/dartz.dart';
import '../../../core/errors/failures.dart';
import '../entities/user.dart';

abstract class AuthRepository {
  Future<Either<Failure, User>> login({
    required String email,
    required String password,
  });

  Future<Either<Failure, void>> logout();

  Future<Either<Failure, User?>> getCurrentUser();
}

Use Case – chứa business logic cụ thể

// features/auth/domain/usecases/login_usecase.dart

import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';

class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future<Either<Failure, User>> call({
    required String email,
    required String password,
  }) async {
    // Business logic validation
    if (email.isEmpty || password.isEmpty) {
      return Left(ValidationFailure('Email và password không được để trống'));
    }

    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!emailRegex.hasMatch(email)) {
      return Left(ValidationFailure('Email không hợp lệ'));
    }

    if (password.length < 6) {
      return Left(ValidationFailure('Mật khẩu phải có ít nhất 6 ký tự'));
    }

    return await repository.login(email: email, password: password);
  }
}

Giải thích: Use Case nhận repository interface (không phải implementation) và chứa validation logic thuần túy. Dùng Either<Failure, User> để biểu diễn thành công (Right) hoặc thất bại (Left).

Các lớp Failure – định nghĩa lỗi

// core/errors/failures.dart

import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);

  @override
  List<Object?> get props => [message];
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

class ValidationFailure extends Failure {
  const ValidationFailure(super.message);
}

class UnknownFailure extends Failure {
  const UnknownFailure(super.message);
}

Xây dựng Data Layer

Repository implementation implement interface từ Domain

// features/auth/data/repositories/auth_repository_impl.dart

import 'package:dartz/dartz.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../mappers/user_mapper.dart';
import '../../../core/errors/failures.dart';
import '../../../core/errors/exceptions.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final UserMapper userMapper;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.userMapper,
  });

  @override
  Future<Either<Failure, User>> login({
    required String email,
    required String password,
  }) async {
    try {
      final userDto = await remoteDataSource.login(email: email, password: password);
      final user = userMapper.mapToEntity(userDto);
      return Right(user);
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } on NetworkException catch (e) {
      return Left(NetworkFailure(e.message));
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, void>> logout() async {
    try {
      await remoteDataSource.logout();
      return const Right(null);
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, User?>> getCurrentUser() async {
    try {
      final userDto = await remoteDataSource.getCurrentUser();
      final user = userDto != null ? userMapper.mapToEntity(userDto) : null;
      return Right(user);
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }
}

Data Source: Remote API (Dio)

Trước tiên, định nghĩa ApiClient:

// core/network/api_client.dart

import 'package:dio/dio.dart';

class ApiClient {
  final Dio dio;

  ApiClient(this.dio) {
    dio.options.baseUrl = 'https://api.example.com';
    dio.options.connectTimeout = const Duration(seconds: 30);
    dio.options.receiveTimeout = const Duration(seconds: 30);
    dio.interceptors.add(
      LogInterceptor(request: true, responseBody: true, error: true),
    );
  }

  Future
<Response> post(String path, {dynamic data}) async {
    return await dio.post(path, data: data);
  }

  Future
<Response> get(String path) async {
    return await dio.get(path);
  }

  // ... put, delete, etc.
}

Data source:

// features/auth/data/datasources/auth_remote_datasource.dart

import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/user_dto.dart';
import '../../../core/errors/exceptions.dart';
import '../../../core/network/api_client.dart';

class AuthRemoteDataSource {
  final ApiClient apiClient;

  AuthRemoteDataSource(this.apiClient);

  Future
<UserDto> login({
    required String email,
    required String password,
  }) async {
    try {
      final response = await apiClient.post(
        '/auth/login',
        data: {'email': email, 'password': password},
      );

      if (response.statusCode == 200) {
        return UserDto.fromJson(response.data as Map<String, dynamic>);
      } else {
        throw ServerException('Login failed: ${response.statusCode}');
      }
    } on DioException catch (e) {
      throw NetworkException(e.message ?? 'Network error');
    }
  }

  Future
<void> logout() async {
    await apiClient.post('/auth/logout');
  }

  Future<UserDto?> getCurrentUser() async {
    try {
      final response = await apiClient.get('/auth/me');
      if (response.statusCode == 200 && response.data != null) {
        return UserDto.fromJson(response.data as Map<String, dynamic>);
      }
      return null;
    } catch (e) {
      return null;
    }
  }
}

DTO và Mapper (dùng Freezed)

// features/auth/data/models/user_dto.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_dto.freezed.dart';
part 'user_dto.g.dart';

@freezed
class UserDto with _$UserDto {
  const factory UserDto({
    required String id,
    required String email,
    @JsonKey(name: 'display_name') required String displayName,
    @JsonKey(name: 'avatar_url') String? avatarUrl,
  }) = _UserDto;

  factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
}

Mapper:

// features/auth/data/mappers/user_mapper.dart

import '../../domain/entities/user.dart';
import '../models/user_dto.dart';

class UserMapper {
  User mapToEntity(UserDto dto) {
    return User(
      id: dto.id,
      email: dto.email,
      displayName: dto.displayName,
      avatarUrl: dto.avatarUrl,
    );
  }

  UserDto mapToDto(User entity) {
    return UserDto(
      id: entity.id,
      email: entity.email,
      displayName: entity.displayName,
      avatarUrl: entity.avatarUrl,
    );
  }
}

Exceptions (cho Data layer)

// core/errors/exceptions.dart

class ServerException implements Exception {
  final String message;
  ServerException(this.message);
}

class NetworkException implements Exception {
  final String message;
  NetworkException(this.message);
}

Xây dựng Presentation Layer với Bloc

State – dùng Equatable

// features/auth/presentation/bloc/auth_state.dart

part of 'auth_bloc.dart';

abstract class AuthState extends Equatable {
  const AuthState();

  @override
  List<Object?> get props => [];
}

class AuthInitial extends AuthState {}

class AuthLoading extends AuthState {}

class AuthAuthenticated extends AuthState {
  final User user;
  const AuthAuthenticated(this.user);

  @override
  List<Object?> get props => [user];
}

class AuthUnauthenticated extends AuthState {}

class AuthError extends AuthState {
  final String message;
  const AuthError(this.message);

  @override
  List<Object?> get props => [message];
}

Event

// features/auth/presentation/bloc/auth_event.dart

part of 'auth_bloc.dart';

abstract 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 LogoutRequested extends AuthEvent {}

Bloc – gọi Use Case và emit state

// features/auth/presentation/bloc/auth_bloc.dart

import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dartz/dartz.dart';
import '../../../domain/usecases/login_usecase.dart';
import '../../../domain/usecases/logout_usecase.dart';
import '../../../domain/entities/user.dart';
import '../../../../core/errors/failures.dart';

part 'auth_event.dart';
part 'auth_state.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;
  final LogoutUseCase logoutUseCase;

  AuthBloc({
    required this.loginUseCase,
    required this.logoutUseCase,
  }) : super(AuthInitial()) {
    on
<LoginRequested>(_onLoginRequested);
    on
<LogoutRequested>(_onLogoutRequested);
  }

  Future
<void> _onLoginRequested(
    LoginRequested event,
    Emitter
<AuthState> emit,
  ) async {
    emit(AuthLoading());

    final result = await loginUseCase(
      email: event.email,
      password: event.password,
    );

    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }

  Future
<void> _onLogoutRequested(
    LogoutRequested event,
    Emitter
<AuthState> emit,
  ) async {
    await logoutUseCase();
    emit(AuthUnauthenticated());
  }
}

Giải thích: Bloc không chứa business logic phức tạp – chỉ gọi Use Case, xử lý kết quả Either và emit state.

UI – LoginPage với BlocConsumer

// features/auth/presentation/pages/login_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/auth_bloc.dart';
import '../widgets/login_form.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => getIt
<AuthBloc>(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Đăng nhập')),
        body: const LoginView(),
      ),
    );
  }
}

class LoginView extends StatefulWidget {
  const LoginView({super.key});

  @override
  State
<LoginView> createState() => _LoginViewState();
}

class _LoginViewState extends State
<LoginView> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state is AuthAuthenticated) {
          Navigator.pushReplacementNamed(context, '/home');
        } else if (state is AuthError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
        }
      },
      builder: (context, state) {
        if (state is AuthLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        if (state is AuthAuthenticated) {
          return Center(child: Text('Xin chào ${state.user.displayName}'));
        }

        return Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextField(
                controller: _emailController,
                decoration: const InputDecoration(labelText: 'Email'),
              ),
              const SizedBox(height: 12),
              TextField(
                controller: _passwordController,
                obscureText: true,
                decoration: const InputDecoration(labelText: 'Mật khẩu'),
              ),
              const SizedBox(height: 24),
              ElevatedButton(
                onPressed: () {
                  context.read
<AuthBloc>().add(
                        LoginRequested(
                          email: _emailController.text,
                          password: _passwordController.text,
                        ),
                      );
                },
                child: const Text('Đăng nhập'),
              ),
            ],
          ),
        );
      },
    );
  }
}

Lưu ý: Sử dụng TextEditingController và dispose chúng để tránh memory leak.


Dependency Injection với GetIt

// core/di/injection.dart

import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../network/api_client.dart';
import '../../features/auth/data/datasources/auth_remote_datasource.dart';
import '../../features/auth/data/repositories/auth_repository_impl.dart';
import '../../features/auth/data/mappers/user_mapper.dart';
import '../../features/auth/domain/repositories/auth_repository.dart';
import '../../features/auth/domain/usecases/login_usecase.dart';
import '../../features/auth/domain/usecases/logout_usecase.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';

final getIt = GetIt.instance;

void setupInjection() {
  // Core
  getIt.registerLazySingleton
<Dio>(() => Dio());
  getIt.registerLazySingleton
<ApiClient>(() => ApiClient(getIt()));

  // Mappers
  getIt.registerLazySingleton
<UserMapper>(() => UserMapper());

  // Data sources
  getIt.registerLazySingleton
<AuthRemoteDataSource>(
    () => AuthRemoteDataSource(getIt()),
  );

  // Repositories
  getIt.registerLazySingleton
<AuthRepository>(
    () => AuthRepositoryImpl(
      remoteDataSource: getIt(),
      userMapper: getIt(),
    ),
  );

  // Use Cases
  getIt.registerLazySingleton
<LoginUseCase>(
    () => LoginUseCase(getIt()),
  );
  getIt.registerLazySingleton
<LogoutUseCase>(
    () => LogoutUseCase(getIt()),
  );

  // Bloc - registerFactory để mỗi màn hình có instance riêng
  getIt.registerFactory
<AuthBloc>(
    () => AuthBloc(
      loginUseCase: getIt(),
      logoutUseCase: getIt(),
    ),
  );
}

Trong main.dart:

import 'package:flutter/material.dart';
import 'core/di/injection.dart';

void main() {
  setupInjection();
  runApp(const MyApp());
}

Viết test cho Clean Architecture

Unit test cho Use Case (Domain layer)

// test/domain/usecases/login_usecase_test.dart

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/core/errors/failures.dart';
import 'package:your_app/features/auth/domain/entities/user.dart';
import 'package:your_app/features/auth/domain/repositories/auth_repository.dart';
import 'package:your_app/features/auth/domain/usecases/login_usecase.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late LoginUseCase useCase;
  late MockAuthRepository mockRepository;

  setUp(() {
    mockRepository = MockAuthRepository();
    useCase = LoginUseCase(mockRepository);
  });

  const tUser = User(id: '1', email: 'test@test.com', displayName: 'Test');
  const tEmail = 'test@test.com';
  const tPassword = '123456';

  test('should return User when login success', () async {
    // Arrange
    when(() => mockRepository.login(email: tEmail, password: tPassword))
        .thenAnswer((_) async => Right(tUser));

    // Act
    final result = await useCase(email: tEmail, password: tPassword);

    // Assert
    expect(result, Right(tUser));
    verify(() => mockRepository.login(email: tEmail, password: tPassword)).called(1);
    verifyNoMoreInteractions(mockRepository);
  });

  test('should return ValidationFailure when email empty', () async {
    // Act
    final result = await useCase(email: '', password: tPassword);

    // Assert
    expect(
      result,
      Left(ValidationFailure('Email và password không được để trống')),
    );
    verifyNever(() => mockRepository.login(email: '', password: tPassword));
  });

  test('should return ValidationFailure when email invalid', () async {
    // Act
    final result = await useCase(email: 'invalid', password: tPassword);

    // Assert
    expect(
      result,
      Left(ValidationFailure('Email không hợp lệ')),
    );
  });

  test('should return ServerFailure when repository fails', () async {
    // Arrange
    when(() => mockRepository.login(email: tEmail, password: tPassword))
        .thenAnswer((_) async => Left(ServerFailure('Server error')));

    // Act
    final result = await useCase(email: tEmail, password: tPassword);

    // Assert
    expect(result, Left(ServerFailure('Server error')));
  });
}

Bloc test (Presentation layer)

// test/presentation/bloc/auth_bloc_test.dart

import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/core/errors/failures.dart';
import 'package:your_app/features/auth/domain/entities/user.dart';
import 'package:your_app/features/auth/domain/usecases/login_usecase.dart';
import 'package:your_app/features/auth/domain/usecases/logout_usecase.dart';
import 'package:your_app/features/auth/presentation/bloc/auth_bloc.dart';

class MockLoginUseCase extends Mock implements LoginUseCase {}
class MockLogoutUseCase extends Mock implements LogoutUseCase {}

void main() {
  late AuthBloc bloc;
  late MockLoginUseCase mockLogin;
  late MockLogoutUseCase mockLogout;

  const tUser = User(id: '1', email: 'test@test.com', displayName: 'Test');
  const tEmail = 'test@test.com';
  const tPassword = '123456';

  setUp(() {
    mockLogin = MockLoginUseCase();
    mockLogout = MockLogoutUseCase();
    bloc = AuthBloc(loginUseCase: mockLogin, logoutUseCase: mockLogout);
  });

  blocTest<AuthBloc, AuthState>(
    'emits [AuthLoading, AuthAuthenticated] when login success',
    build: () {
      when(() => mockLogin(email: tEmail, password: tPassword))
          .thenAnswer((_) async => Right(tUser));
      return bloc;
    },
    act: (bloc) => bloc.add(LoginRequested(email: tEmail, password: tPassword)),
    expect: () => [AuthLoading(), AuthAuthenticated(tUser)],
  );

  blocTest<AuthBloc, AuthState>(
    'emits [AuthLoading, AuthError] when login fails',
    build: () {
      when(() => mockLogin(email: tEmail, password: tPassword))
          .thenAnswer((_) async => Left(ValidationFailure('Invalid email')));
      return bloc;
    },
    act: (bloc) => bloc.add(LoginRequested(email: tEmail, password: tPassword)),
    expect: () => [AuthLoading(), AuthError('Invalid email')],
  );

  blocTest<AuthBloc, AuthState>(
    'emits AuthUnauthenticated when logout',
    build: () {
      when(() => mockLogout()).thenAnswer((_) async => const Right(null));
      return bloc;
    },
    act: (bloc) => bloc.add(LogoutRequested()),
    expect: () => [AuthUnauthenticated()],
  );
}

Lỗi thường gặp và cách khắc phục

Lỗi Nguyên nhân Cách khắc phục
Domain import Flutter Quên quy tắc pure Dart Kiểm tra tất cả import trong domain, chỉ giữ dart core và pure packages.
Đặt Bloc trong Domain layer Nhầm Bloc là business logic Bloc chỉ thuộc Presentation. Business logic nằm trong Use Cases.
Repository không implements interface Quên tạo interface trong Domain Luôn tạo abstract class trong Domain trước, implement ở Data.
Data layer trả về Widget Vi phạm dependency rule Data chỉ trả về dữ liệu (Entities hoặc DTOs), không biết gì về UI.
Quá nhiều Use Case cho feature đơn giản Over-engineering Với feature chỉ hiển thị dữ liệu, có thể bỏ Use Case, gọi repository trực tiếp từ Bloc.
Quên dispose TextEditingController Memory leak Luôn override dispose() trong StatefulWidget và gọi controller.dispose().
Không xử lý loading/error state Thiếu UX Luôn emit AuthLoadingAuthError để UI phản hồi.

Best Practices và mở rộng

Luôn giữ Domain layer pure Dart

  • Không import flutter/material.dart hay flutter/widgets.dart.
  • Chỉ dùng Dart core và các package không phụ thuộc UI.

Sử dụng dependency injection

  • Dùng GetIt hoặc injectable để quản lý dependencies.
  • registerLazySingleton cho các service dùng chung.
  • registerFactory cho Bloc (mỗi màn hình một instance).

Viết test cho cả 3 layer

  • Domain: unit test cho Use Cases và Entities.
  • Data: unit test cho Repository implementations (mock Data Sources).
  • Presentation: widget test và bloc test dùng bloc_test.

Công cụ hỗ trợ sinh code

Bạn có thể dùng các CLI tools như clean_feature_gen hoặc flutter_architecture_generator để tự động sinh cấu trúc.

dart pub global activate clean_feature_gen
clean_feature_gen create auth --with-bloc

So sánh Clean Architecture với MVC/MVVM

Tiêu chí Clean Architecture MVC/MVVM
Phạm vi Toàn bộ ứng dụng Một màn hình / một module
Mục tiêu Tách biệt business logic, UI, data, infrastructure Tách UI, logic điều khiển, model
Dependency rule Chặt chẽ, hướng vào trong Linh hoạt hơn, thường không có quy tắc cứng
Testability Rất cao (Domain pure Dart) Tùy thuộc vào implement, thường dùng mock
Độ phức tạp Cao, phù hợp dự án lớn Thấp hơn, phù hợp dự án vừa
Kết hợp Có thể dùng Bloc làm ViewModel trong Presentation Có thể dùng MVVM trong Presentation layer của Clean Arch

Kết luận: Bạn có thể kết hợp cả hai – dùng Clean Architecture làm kiến trúc tổng thể và MVVM (với Bloc) ở Presentation layer.


Kết luận

Clean Architecture + Bloc là sự kết hợp mạnh mẽ giúp bạn xây dựng ứng dụng Flutter có cấu trúc rõ ràng, dễ bảo trì và mở rộng. Qua bài viết, bạn đã hiểu:

  • 3 layer: Domain (trái tim), Data (thực thi), Presentation (UI).
  • Quy tắc phụ thuộc: Presentation → Domain ← Data.
  • Cách tổ chức folder feature-first.
  • Cách viết code cho Entity, Use Case, Repository, Bloc.
  • Cách tiêm dependency và viết test.

💡 Lời khuyên cuối: Đừng áp dụng Clean Architecture cứng nhắc. Hãy bắt đầu nhỏ, hiểu rõ cách các layer tương tác, sau đó mới mở rộng toàn bộ dự án. Clean Architecture là công cụ, không phải mục đích.


FAQ

Clean Architecture có khác gì với MVC/MVVM không?

Clean Architecture khác ở mức độ trừu tượng và phạm vi. MVC/MVVM là các pattern tổ chức UI-logic trong một màn hình. Clean Architecture là kiến trúc tổng thể cho toàn bộ ứng dụng, xác định quy tắc phụ thuộc giữa các layer. Bạn có thể dùng MVVM (qua Bloc) ở Presentation layer của Clean Architecture.

Có nhất thiết phải dùng Use Case không?

Không. Use Case phù hợp khi có business logic phức tạp, cần tái sử dụng hoặc test kỹ. Với feature đơn giản chỉ lấy và hiển thị dữ liệu, bạn có thể gọi repository trực tiếp từ Bloc để tránh over-engineering.

Bloc nên đặt ở layer nào?

Bloc chỉ thuộc Presentation layer. Nó không chứa business logic – chỉ gọi Use Cases và emhttps://trithucsang.com/wp-admin/admin.php?page=wpcf7it state. Business logic nằm trong Use Cases ở Domain layer.

Làm sao để test Clean Architecture?

  • Domain: unit test Use Cases, Entities (dễ nhất).
  • Data: unit test Repository implementations (mock Data Sources).
  • Presentation: widget test + bloc test dùng bloc_test.

Có công cụ nào hỗ trợ tạo structure không?

Có, như clean_feature_gen hoặc flutter_architecture_generator. Ngoài ra, nhiều IDE extensions hỗ trợ tạo feature folders.

Clean Architecture có phù hợp với mọi dự án Flutter không?

Không. Phù hợp nhất với dự án trung bình đến lớn, nhiều business logic và cần bảo trì lâu dài. Dự án nhỏ (1-2 màn hình) có thể bị over-engineering.


Tài liệu tham khảo


Lưu ý: Bài viết sử dụng Flutter 3.22+, Dart 3.4+, và các package version được chỉ định. Hãy kiểm tra phiên bản mới nhất trên pub.dev trước khi áp dụng.


Tác giả: Huỳnh Nhật Quang – Flutter Developer


Chia sẻ bài viết này
Hãy để lại bình luận