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.
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
|
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:
-
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.
-
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 |
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 |
|---|---|---|
|
|
Master switch for the ordered inbound bridge.
The bridge also gates on an |
|
|
How often each consumer wakes to check for new positions. |
|
|
How long an acquired inbound lease is held.
Must exceed |
|
|
Maximum positions fetched per pass. |
|
|
Liveness backstop: maximum time to wait for a single record’s handler to complete before aborting the pass and retrying on the next poll. |
|
|
Whether the |
|
The |
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. |
Related pages
-
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.