SAGAs
A saga (also called a process manager) coordinates a long-running, multi-step business process that spans several aggregates and transactions. Rather than locking all participants inside a single distributed transaction, a saga reacts to domain events, issues follow-up commands, and — when needed — schedules timeouts, driving the process to completion or compensation one step at a time.
Banking example
Consider a funds transfer between two accounts.
The transfer must debit one account and credit another, but the two accounts live in separate aggregates with separate transactions.
A TransferSaga listens for a TransferRequested event, dispatches a DebitAccount command, and — once the debit confirms — dispatches a CreditAccount command.
If the credit fails or a deadline fires, the saga can compensate by reversing the debit.
No two-phase commit required.
The Spring DDD model
Annotate a plain Kotlin class with @Saga to declare it as a process manager:
@Saga(type = "transfer-saga") (1)
class TransferSaga {
var transferId: String? = null (2)
var state: TransferState = TransferState.PENDING
@SagaEventHandler(associationProperty = "transferId") (3)
@SagaStart
fun on(event: TransferRequested) {
this.transferId = event.transferId
// dispatch debit command...
}
}
| 1 | type is the stable identifier stored in the database; leave blank to default to the fully-qualified class name. |
| 2 | Saga state is persisted as a JSON blob — the class must be a non-data class with mutable var fields and a no-argument constructor so it round-trips cleanly through JSON. |
| 3 | associationProperty names the event property whose runtime value is the correlation id; Spring DDD resolves the saga instance (or creates one, if @SagaStart is present) for that value. |
A @Saga class is not a Spring bean — instances are managed by the framework, not the Spring container.
Each instance is identified by a (sagaType, sagaId) pair, where sagaId is the association value coerced to a string via toString().
Sagas are independent of event sourcing and CQRS. They react to any Spring domain event, regardless of how that event was published.
Delivery model
Spring DDD builds saga delivery on top of Spring’s transactional application-event mechanism.
Each @SagaEventHandler method is backed by a TransactionalApplicationListener that fires after the publishing transaction commits.
The handler then executes in a fresh REQUIRES_NEW transaction on a keyed executor thread.
Key guarantees:
-
Serialized per instance — Spring DDD routes all events for a given
(sagaType, sagaId)through the same serial slot on the keyed executor. A single saga instance never processes two events concurrently. -
At-most-once by default — Spring’s transactional listener fires after commit but has no built-in redelivery, so a crash between the commit and the handler completing drops that delivery. Durable at-least-once delivery — incomplete publications resubmitted after a restart — is available when Spring Modulith’s persistent event-publication registry is configured; make your handlers idempotent if you rely on it. See Spring Modulith Integration.
-
Any Spring domain event — delivery does not require event sourcing; the same mechanism works for plain JPA aggregates or any other Spring event publisher.
See Ordered event delivery for the opt-in ordered inbound path when consuming events from an event-sourced aggregate store.
Lifecycle
A saga instance moves through three stages:
-
Created — a
@SagaStartevent handler creates a new instance (or routes to an existing one if a duplicate arrives). -
Active — subsequent
@SagaEventHandlerevents update the instance state; commands and deadlines may be issued from within these handlers. -
Completed — a
@SagaEndannotation on the terminal handler, or an explicit call to the injectedSagaLifecycle.end(), tombstones the instance. Late re-deliveries of events to a completed saga are silently skipped — the instance is not resurrected.
For full details on wiring start, end, and conditional completion, see Writing a saga.
Starters and dependencies
The minimal dependency for a runnable saga is:
// build.gradle.kts
implementation("de.dwittkoetter:spring-ddd-starter-saga-jdbc:0.0.1-SNAPSHOT")
To consume events from an event-sourced aggregate store in strict delivery order, add the saga
bridge together with an event-store persistence layer — the bridge only activates when an
EventStore bean is present, which the persistence starter provides:
implementation("de.dwittkoetter:spring-ddd-starter-eventsourcing-jdbc:0.0.1-SNAPSHOT") // the JDBC event store
implementation("de.dwittkoetter:spring-ddd-starter-eventsourcing-saga:0.0.1-SNAPSHOT") // ES events → saga ordered inbound
Both universal bundles — spring-ddd-starter-jdbc and spring-ddd-starter-jpa — already include the saga runtime and an event store; no extra dependency is needed when using them.
For observability, add spring-ddd-starter-saga-actuator — see Actuators.
For guidance on choosing the right starter combination, see Choosing your starters.
In this section
-
Writing a saga —
@Saga/@SagaEventHandler/@SagaStart/@SagaEnd, state design, handler parameters, idempotency. -
Deadlines & timeouts — scheduling and cancelling deadlines,
@SagaDeadlineHandler, durable poller. -
Storage & tables —
@SagaTable, JSON-blob persistence, schema auto-creation, optimistic locking. -
Ordered event delivery — opt-in ordered inbound from an event-sourced aggregate store, skip-set, reset.