Вынесение бизнес‑логики из BLoC в use‑cases: прагматичный взгляд на архитектуру Flutter

от автора

Вступление: зачем вообще задумываться об архитектуре

Начиная писать Flutter-приложение, для стейт-менеджмента часто хватает простого setState или решения по типу BLoC/Cubit без излишеств. Но с течением жизни проекта ваши блоки могут начать превращаться в god objects. Внутри хендлеров могут находиться и запросы в сервисы, и валидация, и эмиттеры состояния, а для крупной страницы точно одним ивентом не обойдешься. В таких условиях разработка сильно затрудняется, становится сложно поддерживать и масштабировать проект, снижается тестируемость.

Это не субъективный опыт — строгая разделенная архитектура повышает гибкость, переиспользуемость и тестируемость кода. BLoC сам по себе — паттерн с отличной дисциплиной потока данных и строгим отделением бизнес‑логики от UI, но стоит немного расслабиться, и он разрастается до god объекта.

Цель этой статьи — продемонстрировать, как вынесение бизнес логики в use-cases может помочь вернуть контроль над ViewModel слоем. Это не попытка навязать единственный вариант реализации, а материал про технический компромисс, подтвержденный цифрами и опытом.

Выросший BLoC как симптом

На одном из проектов наша команда столкнулась с тем, что спустя длительное время разработки один из блоков начал сильно разрастаться. Он отвечал за получение товаров из сервиса, кэширование, фильтрацию, обновление UI и много чего еще. Конструктор выглядел ужасно — туда прокидывалось огромное количество зависимостей через DI, которые были нужны в методах блока. При необходимости трогать этот файл всегда становилось не по себе: малейшее изменение «не там где нужно» могло все сломать. Тестирование было почти невозможно. Как итог — мы получили объект, где то и дело вылезают баги, которые тяжело локализовать и устранить.

Такой сценарий типичен для state management решений без чёткого разделения на UI и domain. При этом, BLoC сам по себе задуман как паттерн, обеспечивающий реактивную обработку событий и изоляцию бизнес‑логики от представления. Проблема не в паттерне, а в том, что мы полностью игнорируем его границы.

Самый распространенный вариант применения BLoC — и как стейт-менеджмент для UI, который хранит состояние страницы, и как обработчик событий, вызывающий куски бизнес-логики прямо внутри себя. При таком подходе класс BLoC превращается в настоящий god object, где каждый ивент хендлер вызывает какую-то свою логику и выполняет кучу side-эффектов. Ниже сильно упрощенный пример стандартной реализации ItemsBloc, я оставил только один хендлер события, чтобы показать, как именно оттуда вызывается бизнес-логика.

// BLoC here is not only a UI state manager, but it also// uses service, which is passed through the DI. In real projects// such BLoC would be much biggerclass ItemsBloc extends Bloc<ItemsEvent, ItemsState> {  // Service is passed via DI  final FetchItemsService _service;  ItemsBloc(this._service) : super(Initial()) {    // ...    on<FetchItemsEvent>(_onFetchItemsEvent);    // ...  }  // ...  // Some of your other event handlers  // ...  Future<void> _onFetchItemsEvent(FetchItemsEvent event, Emitter<ItemsState> emit) async {    emit(Loading());    try {      //BLoC is calling the service directly from itself      final items = await _service.fetchItems();      emit(Loaded(items: items));    } catch (error) {      emit(Error(error: error));    }  }  // ...}

На этом примере я оставил только один хендлер — он вызывается при прокидывании FetchItemsEvent из UI напрямую в BLoC, где вызываются сервисы и прочие зависимости, на основе которых затем эмиттится новый State. Такой подход знаком многим, но обладает рядом проблем, которые сильно сказываются на долгосрочной разработке, но об этом чуть позже.

Чистая архитектура, уровни и обязанности

Чтобы вернуть паттерну нормальную структуру, полезно будет вспомнить про принципы чистой архитектуры. Она делит приложения на слои: presentation (UI), domain (бизнес-логика) и data (слой данных). Основной поинт — разделение ответственности и независимость слоя бизнес-логики от внешних деталей. В domain находятся сущности, сервисы и use-casesUse-case — это класс, реализующий конкретный пользовательский сценарий. Он зависит от абстракций репозиториев и сервисов, при этом он ничего не знает о UI.

Такой подход делает код чище и гораздо более читаемым, а также улучшает тестируемость и масштабируемость за счет явного разделения обязанностей. BLoC теперь управляет состоянием UI, принимает входной поток событий и отдает обратно обновленный State, не зная про особенности реализации бизнес-логики. При этом, повышается и общее качество проекта, т.к. он становится более структурированным — теперь не нужно искать реализацию того или иного пользовательского сценария в хендлерах ивентов в BLoC, поскольку такие реализации теперь вынесены в отдельный класс use-case.

Реальный кейс: как use‑cases спасли проект

Вернёмся к нашему огромному файлу BLoC с одного из проектов. Мы полностью переработали его, убрав почти все события по типу FetchItemsButtonPressed, и, соответственно, все их хэндлеры внутри BLoC. Их мы заменили на use-cases, например, для загрузки товаров появился FetchItemsUseCase, который знает, как обратиться к сервисам, проверить кэш, объединить данные и вернуть результат. BLoC превратился в класс, соблюдающий принцип единственной ответственности, отвечающий только за получениесобытий и обновление состояния. Время разработки нового функционала сократилось примерно на треть. Количество багов в модуле заметно снизилось, а тесты стали надёжнее.

На примере ниже я показал, как выглядит файл BLoC целиком после изменений, а также реализацию use-case.

