Separating BLoC and Use Cases in Flutter: Clean Architecture in Action
Flutter's BLoC pattern often turns into a god object: event handlers mix service calls, validation, and state emissions. This makes maintenance, testing, and scaling a nightmare. Moving business logic into use cases restores proper layer separation following clean architecture principles: presentation (UI and BLoC), domain (use cases, entities), and data (repositories, services).
A use case implements a specific scenario: it orchestrates services, handles errors, applies business rules, and stays UI-agnostic. The BLoC focuses purely on unidirectional data flow—handling events and emitting states.
Classic BLoC: Where Logic Gets Tangled with UI
A typical BLoC calls services directly via dependency injection, performing side effects right in the handlers. Here's a simplified ItemsBloc example:
class ItemsBloc extends Bloc<ItemsEvent, ItemsState> {
final FetchItemsService _service;
ItemsBloc(this._service) : super(Initial()) {
on<FetchItemsEvent>(_onFetchItemsEvent);
}
Future<void> _onFetchItemsEvent(FetchItemsEvent event, Emitter<ItemsState> emit) async {
emit(Loading());
try {
final items = await _service.fetchItems();
emit(Loaded(items: items));
} catch (error) {
emit(Error(error: error));
}
}
}
Problems: tight coupling, hard-to-test code, and single responsibility principle (SRP) violations. The BLoC knows too much about data, networking, and business logic.
Refactoring: BLoC as a Pure State Manager
After refactoring, the BLoC only maps events to states, with no dependencies:
class 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));
}
}
The use case handles orchestration:
class 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()));
}
}
}
The BLoC shrinks to 20 lines and becomes fully testable: mock events and verify states.
Benefits of Use Cases as Orchestrators
A use case isn't just a proxy—it combines data from repositories, applies rules, logs activity, and handles synchronization. It depends on abstractions (interfaces), not concrete implementations.
Key advantages:
- Testability: Mock services/repositories for unit-testing use cases.
- Scalability: Add new scenarios without touching the BLoC (Open/Closed principle).
- DI Flexibility: Inject services into use cases; BLoC stays independent.
- Readability: Business scenarios are isolated in dedicated classes.
In a real project, this refactoring cut development time by 30%, reduced bugs, and simplified tests.
Data Flow and Trade-offs
UI → use case → services/repositories → events to BLoC → UI state. The use case knows about the BLoC (a clean architecture compromise for simplicity), but the UI is free of bloc.add calls.
BLoC enables unidirectional data flow: predictable behavior and easy tests. Use cases are tested independently: simulate responses and verify calls.
Key Takeaways
- Layer Separation: BLoC as state manager, use cases for domain logic.
- SRP in Action: Each class has one job.
- Simplified Testing: Pure functions in BLoC, mocks in use cases.
- Scalability: Works with Cubit, Riverpod, or other state managers.
This approach fits any Flutter project where BLoC is ballooning. Measure before/after metrics: class sizes, test coverage, feature delivery time.
— Editorial Team
No comments yet.