Back to Home

Use-cases in BLoC Flutter: architecture refactoring

The article describes BLoC refactoring in Flutter by extracting business logic into use-cases. Code examples before and after are provided, advantages for testing and scalability. The approach is based on clean architecture.

BLoC + use-cases: how to simplify Flutter architecture
Advertisement 728x90

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:

Google AdInline article slot
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:

Google AdInline article slot
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:

Google AdInline article slot
  • 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

Advertisement 728x90

Read Next