Storage & tables

Spring DDD persists every saga instance as a JSON blob in a relational table. The storage layer is accessed through a single SagaStore port, so you can swap or extend the persistence strategy without touching any saga class.

For a conceptual overview of the delivery model, see Overview. For writing saga classes, see Writing a saga.

The SagaStore SPI

SagaStore is the persistence port for saga instances, keyed by the pair (sagaType, sagaId):

interface SagaStore {

    fun <T : Any> find(sagaType: String, sagaId: String, type: KClass<T>): LoadedSaga<T>?

    fun save(sagaType: String, sagaId: String, instance: Any,
             expectedVersion: Long?, completed: Boolean)

    fun delete(sagaType: String, sagaId: String)
}

find returns null if no instance exists. save is a dual-mode operation: when expectedVersion is null it performs a fresh insert; when it is non-null it performs a version-guarded update. delete is a no-op when the instance is absent.

JdbcJsonSagaStore: the built-in implementation

spring-ddd-starter-saga-jdbc wires JdbcJsonSagaStore, which serializes each saga instance to a JSON blob using Jackson 3 (tools.jackson.databind.json.JsonMapper).

Table 1. Instance table columns
Column Description

saga_type

The saga type string (from @Saga(type = "…")). Together with saga_id this forms the primary key.

saga_id

The correlation value, reduced to a String by the association extractor.

state_json

The full saga instance serialized as a JSON blob.

version

A monotonically increasing counter used for optimistic locking. New instances start at 0.

completed_at

Completion timestamp; set when the instance is tombstoned via @SagaEnd or SagaLifecycle.end(), and null while the saga is active. Drives retention.

Optimistic locking

Two concurrent threads that both attempt to start a saga for the same (sagaType, sagaId) pair will both try to insert a row at version = 0. The one that loses the primary-key race receives an OptimisticLockingFailureException.

All subsequent saves use a version-guarded UPDATE:

UPDATE {table}
   SET state_json   = :stateJson,
       completed_at = :completedAt,
       version      = version + 1
 WHERE saga_type = :sagaType
   AND saga_id   = :sagaId
   AND version   = :expectedVersion

If the UPDATE touches zero rows — because another thread already incremented the version — the store raises OptimisticLockingFailureException. The saga engine then retries the full load-handle-save cycle.

Routing saga instances to tables with @SagaTable

By default all saga instances share a single table. Add @SagaTable (from spring-ddd-saga-jdbc) to a saga class to route its instances to a dedicated table.

Table 2. Table routing rules
Declaration Resolved table

(absent)

spring.ddd.saga.table-name (default SAGA_INSTANCE)

@SagaTable (blank value)

Simple class name in SCREAMING_SNAKE_CASE — e.g. OrderSagaORDER_SAGA

@SagaTable("TRANSFER")

TRANSFER

Multiple saga types may share a table (for example to group them by bounded context). The saga_type column always disambiguates rows within a shared table.

@SagaTable must be declared directly on the saga class; it is not inherited. Placing it on a class that is not annotated with @Saga is a startup configuration error.

Banking example

A funds-transfer saga can route its instances to a dedicated table in two ways:

@Saga(type = "transfer-saga")
@SagaTable                        (1)
class TransferSaga { /* … */ }
1 Blank value — resolves to TRANSFER_SAGA.
@Saga(type = "transfer-saga")
@SagaTable("FUNDS_TRANSFER")      (1)
class TransferSaga { /* … */ }
1 Explicit name — resolves to FUNDS_TRANSFER.

Automatic table creation

SagaTableInitializer applies the instance-table DDL at startup when spring.ddd.saga.schema.auto is true (the default). It creates every table registered in the SagaTableRegistry — the shared default table plus every @SagaTable target — using per-dialect CREATE TABLE templates.

Spring DDD ships templates for six dialects: Postgres, MySQL, MariaDB, H2, HSQLDB, and SQL Server.

To own the DDL yourself (Flyway, Liquibase, or manual migration), set:

spring.ddd.saga.schema.auto=false

Separate flags for deadline and inbound checkpoint tables

The deadline tables (SAGA_DEADLINE and SAGA_DEADLINE_LOCK) and the ordered-inbound checkpoint table (SAGA_INBOUND_CHECKPOINT) have their own independent flags:

Property Default Covers

spring.ddd.saga.schema.auto

true

Instance tables (shared SAGA_INSTANCE + all @SagaTable targets)

spring.ddd.saga.deadline.schema.auto

true

SAGA_DEADLINE and SAGA_DEADLINE_LOCK

spring.ddd.saga.inbound.schema.auto

true

SAGA_INBOUND_CHECKPOINT

Setting one flag to false does not affect the others. Use this to hand off deadline or inbound tables to your migration tool while still letting Spring DDD create the instance tables, or vice versa.

For the per-dialect DDL, see Database table schemas. For the full list of configurable table names and related properties, see Configuration properties.