Чистая архитектура в Flutter с BLoC: практическое руководство по разделению слоёв
Подробный разбор того, как выстроить чистую архитектуру в Flutter-приложении с помощью BLoC и паттерна «пользовательский случай — репозиторий — сущность»: доменный, инфраструктурный и презентационный слои на конкретном примере.
Чистая архитектура — не про бюрократию в коде. Это про границы: бизнес-логика ничего не знает о базе данных, UI ничего не знает о сети, а замена REST на GraphQL затрагивает ровно один файл.
Зачем вообще нужна чистая архитектура в Flutter
Практически каждый мобильный разработчик проходил через одну и ту же боль: в небольшом прототипе логика «живёт» прямо внутри виджетов, setState разрастается, а через полгода никто не хочет трогать код, потому что непонятно, что на что влияет. Чистая архитектура решает именно эту проблему — она не добавляет сложности ради сложности, а делает границы между частями приложения явными и непреложными.
Центральный принцип — правило зависимостей (Dependency Rule): зависимости исходного кода направлены строго внутрь. Презентационный слой знает о доменном, доменный не знает ни о чём снаружи. Бизнес-правила никогда не импортируют Flutter, Firebase, Dio или Supabase.
Такая инверсия даёт три практических преимущества:
- Тестируемость. Доменная логика проверяется юнит-тестами на чистом Dart — без эмулятора, без сети, без pumpWidget.
- Заменяемость инфраструктуры. Переход с REST на GraphQL или с Firestore на локальный SQLite затрагивает только один класс в слое данных. Домен и UI остаются нетронутыми.
- Параллельная работа. Стоит определить доменный контракт — один разработчик строит API-клиент, другой — экран с фейковыми данными. Никто никого не ждёт.
Три слоя: что кому можно знать
Архитектура строится вокруг трёх слоёв с жёстко определёнными зонами ответственности:
| Слой | Содержит | Зависит от |
|---|---|---|
| Доменный | Сущности, интерфейсы репозиториев, юзкейсы | Только чистый Dart |
| Данных | DTO, мапперы, реализации репозиториев, источники данных | Доменный слой |
| Презентационный | BLoC/Cubit, состояния, виджеты | Доменный слой |
Обратите внимание: и слой данных, и презентационный зависят от доменного, а доменный не зависит ни от одного из них. В этом вся суть — бизнес-логика изолирована от конкретных технологий.
Структура папок: по фичам, а не по типам
Наивный подход — разделить приложение на папки models/, services/, screens/ — выглядит аккуратно в первые дни, но превращается в квест уже к пятой фиче. Практичнее организовать код сначала по функциональности, потом по слоям:
lib/
features/
todos/
domain/
entities/todo.dart
repositories/todo_repository.dart
usecases/get_todos.dart
data/
models/todo_dto.dart
datasources/todo_remote_data_source.dart
repositories/todo_repository_impl.dart
presentation/
cubit/todos_cubit.dart
cubit/todos_state.dart
pages/todos_page.dart
core/
error/failures.dart
usecases/usecase.dart
Папка core/ хранит общие примитивы — типы ошибок, базовый контракт UseCase, расширения. Всё, что относится к конкретной фиче, живёт внутри её папки.
Доменный слой: сущности и юзкейсы
Доменный слой — это чистый Dart. Никаких package:flutter, никаких JSON-аннотаций. Начинается всё с сущности — структуры, с которой работает приложение, а не той, которую возвращает API:
class Todo extends Equatable {
const Todo({
required this.id,
required this.title,
required this.isCompleted,
});
final String id;
final String title;
final bool isCompleted;
@override
List<Object?> get props => [id, title, isCompleted];
}
Расширение Equatable нужно, чтобы два объекта с одинаковыми полями считались равными — это упрощает сравнение состояний в Cubit и упрощает тесты.
Далее — интерфейс репозитория. Это контракт, который домен «требует» от внешнего мира. Он объявляется в доменном слое, но реализуется в слое данных. Именно так работает инверсия зависимостей:
abstract interface class TodoRepository {
Future<Either<Failure, List<Todo>>> getTodos();
Future<Either<Failure, Todo>> toggle(String id);
}
Возвращаемый тип Either<Failure, T> из пакета dartz — это не выброс исключения, а значение, которое вызывающая сторона обязана обработать. Класс Failure определяется в core/ как sealed-тип с наследниками ServerFailure и NetworkFailure.
Юзкейс — это одно действие приложения с единственным публичным методом. Он читается почти как предложение: «получить задачи». В нём живёт оркестрация — вызов репозитория, возможно, объединение нескольких источников, применение бизнес-правил — без того, чтобы Cubit знал, откуда берутся данные:
class GetTodos implements UseCase<List<Todo>, NoParams> {
const GetTodos(this._repository);
final TodoRepository _repository;
@override
Future<Either<Failure, List<Todo>>> call(NoParams params) {
return _repository.getTodos();
}
}
Справедливый вопрос: не слишком ли это, если юзкейс просто проксирует вызов репозитория? На ранних этапах многие из них действительно прямолинейны. Но ценность — в шве: когда «получить задачи» потребуется фильтровать архивные, мержить с локальным кэшем или логировать аналитику, меняется один файл, и все вызывающие стороны получают обновлённую логику. Эта дисциплина — то, что позволяет архитектуре масштабироваться, а не гнить.
Слой данных: DTO, мапперы, репозитории
Слой данных — это место, где живёт весь внешний беспорядок. И где он остаётся. Ключевой приём — DTO (Data Transfer Object), который зеркально повторяет структуру JSON, плюс маппер, конвертирующий его в чистую доменную сущность. Сырые JSON-формы не должны просачиваться в домен:
class TodoDto {
const TodoDto({
required this.id,
required this.title,
required this.completed,
});
final String id;
final String title;
final bool completed;
factory TodoDto.fromJson(Map<String, dynamic> json) {
return TodoDto(
id: json['id'] as String,
title: json['title'] as String,
completed: json['is_done'] as bool? ?? false,
);
}
Todo toEntity() => Todo(
id: id,
title: title,
isCompleted: completed,
);
}
Здесь ценность маппера очевидна: API использует поле is_done, домен — isCompleted, а поле может быть null. Вся эта «грязь» карантинится в одном месте. Если бэкенд завтра переименует поле, меняется ровно один файл.
Источник данных владеет транспортом — Dio, http, Firestore — и общается на языке DTO:
class TodoRemoteDataSourceImpl implements TodoRemoteDataSource {
const TodoRemoteDataSourceImpl(this._dio);
final Dio _dio;
@override
Future<List<TodoDto>> fetchTodos() async {
final res = await _dio.get<List<dynamic>>('/todos');
final data = res.data ?? const <dynamic>[];
return data
.map((e) => TodoDto.fromJson(e as Map<String, dynamic>))
.toList();
}
}
Реализация репозитория склеивает всё вместе: вызывает источник данных, маппит DTO в сущности и конвертирует исключения в значения Failure. Это единственный класс, реализующий доменный интерфейс TodoRepository:
class TodoRepositoryImpl implements TodoRepository {
const TodoRepositoryImpl(this._remote);
final TodoRemoteDataSource _remote;
@override
Future<Either<Failure, List<Todo>>> getTodos() async {
try {
final dtos = await _remote.fetchTodos();
return Right(dtos.map((d) => d.toEntity()).toList());
} on DioException {
return const Left(NetworkFailure());
} catch (_) {
return const Left(ServerFailure());
}
}
}
Именно здесь паттерн репозитория делает свою реальную работу: домен запросил List<Todo> или типизированную ошибку — и получил ровно это, с поглощением всех деталей работы с Dio.
Презентационный слой: Cubit и UI
Для большинства экранов разумнее использовать Cubit вместо полноценного Bloc с событиями. Cubit проще — вы вызываете методы напрямую и эмитируете состояния — и этого хватает для подавляющего большинства UI-логики. Полноценный Bloc с потоком событий нужен, когда требуется дебаунс поискового ввода, трансформация параллельных событий или ведение аудиторного следа переходов.
Состояние моделируется как sealed class, чтобы компилятор заставил обработать каждый вариант:
sealed class TodosState extends Equatable {
const TodosState();
}
class TodosInitial extends TodosState { const TodosInitial(); }
class TodosLoading extends TodosState { const TodosLoading(); }
class TodosLoaded extends TodosState {
const TodosLoaded(this.todos);
final List<Todo> todos;
}
class TodosError extends TodosState {
const TodosError(this.message);
final String message;
}
Cubit зависит только от юзкейса — никогда напрямую от репозитория или Dio:
class TodosCubit extends Cubit<TodosState> {
TodosCubit(this._getTodos) : super(const TodosInitial());
final GetTodos _getTodos;
Future<void> load() async {
emit(const TodosLoading());
final result = await _getTodos(const NoParams());
result.fold(
(failure) => emit(TodosError(failure.message)),
(todos) => emit(TodosLoaded(todos)),
);
}
}
Виджет переключается по sealed-состоянию — исчерпывающе, без ветки default:
BlocBuilder<TodosCubit, TodosState>(
builder: (context, state) => switch (state) {
TodosInitial() || TodosLoading() =>
const Center(child: CircularProgressIndicator()),
TodosError(:final message) => Center(child: Text(message)),
TodosLoaded(:final todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) => CheckboxListTile(
value: todos[i].isCompleted,
title: Text(todos[i].title),
onChanged: (_) {},
),
),
},
)
Сборка всех слоёв происходит один раз — в точке композиции, с использованием DI-контейнера вроде get_it. Каждый слой получает свою внутреннюю зависимость через конструктор — именно это делает всё замокабельным:
final sl = GetIt.instance;
sl.registerLazySingleton<TodoRemoteDataSource>(
() => TodoRemoteDataSourceImpl(sl()));
sl.registerLazySingleton<TodoRepository>(
() => TodoRepositoryImpl(sl()));
sl.registerLazySingleton(() => GetTodos(sl()));
sl.registerFactory(() => TodosCubit(sl()));
Главный выигрыш: тестирование
Когда каждая зависимость — это интерфейс, внедрённый через конструктор, каждый слой можно тестировать изолированно с фейками — без Flutter, без сети:
class MockGetTodos extends Mock implements GetTodos {}
void main() {
late MockGetTodos getTodos;
setUp(() => getTodos = MockGetTodos());
blocTest<TodosCubit, TodosState>(
'emits [Loading, Loaded] when the use case succeeds',
build: () {
when(() => getTodos(any())).thenAnswer(
(_) async => const Right([
Todo(id: '1', title: 'Ship it', isCompleted: false),
]),
);
return TodosCubit(getTodos);
},
act: (cubit) => cubit.load(),
expect: () => [
const TodosLoading(),
const TodosLoaded([
Todo(id: '1', title: 'Ship it', isCompleted: false),
]),
],
);
}
Тест выполняется за миллисекунды и не касается реального API. Тест на сценарий ошибки пишется аналогично — достаточно вернуть Left(NetworkFailure()). Тестируемость перестаёт быть рутиной, потому что архитектура изначально делает все зависимости подменяемыми.
Итого
Чистая архитектура в Flutter — это не про обряды и лишние абстракции. Это про размещение границ там, где происходят изменения: сущности и юзкейсы, которые никогда не импортируют инфраструктуру; слой данных, который карантинит каждую причуду API за маппером; Cubit, который знает только домен.
Начните с одной фичи-папки, не позволяйте Dio и JSON просачиваться внутрь — и структура начнёт работать на вас: быстрые тесты, заменяемые бэкенды, параллельная работа команды. Это не теоретический идеал — это рабочий паттерн, который проверен на реальных продакшен-приложениях с многолетним жизненным циклом.