Хорошая архитектура — фундамент успешного проекта. Правильно спроектированная архитектура делает код поддерживаемым, тестируемым и масштабируемым. В этом руководстве мы разберём Clean Architecture для Flutter приложений.
Что такое Clean Architecture?
Clean Architecture — это подход к проектированию программных систем, предложенный Robert C. Martin (Uncle Bob). Основная идея — разделение системы на слои с зависимостями, направленными внутрь.
Ключевые принципы
- Independence of Frameworks — бизнес-логика не зависит от фреймворка
- Testability — бизнес-правила можно тестировать без UI, базы данных, веб-сервера
- Independence of UI — UI можно легко менять без изменения бизнес-логики
- Independence of Database — бизнес-правила не привязаны к конкретной базе данных
- Independence of External Services — бизнес-правила не знают о внешнем мире
Правило зависимостей
Внешние слои ← Внутренние слои
Источник зависимости всегда указывает внутрь.
Внутренние слои ничего не знают о внешних.
Слои архитектуры
Clean Architecture разделяет приложение на concentric слои:
┌─────────────────────────────────┐
│ Presentation (UI) │
│ - Widgets │
│ - Controllers/BLoC │
│ - State Management │
├─────────────────────────────────┤
│ Domain (Business Logic) │
│ - Entities │
│ - Use Cases │
│ - Repository Interfaces │
├─────────────────────────────────┤
│ Data (Data Sources) │
│ - Repository Implementation │
│ - DTOs │
│ - Data Sources │
└─────────────────────────────────┘
Domain Layer (внутренний слой)
Самый внутренний слой, содержащий бизнес-логику.
Компоненты: — Entities — core business объекты — Use Cases — application-specific бизнес-правила — Repository Interfaces — абстракции для данных
Правило: Не имеет зависимостей от других слоев. Не знает о Flutter, UI, базах данных.
Data Layer (средний слой)
Реализует доступ к данным.
Компоненты: — Repository Implementation — реализация интерфейсов из Domain — DTOs — Data Transfer Objects для парсинга JSON/API — Data Sources — API, Database, Cache
Правило: Знает о Domain, но Presentation не знает о нём.
Presentation Layer (внешний слой)
UI и state management.
Компоненты: — Widgets — Flutter UI компоненты — Controllers — BLoC, Cubit, Provider — State — UI состояние
Правило: Знает о Domain, использует Use Cases.
Структура проекта
Рекомендуемая структура проекта для Clean Architecture:
lib/
├── core/
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── network/
│ │ └── network_info.dart
│ ├── usecases/
│ │ └── usecase.dart
│ └── constants.dart
├── features/
│ └── auth/
│ ├── data/
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ ├── repositories/
│ │ │ └── auth_repository_impl.dart
│ │ └── datasources/
│ │ ├── auth_remote_datasource.dart
│ │ └── auth_local_datasource.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── user.dart
│ │ ├── repositories/
│ │ │ └── auth_repository.dart
│ │ └── usecases/
│ │ ├── login_usecase.dart
│ │ ├── register_usecase.dart
│ │ └── logout_usecase.dart
│ └── presentation/
│ ├── bloc/
│ │ ├── auth_bloc.dart
│ │ ├── auth_event.dart
│ │ └── auth_state.dart
│ ├── pages/
│ │ ├── login_page.dart
│ │ └── register_page.dart
│ └── widgets/
│ └── login_form.dart
├── config/
│ ├── routes.dart
│ └── theme.dart
└── main.dart
Domain Layer
Entities (Сущности)
Entities — это core business объекты приложения.
// features/auth/domain/entities/user.dart
class User {
final String id;
final String name;
final String email;
final DateTime createdAt;
User({
required this.id,
required this.name,
required this.email,
required this.createdAt,
});
// Business logic
bool get isActive => email.isNotEmpty;
User copyWith({
String? id,
String? name,
String? email,
DateTime? createdAt,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
createdAt: createdAt ?? this.createdAt,
);
}
}
Repository Interfaces
Интерфейсы репозиториев определяют контракт для получения данных.
// features/auth/domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/user.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(String email, String password);
Future<Either<Failure, User>> getCurrentUser();
Future<Either<Failure, void>> logout();
Stream<Either<Failure, User>> get authStateChanges;
}
Use Cases
Use Cases (Interactors) содержат application-specific бизнес-правила.
// core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams extends Equatable {
@override
List<Object?> get props => [];
}
// features/auth/domain/usecases/login_usecase.dart
class LoginUseCase implements UseCase<User, LoginParams> {
final AuthRepository repository;
LoginUseCase(this.repository);
@override
Future<Either<Failure, User>> call(LoginParams params) async {
// Валидация
if (!params.email.contains('@')) {
return Left(InvalidEmailFailure());
}
if (params.password.length < 6) {
return Left(InvalidPasswordFailure());
}
// Выполнение
return await repository.login(params.email, params.password);
}
}
class LoginParams extends Equatable {
final String email;
final String password;
const LoginParams({
required this.email,
required this.password,
});
@override
List<Object?> get props => [email, password];
}
Failures
Определите common failure типы:
// core/error/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(String message) : super(message);
}
class NetworkFailure extends Failure {
const NetworkFailure(String message) : super(message);
}
class CacheFailure extends Failure {
const CacheFailure(String message) : super(message);
}
class InvalidEmailFailure extends Failure {
const InvalidEmailFailure([String message = 'Invalid email format'])
: super(message);
}
class InvalidPasswordFailure extends Failure {
const InvalidPasswordFailure([String message = 'Password too short'])
: super(message);
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure([String message = 'Unauthorized'])
: super(message);
}
class NotFoundFailure extends Failure {
const NotFoundFailure([String message = 'Resource not found'])
: super(message);
}
Data Layer
DTOs (Data Transfer Objects)
DTOs используются для парсинга внешних данных (JSON, API).
// features/auth/data/models/user_model.dart
import '../../domain/entities/user.dart';
class UserModel extends User {
const UserModel({
required String id,
required String name,
required String email,
required DateTime createdAt,
}) : super(
id: id,
name: name,
email: email,
createdAt: createdAt,
);
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'created_at': createdAt.toIso8601String(),
};
}
// Конвертация в Entity
User toEntity() {
return User(
id: id,
name: name,
email: email,
createdAt: createdAt,
);
}
}
// Entity extension для создания UserModel
extension UserExtension on User {
UserModel toModel() {
return UserModel(
id: id,
name: name,
email: email,
createdAt: createdAt,
);
}
}
Data Sources
Data Sources обеспечивают доступ к источникам данных.
// features/auth/data/datasources/auth_remote_datasource.dart
import '../../../../core/error/exceptions.dart';
import '../models/user_model.dart';
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String email, String password);
Future<UserModel> getCurrentUser(String token);
Future<void> logout();
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl({required this.client});
@override
Future<UserModel> login(String email, String password) async {
final response = await client.post(
Uri.parse('https://api.example.com/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
return UserModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException();
}
}
@override
Future<UserModel> register(String email, String password) async {
final response = await client.post(
Uri.parse('https://api.example.com/auth/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 201) {
return UserModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
@override
Future<UserModel> getCurrentUser(String token) async {
final response = await client.get(
Uri.parse('https://api.example.com/auth/me'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
return UserModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
@override
Future<void> logout() async {
await client.post(Uri.parse('https://api.example.com/auth/logout'));
}
}
Exceptions
Определите custom exceptions:
// core/error/exceptions.dart
class ServerException implements Exception {
final String message;
ServerException([this.message = 'Server error']);
@override
String toString() => message;
}
class NetworkException implements Exception {
final String message;
NetworkException([this.message = 'Network error']);
@override
String toString() => message;
}
class UnauthorizedException implements Exception {
const UnauthorizedException();
}
class CacheException implements Exception {
final String message;
CacheException([this.message = 'Cache error']);
@override
String toString() => message;
}
Repository Implementation
// features/auth/data/repositories/auth_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../models/user_model.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
if (await networkInfo.isConnected) {
try {
final user = await remoteDataSource.login(email, password);
return Right(user.toEntity());
} on UnauthorizedException {
return Left(UnauthorizedFailure());
} on ServerException {
return Left(ServerFailure('Server error'));
}
} else {
return Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, User>> register(String email, String password) async {
if (await networkInfo.isConnected) {
try {
final user = await remoteDataSource.register(email, password);
return Right(user.toEntity());
} on ServerException {
return Left(ServerFailure('Registration failed'));
}
} else {
return Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, User>> getCurrentUser() async {
// Реализация с токеном из локального хранилища
return Left(NotImplementedFailure());
}
@override
Future<Either<Failure, void>> logout() async {
try {
await remoteDataSource.logout();
return const Right(null);
} on ServerException {
return Left(ServerFailure('Logout failed'));
}
}
@override
Stream<Either<Failure, User>> get authStateChanges {
// Реализация stream для auth state changes
return const Stream.empty();
}
}
Presentation Layer
BLoC (Business Logic Component)
// features/auth/presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/error/failures.dart';
import '../../../domain/entities/user.dart';
import '../../../domain/usecases/login_usecase.dart';
import '../../../domain/usecases/logout_usecase.dart';
// Events
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class LoginEvent extends AuthEvent {
final String email;
final String password;
const LoginEvent({required this.email, required this.password});
@override
List<Object?> get props => [email, password];
}
class LogoutEvent extends AuthEvent {}
class CheckAuthEvent extends AuthEvent {}
// States
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];
}
// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
final LogoutUseCase logoutUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthBloc({
required this.loginUseCase,
required this.logoutUseCase,
required this.getCurrentUserUseCase,
}) : super(AuthInitial()) {
on<LoginEvent>(_onLogin);
on<LogoutEvent>(_onLogout);
on<CheckAuthEvent>(_onCheckAuth);
}
Future<void> _onLogin(LoginEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
final result = await loginUseCase(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
final result = await logoutUseCase(const NoParams());
result.fold(
(failure) => emit(AuthError(failure.message)),
(_) => emit(AuthUnauthenticated()),
);
}
Future<void> _onCheckAuth(
CheckAuthEvent event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await getCurrentUserUseCase(const NoParams());
result.fold(
(failure) => emit(AuthUnauthenticated()),
(user) => emit(AuthAuthenticated(user)),
);
}
}
Pages (Screens)
// features/auth/presentation/pages/login_page.dart
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return const Center(child: CircularProgressIndicator());
}
return const LoginForm();
},
),
),
);
}
}
Widgets
// features/auth/presentation/widgets/login_form.dart
class LoginForm extends StatelessWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>();
final emailController = TextEditingController();
final passwordController = TextEditingController();
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Invalid email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 6) {
return 'Password too short';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
context.read<AuthBloc>().add(
LoginEvent(
email: emailController.text,
password: passwordController.text,
),
);
}
},
child: const Text('Login'),
),
],
),
),
);
}
}
Dependency Injection
Используйте GetIt для DI:
// core/locator/locator.dart
import 'package:get/get.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import '../features/auth/data/datasources/auth_remote_datasource.dart';
import '../features/auth/data/repositories/auth_repository_impl.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';
import 'network/network_info.dart';
final sl = GetIt.instance;
Future<void> init() async {
// External
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerLazySingleton(() => sharedPreferences);
sl.registerLazySingleton(() => http.Client());
sl.registerLazySingleton(() => NetworkInfoImpl());
// Data sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(client: sl()),
);
// Repositories
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
networkInfo: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => LoginUseCase(sl()));
sl.registerLazySingleton(() => LogoutUseCase(sl()));
// BLoC
sl.registerFactory(
() => AuthBloc(
loginUseCase: sl(),
logoutUseCase: sl(),
getCurrentUserUseCase: sl(),
),
);
}
Заключение
Clean Architecture помогает создавать поддерживаемые и тестируемые приложения. Используйте её для средних и больших проектов. Для маленьких проектов можно упростить архитектуру, но сохраните разделение слоёв.
Ключевые преимущества
- Тестируемость — каждый слой тестируется независимо
- Масштабируемость — легко добавлять новые features
- Поддерживаемость — изменения изолированы в слоях
- Переиспользуемость — Use Cases переиспользуются в разных частях приложения