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 get và dart 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 AuthLoading và AuthError để UI phản hồi. |
Best Practices và mở rộng
Luôn giữ Domain layer pure Dart
- Không import
flutter/material.darthayflutter/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.
registerLazySingletoncho các service dùng chung.registerFactorycho 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
- Bloc Library – Official Documentation
- flutter_bloc package
- get_it package
- freezed package
- dio package
- Flutter State Management – Official Guide
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