Some features can't afford to lose track of what happened. A payment flow where you need to know exactly why a user ended up on the error screen. An approval workflow where regulators might ask for a record of every state transition. An inventory system where "the quantity just changed" isn't an acceptable answer — you need to know what changed it, when, and in response to what.
This is where BLoC isn't just a state management choice — it's an audit architecture choice. The event-driven model gives you something no other Flutter state management tool provides out of the box: a named, typed, traceable record of every cause that led to every effect.
This post shows how to build BLoC patterns that produce real audit trails, connect to domain-driven design, and survive production debugging.
Why Events Are Audit Records
In BLoC, every state change has a cause: an event. Events are Dart objects with names, types, and data. They're not strings in a log file — they're structured, typed records of intent.
sealed class OrderEvent {}
class OrderCreated extends OrderEvent {
final String customerId;
final List<LineItem> items;
final DateTime timestamp;
OrderCreated({required this.customerId, required this.items})
: timestamp = DateTime.now();
}
class OrderApproved extends OrderEvent {
final String approvedBy;
final DateTime timestamp;
OrderApproved({required this.approvedBy})
: timestamp = DateTime.now();
}
class OrderRejected extends OrderEvent {
final String rejectedBy;
final String reason;
final DateTime timestamp;
OrderRejected({required this.rejectedBy, required this.reason})
: timestamp = DateTime.now();
}
class OrderShipped extends OrderEvent {
final String trackingNumber;
final DateTime timestamp;
OrderShipped({required this.trackingNumber})
: timestamp = DateTime.now();
}Each event answers:
- What happened (the event type)
- Who caused it (the actor)
- When it happened (the timestamp)
- With what data (the payload)
This isn't logging. This is the primary mechanism by which state changes. The audit trail isn't a side effect — it's the architecture.
The BlocObserver: Your Centralized Audit Log
BLoC provides a BlocObserver that intercepts every event and state change across your entire app. This is your audit infrastructure.
class AuditBlocObserver extends BlocObserver {
final AuditLogger _logger;
AuditBlocObserver(this._logger);
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
_logger.logEvent(
blocType: bloc.runtimeType.toString(),
eventType: event.runtimeType.toString(),
event: event,
timestamp: DateTime.now(),
);
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
_logger.logTransition(
blocType: bloc.runtimeType.toString(),
from: transition.currentState.runtimeType.toString(),
event: transition.event.runtimeType.toString(),
to: transition.nextState.runtimeType.toString(),
timestamp: DateTime.now(),
);
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
_logger.logError(
blocType: bloc.runtimeType.toString(),
error: error,
stackTrace: stackTrace,
timestamp: DateTime.now(),
);
}
}
// In main.dart
void main() {
Bloc.observer = AuditBlocObserver(AuditLogger());
runApp(const MyApp());
}With this in place, every BLoC in your app automatically logs every event, every state transition, and every error — without modifying any individual BLoC.
What the Audit Log Looks Like
[2026-03-20 14:32:01] OrderBloc | Event: OrderCreated(customer: C-1234, items: 3)
[2026-03-20 14:32:01] OrderBloc | Transition: OrderInitial → OrderCreated → OrderPending
[2026-03-20 14:32:45] OrderBloc | Event: OrderApproved(approvedBy: manager@company.com)
[2026-03-20 14:32:45] OrderBloc | Transition: OrderPending → OrderApproved → OrderProcessing
[2026-03-20 14:33:12] OrderBloc | Event: OrderShipped(tracking: TRK-9876)
[2026-03-20 14:33:12] OrderBloc | Transition: OrderProcessing → OrderShipped → OrderCompleteWhen a customer calls and says "my order disappeared," you don't guess. You read the log. You see every event that touched that order. You know exactly what happened, in what sequence, and what state the system was in at every step.
Pattern 1: The Transition Guard
Not every event should be valid from every state. An order that's already shipped shouldn't accept an OrderApproved event. BLoC lets you enforce these rules explicitly.
class OrderBloc extends Bloc<OrderEvent, OrderState> {
OrderBloc() : super(OrderInitial()) {
on<OrderCreated>(_onCreated);
on<OrderApproved>(_onApproved);
on<OrderRejected>(_onRejected);
on<OrderShipped>(_onShipped);
}
void _onApproved(OrderApproved event, Emitter<OrderState> emit) {
final current = state;
if (current is! OrderPending) {
// Invalid transition — log it, don't crash
addError(
InvalidTransitionError(
from: current.runtimeType.toString(),
event: 'OrderApproved',
reason: 'Can only approve orders in Pending state',
),
);
return;
}
emit(OrderProcessing(
orderId: current.orderId,
approvedBy: event.approvedBy,
approvedAt: event.timestamp,
));
}
void _onShipped(OrderShipped event, Emitter<OrderState> emit) {
final current = state;
if (current is! OrderProcessing) {
addError(
InvalidTransitionError(
from: current.runtimeType.toString(),
event: 'OrderShipped',
reason: 'Can only ship orders in Processing state',
),
);
return;
}
emit(OrderComplete(
orderId: current.orderId,
trackingNumber: event.trackingNumber,
shippedAt: event.timestamp,
));
}
}The transition guard pattern gives you:
- Invalid transitions are caught, not silently applied
- Error logging captures the attempted invalid transition
- State integrity — the BLoC can never reach an impossible state
Compare this to Riverpod, where state is a value you set. Nothing prevents you from setting OrderComplete when the current state is OrderInitial. You'd need to build and enforce that guard yourself.
Pattern 2: Event Sourcing Lite
Full event sourcing stores every event and reconstructs state by replaying them. That's typically a backend pattern. But a lighter version works well in Flutter for features that benefit from history.
class AuditableBloc<E, S> extends Bloc<E, S> {
final List<AuditEntry<E, S>> _history = [];
AuditableBloc(super.initialState);
List<AuditEntry<E, S>> get history => List.unmodifiable(_history);
@override
void onTransition(Transition<E, S> transition) {
super.onTransition(transition);
_history.add(AuditEntry(
event: transition.event,
fromState: transition.currentState,
toState: transition.nextState,
timestamp: DateTime.now(),
));
}
/// Replay history up to a point in time
S? stateAt(DateTime timestamp) {
final relevant = _history.where((e) => e.timestamp.isBefore(timestamp));
if (relevant.isEmpty) return null;
return relevant.last.toState;
}
}
class AuditEntry<E, S> {
final E event;
final S fromState;
final S toState;
final DateTime timestamp;
AuditEntry({
required this.event,
required this.fromState,
required this.toState,
required this.timestamp,
});
}Now any BLoC that extends AuditableBloc gets full history:
class PaymentBloc extends AuditableBloc<PaymentEvent, PaymentState> {
PaymentBloc() : super(PaymentInitial());
// ... event handlers ...
}
// In debugging or support tools
final bloc = context.read<PaymentBloc>();
print(bloc.history.map((e) => '${e.timestamp}: ${e.event} → ${e.toState}'));
// What was the state 5 minutes ago?
final pastState = bloc.stateAt(DateTime.now().subtract(Duration(minutes: 5)));This is invaluable for support tools, admin dashboards, and debugging production issues.
Pattern 3: Domain Events Bridge
If your backend uses domain-driven design with domain events, BLoC events can mirror them. This creates architectural consistency from database to UI.
// Backend domain event (Node.js / NestJS)
// { type: "OrderStatusChanged", orderId: "123", from: "pending", to: "approved", by: "user@email.com" }
// Frontend BLoC event — same vocabulary
class OrderStatusChanged extends OrderEvent {
final String orderId;
final OrderStatus from;
final OrderStatus to;
final String changedBy;
OrderStatusChanged({
required this.orderId,
required this.from,
required this.to,
required this.changedBy,
});
}When a WebSocket pushes a domain event from the backend, the frontend translates it directly to a BLoC event:
class OrderBloc extends Bloc<OrderEvent, OrderState> {
final WebSocketService _ws;
late final StreamSubscription _subscription;
OrderBloc(this._ws) : super(OrderInitial()) {
on<OrderStatusChanged>(_onStatusChanged);
on<OrderLoadRequested>(_onLoadRequested);
// Bridge: backend domain events → BLoC events
_subscription = _ws.orderEvents.listen((domainEvent) {
add(OrderStatusChanged(
orderId: domainEvent.orderId,
from: OrderStatus.fromString(domainEvent.from),
to: OrderStatus.fromString(domainEvent.to),
changedBy: domainEvent.by,
));
});
}
@override
Future<void> close() {
_subscription.cancel();
return super.close();
}
}Now the audit trail spans the full stack. A support agent can see the backend event that triggered the frontend state change. The vocabulary is consistent. The debugging story is complete.
For more on how domain events work in DDD, see Domain Events: When Things Happen in Your System.
Pattern 4: Undo / Redo
The event history pattern naturally supports undo/redo for features that need it:
mixin UndoableBlocMixin<E, S> on AuditableBloc<E, S> {
int _historyIndex = -1;
bool get canUndo => _historyIndex > 0;
bool get canRedo => _historyIndex < history.length - 1;
void undo() {
if (!canUndo) return;
_historyIndex--;
// emit the previous state directly
// ignore: invalid_use_of_visible_for_testing_member
emit(history[_historyIndex].fromState);
}
void redo() {
if (!canRedo) return;
_historyIndex++;
// ignore: invalid_use_of_visible_for_testing_member
emit(history[_historyIndex].toState);
}
@override
void onTransition(Transition<E, S> transition) {
super.onTransition(transition);
_historyIndex = history.length - 1;
}
}This is useful for text editors, drawing apps, form builders, or any feature where users expect to undo actions.
Pattern 5: Middleware Pipeline
For cross-cutting concerns — analytics, permissions, rate limiting — you can add middleware that processes events before they reach the handler:
abstract class EventMiddleware<E> {
/// Return null to block the event, or return the (possibly modified) event
E? process(E event);
}
class PermissionMiddleware extends EventMiddleware<OrderEvent> {
final PermissionService _permissions;
PermissionMiddleware(this._permissions);
@override
OrderEvent? process(OrderEvent event) {
if (event is OrderApproved) {
if (!_permissions.canApproveOrders(event.approvedBy)) {
// Log the unauthorized attempt
logger.warning('Unauthorized approval attempt by ${event.approvedBy}');
return null; // Block the event
}
}
return event;
}
}
class AnalyticsMiddleware extends EventMiddleware<OrderEvent> {
final AnalyticsService _analytics;
AnalyticsMiddleware(this._analytics);
@override
OrderEvent? process(OrderEvent event) {
_analytics.track('order_event', {
'type': event.runtimeType.toString(),
'timestamp': DateTime.now().toIso8601String(),
});
return event; // Pass through
}
}Apply middleware in the BLoC using an EventTransformer — this is the correct interception point, because onEvent is only an observation callback that doesn't control event processing:
class OrderBloc extends Bloc<OrderEvent, OrderState> {
final List<EventMiddleware<OrderEvent>> _middlewares;
OrderBloc({required List<EventMiddleware<OrderEvent>> middlewares})
: _middlewares = middlewares,
super(OrderInitial()) {
on<OrderCreated>(_onCreated, transformer: _withMiddleware());
on<OrderApproved>(_onApproved, transformer: _withMiddleware());
// ...
}
/// Creates an EventTransformer that runs events through the middleware
/// pipeline. Events that any middleware blocks (returns null) are dropped
/// before reaching the handler.
EventTransformer<E> _withMiddleware<E extends OrderEvent>() {
return (events, mapper) => events
.where((event) {
OrderEvent? processed = event;
for (final middleware in _middlewares) {
processed = middleware.process(processed!);
if (processed == null) return false; // Event blocked
}
return true;
})
.asyncExpand(mapper);
}
}This is backend-style architecture in the frontend. Permissions, analytics, rate limiting — all applied consistently without cluttering individual event handlers.
Testing Auditable BLoCs
The event-driven model makes testing precise:
void main() {
group('OrderBloc', () {
late OrderBloc bloc;
setUp(() {
bloc = OrderBloc();
});
tearDown(() {
bloc.close();
});
blocTest<OrderBloc, OrderState>(
'should transition from Pending to Processing on approval',
build: () => bloc,
seed: () => OrderPending(orderId: '123'),
act: (bloc) => bloc.add(
OrderApproved(approvedBy: 'manager@company.com'),
),
expect: () => [
isA<OrderProcessing>()
.having((s) => s.orderId, 'orderId', '123')
.having((s) => s.approvedBy, 'approvedBy', 'manager@company.com'),
],
);
blocTest<OrderBloc, OrderState>(
'should reject approval when not in Pending state',
build: () => bloc,
seed: () => OrderComplete(orderId: '123', trackingNumber: 'TRK-1'),
act: (bloc) => bloc.add(
OrderApproved(approvedBy: 'manager@company.com'),
),
expect: () => [], // No state change
errors: () => [isA<InvalidTransitionError>()],
);
blocTest<OrderBloc, OrderState>(
'full order lifecycle',
build: () => bloc,
act: (bloc) {
bloc.add(OrderCreated(customerId: 'C-1', items: [mockItem]));
bloc.add(OrderApproved(approvedBy: 'manager@co.com'));
bloc.add(OrderShipped(trackingNumber: 'TRK-999'));
},
expect: () => [
isA<OrderPending>(),
isA<OrderProcessing>(),
isA<OrderComplete>(),
],
);
});
}Each test verifies:
- The right state transition happened
- Invalid transitions are caught
- The full lifecycle works end to end
No mocking the UI. No widget tests needed. Pure business logic verification.
When This Pattern Is Overkill
Not every BLoC needs audit trails. A theme switcher BLoC doesn't need transition guards. A simple counter doesn't need event sourcing.
Use auditable BLoC patterns when:
- Money is involved — payments, subscriptions, refunds
- Compliance matters — healthcare, finance, legal workflows
- Multi-step processes — onboarding, approvals, order fulfillment
- Debugging is expensive — features where "it just broke" costs real time and money
Use simpler state management for everything else. The patterns in this post are tools, not mandates.
Related reading:
- DDD Aggregates: What They Are and Why Your Domain Needs Them — the backend patterns that BLoC mirrors
- DDD Value Objects
- State Management at Scale: What Changes After 50 Screens — the architecture that emerges when these patterns compound