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

SagaLifecycle

Per-invocation handle to call end() for conditional completion.

SagaDeadlines

Per-invocation handle to schedule or cancel a named deadline. See Deadlines & timeouts.

@MetadataValue("key") String

A value extracted from the message metadata attached to the event. See Message metadata.

@Value("…​")

A value resolved from Spring configuration — properties or SpEL — exactly like Spring’s @Value elsewhere.

SagaCommands

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-data class — the saga class must not be a Kotlin data class; the framework deserialises by calling the no-arg constructor and setting var fields, not by calling copy().

  • var fields — use mutable var fields. Immutable val fields 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, and java.time types such as Instant are 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 sagaJsonMapper bean. The framework’s mapper backs off when one is present, so you can register the modules or custom serializers your saga state needs:

@Bean
fun sagaJsonMapper(): JsonMapper =
    JsonMapper.builder()
        .addModule(kotlinModule())
        .addModule(MyCustomTypesModule())
        .build()

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.