17.08.2026 456 материалов

Чистая архитектура в Flutter с BLoC: практическое руководство по разделению слоёв

Подробный разбор того, как выстроить чистую архитектуру в 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 просачиваться внутрь — и структура начнёт работать на вас: быстрые тесты, заменяемые бэкенды, параллельная работа команды. Это не теоретический идеал — это рабочий паттерн, который проверен на реальных продакшен-приложениях с многолетним жизненным циклом.