/ Docs

Eventual Consistency

How Bounda Processes Events

When a command handler returns events, Bounda persists them to the event store and returns control to the caller. Reactive operations — projections, policies, and process managers — are processed asynchronously by an in-process event dispatcher. This means read models and side effects are eventually consistent with the write side.

The Event Dispatcher

The event dispatcher is a cursor-based polling worker that runs inside the application process. It maintains a separate cursor (position in the global event stream) for each subscriber kind. On each poll cycle, it loads a batch of events after the cursor, runs the subscriber, and advances the cursor.

Subscribers and Priority

SubscriberPriorityDescription
projection10Updates read models
policy20Triggers side effects and dispatches commands
process-manager30Orchestrates multi-step workflows

Lower priority numbers run first within each poll cycle. Projections are always up to date before policies and process managers react.

Error Isolation

Each subscriber is processed independently. If the projection subscriber fails on a batch, the policy and process-manager subscribers still advance. The failed subscriber retries from its last saved cursor position on the next poll cycle.

Monitoring with getLag()

After booting the application, call app.getLag() to inspect how far each subscriber is behind the global event stream:

const lag = await app.getLag();
// {
//   lastGlobalPosition: 42,
//   subscribers: {
//     projection:         { currentPosition: 42, lag: 0 },
//     policy:             { currentPosition: 40, lag: 2 },
//     "process-manager":  { currentPosition: 40, lag: 2 },
//   },
//   maxLag: 2,
// }

A lag of 0 means the subscriber is fully caught up with the event stream.

Testing with processUntilIdle()

In a long-running application (e.g., a web server), the dispatcher processes events automatically — read models update within milliseconds of a command completing. In scripts and tests, however, the polling timer may not fire before your assertions run.

Call app.processUntilIdle() after dispatching commands to drain all pending events before asserting on read models:

await app.commands.placeOrder({
  aggregateId: "order-1",
  customerId: "cust-1",
  items: [{ productId: "prod-1", quantity: 1, price: 30.0 }],
});

await app.processUntilIdle();

const summary = await app.queries.getOrderSummary({ orderId: "order-1" });
expect(summary.status).toBe("placed");

processUntilIdle loops internally until a cycle processes zero events, then returns the total number of events processed. A maxIterations guard (default: 100) prevents infinite loops from circular policy chains.

Always call app.stop() in test teardown (e.g., afterAll) to clear the polling interval.

Recovery

Because each subscriber tracks its own cursor, restarting the application resumes processing from the last committed position. No events are lost — the dispatcher picks up exactly where it left off. Events persisted before a crash but not yet processed are automatically picked up on the next boot.

  • Projections — read model updates processed by the dispatcher
  • Policies — side effects triggered asynchronously
  • Process Managers — multi-step workflows coordinated asynchronously
  • Commands — the write operation that produces events