Writing a saga
This page explains the mechanics of writing a saga. For a conceptual introduction to the delivery model and lifecycle stages, see Overview.
Declaring a saga class
Annotate a plain Kotlin class with @Saga to declare it as a process manager:
@Saga(type = "transfer-saga") (1)
class TransferSaga { (2)
var transferId: String? = null
var debitConfirmed: Boolean = false
var creditConfirmed: Boolean = false
}
| 1 | type is the stable identifier persisted in the database.
Leave it blank to default to the fully-qualified class name. |
| 2 | A saga class must be a non-data class with mutable var fields and a no-argument constructor.
Spring DDD serialises saga state to a JSON blob (via Jackson 3) and deserialises it before each
handler invocation, so every field must be JSON-round-trip-safe. |
A @Saga class is not a Spring bean.
Instances are managed by the framework, not the Spring container.
Handling events with @SagaEventHandler
Place @SagaEventHandler on any method that should respond to a domain event.
The mandatory associationProperty attribute names the event property whose runtime value identifies
which saga instance should receive the event:
@SagaEventHandler(associationProperty = "transferId") (1)
fun on(event: DebitConfirmed) {
debitConfirmed = true
}
| 1 | Spring DDD reads event.transferId by reflection, coerces it to a String via toString(),
and uses that string as the saga id.
A null correlation value fails loud at runtime — association properties must not be null. |
Starting a saga with @SagaStart
Add @SagaStart to the event handler that opens a new saga instance.
When an event arrives and no instance exists for the correlation value, a new one is created;
if an instance already exists, the event is delivered to it (idempotent toward redelivery and races):
@SagaEventHandler(associationProperty = "transferId")
@SagaStart (1)
fun on(event: TransferRequested, commands: SagaCommands) {
this.transferId = event.transferId
commands.send(DebitAccount(accountId = event.sourceAccountId, amount = event.amount))
}
| 1 | The combination of @SagaStart and @SagaEventHandler on the same method is the standard pattern.
@SagaStart has no effect when the instance already exists. |
Ending a saga with @SagaEnd
Add @SagaEnd to the terminal event handler.
After the method returns, the instance is marked completed (tombstoned).
Late re-deliveries of events to a completed saga are silently skipped — the instance is not resurrected:
@SagaEventHandler(associationProperty = "transferId")
@SagaEnd (1)
fun on(event: TransferCompleted) {
// final state update if needed
}
| 1 | @SagaEnd may also be placed on a
@SagaDeadlineHandler to complete the saga when a timeout fires. |
Conditional completion with SagaLifecycle
For sagas that complete only when certain conditions are met, inject SagaLifecycle as a handler
parameter and call lifecycle.end() explicitly instead of using @SagaEnd:
@SagaEventHandler(associationProperty = "transferId")
fun on(event: CreditConfirmed, lifecycle: SagaLifecycle) { (1)
creditConfirmed = true
if (debitConfirmed && creditConfirmed) {
lifecycle.end() (2)
}
}
| 1 | SagaLifecycle is injected by the framework per invocation — no field injection needed. |
| 2 | Calling lifecycle.end() completes the instance after the handler returns,
exactly as @SagaEnd would. |
Emitting commands
A saga expresses its side effects as commands, sent through the SagaCommands handle rather than
by calling out to other components directly.
On the ordered/catch-up path, commands emitted during a handler invocation are dispatched only after
the saga’s state has been persisted; dispatch is at-least-once, so a retried invocation can
re-send a command that was already delivered — command handlers must therefore be idempotent.
Emitting commands from a saga requires the spring-ddd-starter-saga-cqrs starter on the classpath,
which bridges SagaCommands to the CQRS command gateway.
Handler parameter injection
The first parameter of any @SagaEventHandler method must be the event payload.
After the event, you may declare any of the following in any order:
| Parameter type | What you get |
|---|---|
|
Per-invocation handle to call |
|
Per-invocation handle to schedule or cancel a named deadline. See Deadlines & timeouts. |
|
A value extracted from the message metadata attached to the event. See Message metadata. |
|
A value resolved from Spring configuration — properties or SpEL — exactly like Spring’s |
|
Handle for emitting commands from the saga. On the ordered/catch-up path, emitted commands are dispatched after the saga’s state is persisted. See Emitting commands. |
Any other bean type |
Resolved from the Spring application context at invocation time. |
Designing saga state
Because saga state is serialised to JSON between invocations, keep these rules in mind:
-
Non-
dataclass — the saga class must not be a Kotlindataclass; the framework deserialises by calling the no-arg constructor and settingvarfields, not by callingcopy(). -
varfields — use mutablevarfields. Immutablevalfields cannot be set after deserialisation. -
No-arg constructor — the class must have a no-argument constructor (Kotlin provides one automatically when all fields have defaults).
-
JSON-round-trip-safe types — use types that Jackson 3 can serialise and deserialise without additional configuration:
String,Int,Long,Boolean,BigDecimal, enums, andjava.timetypes such asInstantare all safe. Avoid framework internal types or Spring beans as state fields.
|
To support a type the default serialisation can’t handle, provide your own
|
Idempotency
Delivery is at-least-once — on application restart, Spring Modulith resubmits any event publications that did not complete before the process stopped. A handler may therefore receive the same event more than once.
Design handlers so that processing the same event twice produces the same outcome.
A boolean flag (such as debitConfirmed in the example below) is a simple guard; for commands
sent to external systems, include an idempotency key derived from the saga id and the event.
For more detail on the delivery model see Overview.
Worked example: TransferSaga
The following example coordinates a funds transfer across two bank accounts.
The saga starts on TransferRequested, issues a debit command, waits for confirmation, issues a credit
command, and completes when both legs are confirmed.
@Saga(type = "transfer-saga")
class TransferSaga {
var transferId: String? = null
var sourceAccountId: String? = null
var targetAccountId: String? = null
var amount: BigDecimal = BigDecimal.ZERO
var debitConfirmed: Boolean = false
var creditConfirmed: Boolean = false
@SagaEventHandler(associationProperty = "transferId")
@SagaStart
fun on(event: TransferRequested, commands: SagaCommands) { (1)
this.transferId = event.transferId
this.sourceAccountId = event.sourceAccountId
this.targetAccountId = event.targetAccountId
this.amount = event.amount
commands.send(DebitAccount(accountId = event.sourceAccountId, amount = event.amount)) (2)
}
@SagaEventHandler(associationProperty = "transferId")
fun on(event: DebitConfirmed, commands: SagaCommands) { (3)
debitConfirmed = true
commands.send(CreditAccount(accountId = targetAccountId!!, amount = amount))
}
@SagaEventHandler(associationProperty = "transferId")
fun on(event: CreditConfirmed, lifecycle: SagaLifecycle) { (4)
creditConfirmed = true
if (debitConfirmed && creditConfirmed) {
lifecycle.end()
}
}
@SagaEventHandler(associationProperty = "transferId")
@SagaEnd
fun on(event: TransferFailed) { (5)
// Terminal — completed after the handler returns; no explicit lifecycle.end() needed.
}
}
| 1 | @SagaStart creates the instance on the first TransferRequested with this transfer id. |
| 2 | SagaCommands is injected as a handler parameter.
On the ordered/catch-up path the debit command is dispatched after this handler’s saga state
change is persisted; see Emitting commands. |
| 3 | On confirmation of the debit, the saga issues the credit command. |
| 4 | Conditional completion: the saga ends only when both legs are confirmed. |
| 5 | Any failure path uses @SagaEnd to tombstone the instance; late redeliveries are skipped. |
|
To add a payment timeout that compensates if the transfer stalls, see Deadlines & timeouts. |
Related pages
-
Overview — concept, delivery model, lifecycle, starters.
-
Deadlines & timeouts — scheduling and cancelling deadlines from within a handler.
-
Storage & tables —
@SagaTable, JSON persistence, schema creation. -
Ordered event delivery — opt-in ordered inbound from an event-sourced store.