// items_bloc.dartclass ItemsBloc extends Bloc<ItemsEvent, ItemsState> {  ItemsBloc() : super(Initial()) {    on<SetLoading>(_onSetLoading);    on<SetLoaded>(_onSetLoaded);    on<SetError>(_onSetError);  }  Future<void> _onSetLoading(SetLoading event, Emitter<ItemsState> emit) async {    emit(Loading());  }  Future<void> _onSetLoaded(SetLoaded event, Emitter<ItemsState> emit) async {    emit(Loaded(items: event.items));  }  Future<void> _onSetError(SetError event, Emitter<ItemsState> emit) async {    emit(Error(error: event.error));  }}
// fetch_items_use_case.dartclass FetchItemsUseCase {  final ItemsBloc bloc;  final FetchItemsService service;  FetchItemsUseCase({required this.bloc, required this.service});  Future<void> call() async {    bloc.add(SetLoading());    try {      final items = await service.fetchItems();      bloc.add(SetLoaded(items: items));    } catch (error) {      bloc.add(SetError(error: error.toString()));    }  }}

Как вы могли заметить, BLoC теперь занимает всего 20 строк, не зависит от сервисов (и в целом ни от чего не зависит), и является по сути классом, предоставляющим чистые функции для обновления состояния. При этом мы не потеряли в качестве и читаемости кода, даже наоборот, ведь теперь все пользовательские сценарии, вызывающие какое-либо изменение состояния, обособлены друг от друга, оставляя основной класс для управления состоянием простым и понятным. Секрет в явном управлении потоком: use‑case оркестрирует несколько сервисов и репозиториев, обрабатывает ошибки, выполняет side-эффекты и только потом отправляет событие в BLoC. Такая схема соответствует принципам чистой архитектуры и позволяет масштабировать проект.

Use‑case как orchestrator, а не прокси

Вы можете спросить: зачем же нужен use‑case, если он лишь вызывает сервис и делает bloc.add? Но в реальном приложении сценарии могут быть (и будут) гораздо сложнее. Один use‑case может делать запрос на сервер и в локальное хранилище, объединять результаты и сортировать их, обрабатывать ошибки, логировать и обновлять аналитику, инициировать синхронизацию в фоне. В простом примере можно было бы вызывать сервис напрямую, но в сложных сценариях use‑case освобождает BLoC от работы, для которой он не предназначен. BLoC остаётся стейт‑менеджером, не выполняя ничего кроме маппинга событий в состояние.

Схема, демонстрирующая подход к использованию BLoC

Схема, демонстрирующая подход к использованию BLoC

Use‑case зависит от абстрактных сервисов, которые скрывают детали работы с репозиториями, сетью или локальным хранилищем. Это соответствует принципам чистой архитектуры: доменный слой не знает о реализации, что повышает гибкость и тестируемость. Сервисы возвращают сущности или модели, а use‑case применяет бизнес‑правила. Например, ItemsRepository определяет метод получения предметов, а конкретная реализация обращается к API. Это даёт возможность подменить источник данных без переписывания use‑case.

BLoC как unidirectional state manager

Когда логика вынесена наружу, event handlers в блоке превращаются в чистые функции: они принимают события и эмитят состояния. BLoC реагирует только на входящие события и не ходит ни в сервисы, ни куда-либо еще. Такой BLoC легко тестировать: отправляем событие, проверяем состояние. За счёт однонаправленного потока данных и событий поведение становится предсказуемым. Кроме того, если в будущем понадобятся новые сценарии, их можно добавить в use‑case, не трогая BLoC, не нарушая тем самым принцип Open/Closed.

Когда use‑cases изолированы, их легко тестировать. Мы можем подменить сервис mock‑объектом, симулировать разные ответы и проверять, что use‑case корректно вызывает методы и обрабатывает события. BLoC тоже тестируется отдельно: вместо реального use‑case подставляем mock и отправляем события, проверяя состояния. Всё это возможно, потому что бизнес‑логика отделена от представления и внешних зависимостей, как и советует чистая архитектура.

Инъекция зависимостей — ещё один плюс. Можно использовать любой контейнер DI или вручную передавать сервисы и блоки в конструктор use‑case, без необходимости прокидывать их в блок. Это уменьшает связанность и облегчает конфигурацию в тестах.

Поток данных и компромисс с событиями

В описываемом мной подходе поток данных выглядит следующим образом: UI вызывает use‑caseuse‑case обращается к репозиториям и сервисам; после получения данных use‑case отправляет событие в BLoC; BLoC обновляет состояние UI. Формально доменный слой (use‑case) начинает знать о блоке. Это нарушает идеальную зависимость domain to presentation, но в обмен на простоту и наглядность. Use-case знает, какие события нужно отправить и делает это напрямую. В то же время, любые вызовы bloc.add полностью пропадают из слоя UI, где они до этого и находились, так что это не должно вызывать вопросов.

Заключение

Разделение бизнес‑логики и UI — необходимость для масштабируемых приложений. Use‑cases помогают структуировать код, упрощают тестирование и делают BLoC максимально простым. BLoC остаётся паттерном для управления состоянием, но освобождается от лишней нагрузки. Кроме того, такой подход не обязательно требует использования именно BLoC, его можно реализовывать с любым другим стейт-менеджером. Подумайте, где в вашем проекте файлы начали разрастаться, попробуйте вынести сценарии в отдельные use‑cases и измерьте эффект.

Если вы уже использовали подобный подход, расскажите о своём опыте в комментариях. Согласны ли вы с преимуществами, которые я описал в этой статье? Какие проблемы встретили в больших проектах? Обсудим внизу статьи — чем больше реальных примеров, тем полезнее для сообщества.

ссылка на оригинал статьи https://habr.com/ru/articles/1022838/