HomeDocumentationState Management in Flutter
State Management in Flutter
12

BLoC Patterns for Auditable Business Logic

BLoC Patterns for Auditable Business Logic: Event Sourcing, Guards, and DDD

April 4, 2026

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.

dart
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.

dart
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

javascript
[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 → OrderComplete

When 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.

dart
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.

dart
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:

dart
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.

dart
// 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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
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:

  1. DDD Aggregates: What They Are and Why Your Domain Needs Them — the backend patterns that BLoC mirrors
  2. DDD Value Objects
  3. State Management at Scale: What Changes After 50 Screens — the architecture that emerges when these patterns compound

Related Topics

flutter bloc patternsbloc event sourcing flutterflutter audit trail state managementbloc transition guardsflutter bloc observer loggingbloc domain events flutterflutter bloc testing patternsbloc middleware pipelineflutter bloc undo redobloc ddd flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.