Pure Business Logic via Zone-Based Error Control and Custom Transformers
While the BLoC pattern is a go-to choice for Flutter developers due to its predictability, growing projects often see their EventHandlers cluttered with “infrastructure noise.”
1. The Cost of Dirty Code
Consider a standard implementation of a data loading event:
on<LoadUser>((event, emit) async {
emit(UserLoading());
try {
final user = await repository.getUser(event.id);
emit(UserLoaded(user));
} on SocketException {
emit(UserError('No internet connection'));
} on ApiException catch (e) {
emit(UserError(e.message));
} catch (e, stack) {
log(e, stack);
emit(UserError('Something went wrong'));
}
});
Let’s calculate:
In a project with 50 events, developers write and maintain 500–700 lines of code that have nothing to do with business logic. Each of these lines is a potential point of failure: forgot to handle TimeoutException, missed logging, caught the wrong exception type.
2. Why Try-Catch Is Not a Solution
void add(E event) {
try {
_handlers[event.runtimeType](event);
} catch (e) {
// nothing will reach here
}
}
The problem: event handlers are asynchronous. A try-catch in add() will only catch errors thrown before the first await. Everything that happens afterward goes to a different microtask and is lost.
on<Event>((event, emit) async {
await Future.delayed(Duration(seconds: 1));
throw Exception(); // ← try-catch from add() cannot reach here
});
Three fundamental limitations of the imperative approach:
- Async leaks — an error in a “forgotten” Future (analytics, logging, timer) can crash the application
- No cancellation mechanism — if an event is cancelled by a transformer (e.g., restartable), the code inside continues running in the background
- Recursion risk — if emitting an error state itself triggers an exception, you can enter an infinite loop
3. Enter Zones: Execution Context for Async Code
Dart provides a Zone mechanism — execution contexts in which asynchronous code runs. Think of a Zone as a “protective dome” over a piece of code.
Two key capabilities of Zones:
- Catch any asynchronous error — even from Timer, scheduleMicrotask, and Future.delayed
- Store context — data placed in a Zone is accessible from anywhere inside it via Zone.current
runZonedGuarded(
() async {
await Future.delayed(Duration(seconds: 1));
throw Exception('Error!'); // ← will be caught
},
(error, stackTrace) {
print('Caught: $error');
}
);
4. Architecture: Each Handler in Its Own Zone
To apply Zones to BLoC, we hook into the event lifecycle through a custom EventTransformer.
How It Works
Key Implementation Elements
Cancel Token — an object signaling that event execution is no longer needed:
abstract class ICancelToken {
bool get isCancelled;
Future<void> get whenCancel;
void cancel([dynamic reason]);
void throwIfCancelled();
}
StreamController with onCancel — links event lifecycle with the token:
final controller = StreamController<State>(
sync: true,
onCancel: () => token.cancel(), // cancel when event stops
);
takeUntil — cuts off the state stream when the token is cancelled:
await for (final state in handler(event).takeUntil(token.whenCancel)) {
controller.add(state);
}
runZonedGuarded — creates a “protective dome”:
runZonedGuarded(
() async { /* handler execution */ },
(error, stack) => handleError(error, stack, event),
zoneValues: { #token: token, #event: event },
);
5. Error Handling Hierarchy
When a Zone catches an error, the question arises: what state should it transform into?
Chain of responsibility with priorities:
S? result = getEventSpecificMapper(error, event) // ← specific to event
?? getGlobalMapper(error, event) // ← for all bloc events
?? null; // ← ignore
Why three levels?
6. Protection Against Recursion and Noise
Recursion Guard: set a flag in zoneValues before emitting the error state:
void handleError(Object error, StackTrace stack, E event) {
if (Zone.current[#isHandlingError] == true) return; // already handling
runZoned(
() => emit(errorState),
zoneValues: { #isHandlingError: true }, // block re-entry
);
}
Silent Error Filtering: use isGlobalSilent to filter out expected exceptions (e.g., manual request cancellation) so they don't clutter analytics logs or confuse users with unnecessary alerts.
7. The Result: Code We Deserve
The same handler with error handling moved to the infrastructure layer:
on<LoadUser>((event, emit) async {
emit(UserLoading());
final user = await repository.getUser(event.id, token: contextToken);
emit(UserLoaded(user));
});
Comparison:
And error handling lives its own life, in one place:
@override
UserState? mapErrorToState(Object error, StackTrace stack, UserEvent event) {
if (error is SocketException) return UserError('No internet connection');
if (error is ApiException) return UserError(error.message);
return UserError('Something went wrong');
}
Example with Cancellation on Repeated Events
A particularly valuable scenario is when a user rapidly calls the same event multiple times:
on<SearchEvent>((event, emit) async {
emit(SearchLoading());
// The first request (flutter) will be automatically cancelled
// when the second one (dart) arrives
final results = await searchRepository.search(event.query, token: contextToken);
emit(SearchLoaded(results));
}, transformer: restartable());
In the repository, simply check the token:
Future<List<Result>> search(String query, {ICancelToken? token}) async {
token?.throwIfCancelled();
final response = await dio.get('/search?q=$query', cancelToken: token?.toDio());
return response.data;
}
8. Performance
Creating a Zone and token for each event has a measurable but negligible cost:
49 microseconds is 300 times faster than the frame budget at 60 FPS (16.6 ms).
9. When This Solution Is Overkill
Like any engineering solution, this has boundaries of applicability.
DO NOT use if:
- Small project (<10 events) — overhead won’t pay off
- Using Cubit — no events, pattern not applicable
- Team unfamiliar with Zones — learning curve can be steep
DO use if:
- Project is growing and the number of events keeps increasing
- Tired of writing the same try-catch in every handler
- Need automatic cancellation of long operations on repeated calls
10. Production-Ready Solution
The described architectural approach is implemented in the package bloc_error_control (available on pub.dev).
It’s a compact mixin that encapsulates all the Zone, token, and error mapper logic:
class UserBloc extends Bloc<UserEvent, UserState>
with BlocErrorControlMixin<UserEvent, UserState> {
// your code
}
For those who want to reduce boilerplate even further, there’s an optional code generation package bloc_error_control_generator. It allows you to connect methods to error handlers via annotations while preserving manual control where needed.
Test coverage: 100% code, 84.6% branches.
Conclusion
Moving exception handling and async context management to the infrastructure layer is a shift from imperative error fighting to designing an environment that takes care of its own integrity.
We stop writing defensive code in every method and start designing a system where async context is a managed resource, not a source of chaos.
GitHub Repository: github.com/ruslandavudov/bloc_error_control
Questions about Zones or ICancelToken implementation? See you in the comments!





Top comments (0)