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.
A command is sent while the handler runs, before the saga’s state and the consumer’s checkpoint commit, so an apply that fails and is retried sends it again: command handlers must therefore be idempotent.
See One consumer per saga type.
Emitting commands from a saga requires a CQRS starter on the classpath, for example spring-ddd-starter-cqrs-jpa; SagaCommands then sends through 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. A command is sent while the handler runs, before the saga’s state commits. 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: an apply that fails, or that a restart or a handover between instances interrupts before it commits, is applied again. 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 Event delivery.
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.
The debit command is sent while this handler runs, before its state change commits, so its handler must tolerate a duplicate; 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. -
Event delivery — the topics a saga reads, one consumer per saga type, delivery guarantees.