Ordered event delivery

By default, Spring DDD delivers domain events to saga handlers via a live, after-commit Spring-event listener. For a full description of that path, see Overview.

When your aggregates are event-sourced, you can opt in to an ordered inbound path. In this mode a dedicated consumer reads events directly from the event store in log order, so each saga type processes events in the same sequence in which they were appended — regardless of how many application nodes are running.

Enabling ordered inbound

Add the spring-ddd-starter-eventsourcing-saga dependency to your project. This starter activates the ordered inbound bridge when an EventStore bean is present in the application context:

// build.gradle.kts
dependencies {
    implementation("de.dwittkoetter:spring-ddd-starter-eventsourcing-saga:0.0.1-SNAPSHOT")
}

On startup, SagaEventSourcingBridgeAutoConfiguration (in the spring-ddd-starter-eventsourcing-saga starter; the spring-ddd-saga-bridge library hosts the underlying inbound wiring) creates a SagaInboundConsumerManager — one ordered consumer per registered saga type. No code changes to your saga class are required.

Mechanics

Ordered consumer and checkpoint

For each saga type, the framework maintains a durable checkpoint in the SAGA_INBOUND_CHECKPOINT table. The consumer polls an OrderedSource (backed by the event store) for positions after the stored checkpoint, fetching up to spring.ddd.saga.inbound.polling.batch-size positions per pass.

Events are processed serially — only one record at a time per saga type. The checkpoint advances only after the handler completes durably; if the application restarts before that point, the record is re-delivered. This gives at-least-once delivery semantics, consistent with the live path. Commands emitted through SagaCommands during the handler follow the same rule: they are buffered and dispatched only once the checkpoint advance commits, so a re-delivered record can re-dispatch a command — see Emitting commands.

A single-active lease (also stored in SAGA_INBOUND_CHECKPOINT) ensures that exactly one node drives the consumer per saga type in a multi-node deployment.

Saga ordered inbound: events are delivered to a saga type in event-log order; the checkpoint advances only after a handler succeeds
Figure 1. Ordered inbound: events are handled in order, and the checkpoint only advances on success

Skip-set routing

When the ordered inbound is active, Spring DDD builds a skip-set: the union of all event types published by your @EventSourced aggregates. Events in the skip-set bypass the live Spring-event listener and arrive exclusively via the ordered path, preventing double-delivery.

Non-ES event types (from non-event-sourced sources) continue to arrive on the live path as before.

A saga that handles a mix of event-sourced and non-event-sourced event types cannot be reset via SagaInboundReset — the framework fails fast on that operation, because the non-ES events are absent from the ordered stream and cannot be reliably replayed. If you need to mix event sources, keep the saga on the live path only.

Liveness backstop

The consumer waits up to spring.ddd.saga.inbound.dispatch-timeout (default 60 s) for a single record’s unit of work to complete on the shared keyed executor. If that bound is exceeded, the pass is aborted, the checkpoint stays unadvanced, and the record is retried on the next poll cycle. This prevents a permanently-wedged handler from blocking the consumer indefinitely.

Set dispatch-timeout comfortably above the slowest expected handler-plus-transaction time for your application.

Recovering from a failed handler

If a @SagaEventHandler throws while processing an ordered event, the pass is aborted and the checkpoint is not advanced — the event is retried on the next poll. The saga type stalls at that position, with later events blocked behind it (pos 43 in the diagram above), until the handler succeeds. There is no automatic skip: ordered delivery will not move past an event it could not process.

To get the saga moving again:

  1. Fix the cause and let it retry. Most failures are a handler bug or bad input. Deploy the fix (or correct the data); the next poll re-processes the stalled event — at-least-once delivery means it is still queued — and the consumer drains the backlog. No manual trigger is required.

  2. Skip the event deliberately. If an event is genuinely unprocessable and its effect can be abandoned, move the checkpoint past it with SagaInboundReset (see Resetting a checkpoint); the consumer resumes from the next position.

A handler that hangs rather than throws is handled by the dispatch-timeout backstop above — the pass is aborted and retried instead of blocking the consumer forever.

On the live path (non-event-sourced events, no ordered inbound) a failing handler is logged and its event publication is marked failed rather than retried in place. Recovery there is resubmitting the incomplete publication through Spring Modulith’s persistent event-publication registry — see Spring Modulith Integration.

Resetting a checkpoint

The SagaInboundReset SPI allows you to rewind a saga type’s checkpoint to an earlier position and trigger re-processing without restarting the application. Spring DDD registers a default SagaInboundReset bean when the consumer manager is active.

Calling SagaInboundReset on a saga type that mixes event-sourced and non-event-sourced event types fails fast. See the skip-set routing note above.

Live-path durability

Sagas on the default live path (no ordered inbound) get their own durability from Spring Modulith’s persistent publications — covered in Overview. This is independent of the ordered path’s durable checkpoint described above.

Key configuration properties

The following properties tune the ordered inbound consumer. For the full list, see Configuration properties.

Property Default Description

spring.ddd.saga.inbound.enabled

true

Master switch for the ordered inbound bridge. The bridge also gates on an EventStore bean being present.

spring.ddd.saga.inbound.polling.interval

1s

How often each consumer wakes to check for new positions.

spring.ddd.saga.inbound.lease-ttl

1m

How long an acquired inbound lease is held. Must exceed polling.interval.

spring.ddd.saga.inbound.polling.batch-size

100

Maximum positions fetched per pass.

spring.ddd.saga.inbound.dispatch-timeout

60s

Liveness backstop: maximum time to wait for a single record’s handler to complete before aborting the pass and retrying on the next poll.

spring.ddd.saga.inbound.schema.auto

true

Whether the SAGA_INBOUND_CHECKPOINT table DDL is applied at startup.

The OrderedSource interface is the seam between the consumer and the event store. A different implementation of that port could back the consumer with a durable message stream instead of polling the event store directly — but no such implementation is included in Spring DDD today.

Example: TransferSaga on the ordered path

The TransferSaga described in Writing a saga requires no changes to benefit from ordered delivery — simply add the spring-ddd-starter-eventsourcing-saga dependency. If BankAccount is declared with @EventSourced, its TransferRequested, DebitConfirmed, CreditConfirmed, and TransferFailed events are added to the skip-set automatically, and the TransferSaga handlers receive them in event-log order:

@EventSourced (1)
@Aggregate
class BankAccount { /* ... */ }

@Saga(type = "transfer-saga") (2)
class TransferSaga {

    @SagaEventHandler(associationProperty = "transferId")
    @SagaStart
    fun on(event: TransferRequested, commands: SagaCommands) { /* ... */ }

    @SagaEventHandler(associationProperty = "transferId")
    fun on(event: DebitConfirmed, commands: SagaCommands) { /* ... */ }

    @SagaEventHandler(associationProperty = "transferId")
    fun on(event: CreditConfirmed, lifecycle: SagaLifecycle) { /* ... */ }
}
1 @EventSourced on the aggregate causes all its event types to be added to the ordered-inbound skip-set.
2 The saga class is unchanged — the routing switch is entirely infrastructure-level.
  • Overview — concept, delivery model, lifecycle, starters.

  • Writing a saga@Saga, @SagaEventHandler, handler parameters, worked example.

  • Deadlines & timeouts — scheduling and cancelling deadlines from within a handler.

  • Storage & tables@SagaTable, JSON persistence, schema creation.