При разработке Flutter-приложений паттерн
BLoC часто становится всемогущим объектом, впитывающим всю бизнес-логику. Внутри хендлеров оказываются и запросы к сервисам, и валидация и эмиттеры состояния. Проект разрастается, файлы раздуваются, тестирование становится невозможным. Сегодня обсудим как можно вынести бизнес-логику в отдельные классы -
use-cases.
Как это работает:
Вместо того чтобы вызывать сервисы прямо внутри хендлеров
BLoC, вся логика конкретного сценария (например загрузки товаров) выносится в отдельный класс.
Use-case зависит от абстракций репозиториев и сервисов, но ничего не знает про
UI. Он вызывает нужные зависимости, обрабатывает ошибки, выполняет side-эффекты и только после этого отправляет событие в
BLoC. Сам
BLoC превращается в тонкую прослойку: принимает события, обновляет состояние и больше ничего не делает.
Как это работает на практике:
Огромный BLoC-файл, отвечающий за получение данных, кэширование, фильтрацию и обновление
UI, превращается в узкое место проекта. Конструктор забит зависимостями, тестирование почти невозможно. После рефакторинга
BLoC сокращается до 20-30 строк, не зависит от сервисов и становится просто набором функций для обновления состояния. Все пользовательские сценарии выносятся в отдельные
use-cases.
В результате время разработки нового функционала сокращается, количество багов снижается, тесты становятся надежнее.
Пример кода:
На примере ниже показано, как выглядит файл
BLoC целиком:
// items_bloc.dart
class ItemsBloc extends Bloc {
ItemsBloc() : super(Initial()) {
on(_onSetLoading);
on(_onSetLoaded);
on(_onSetError);
}
Future _onSetLoading(SetLoading event, Emitter emit) async {
emit(Loading());
}
Future _onSetLoaded(SetLoaded event, Emitter emit) async {
emit(Loaded(items: event.items));
}
Future _onSetError(SetError event, Emitter emit) async {
emit(Error(error: event.error));
}
}
А также реализация
use-case:
// fetch_items_use_case.dart
class FetchItemsUseCase {
final ItemsBloc bloc;
final FetchItemsService service;
FetchItemsUseCase({required this.bloc, required this.service});
Future call() async {
bloc.add(SetLoading());
try {
final items = await service.fetchItems();
bloc.add(SetLoaded(items: items));
} catch (error) {
bloc.add(SetError(error: error.toString()));
}
}
}
Почему это работает:
Use-case оркестрирует несколько сервисов и репозиториев, объединяет данные, обрабатывает ошибки, логирует и только после этого отправляет событие в
BLoC.
BLoC остается только стейт-менеджером и не выполняет ничего, кроме преобразования событий в состояние. Такой подход соответствует принципам чистой архитектуры: доменный слой не зависит от реализации, что повышает гибкость и тестируемость.
Компромисс:
В этой схеме
use-case вынужден знать о
BLoC (вызывать bloc.add), что формально нарушает идеальную зависимость доменного слоя от презентационного. Но взамен
UI освобождается от вызовов
bloc.add, а код становится проще и нагляднее. Компромисс оправдан.
Вывод:
Разделение бизнес-логики и
UI - необходимость для масштабируемых приложений.
Use-cases помогают структурировать код, упрощают тестирование и делают
BLoC максимально простым. Этот подход не привязан к
BLoC и может использоваться с любым другим стейт-менеджером.