Database table schemas
Spring DDD’s JDBC stores create their tables automatically on startup. Each subsystem has a schema-creation switch (see Schema creation control); disable it to manage the schema with Flyway or Liquibase and apply the DDL listed here. All stores support six dialects: H2, HSQLDB, MySQL, MariaDB, PostgreSQL, and SQL Server.
In the DDL blocks, ${event_table} and ${snapshot_table} are resolved by the framework at startup to the actual per-aggregate table names.
{tableName} is resolved to the configured or default table name.
Wherever MariaDB reuses the MySQL DDL verbatim, this is noted in the collapsible block instead of duplicating the statement.
|
Event store
Module: spring-ddd-eventsourcing-jdbc.
Schema creation: spring.ddd.eventsourcing.schema.auto (default true); initializer: EventStoreSchemaInitializer.
The event store creates one event table per @EventSourced aggregate and, when snapshots are enabled, one snapshot table per aggregate.
Tables are created only when at least one @EventSourced aggregate is detected; a projection-only application creates nothing.
Event-store table
Stores the ordered event stream for a single aggregate type.
The table name is derived in SCREAMING_SNAKE_CASE from the aggregate class name, or overridden with @EventTable.
The representative name account_event is used in the DDL below.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) |
Primary key. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB, MySQL/MariaDB, and SQL Server. |
|
VARCHAR(255) |
Not null. Serialised aggregate identity. |
|
VARCHAR(255) |
Not null. Fully-qualified aggregate class name. |
|
BIGINT |
Not null. Per-aggregate monotonic sequence starting at 0. Unique composite constraint with |
|
VARCHAR(255) |
Not null. Fully-qualified event class name. |
|
INT |
Not null, default 0. Schema revision for upcasting. |
|
CLOB | JSONB | LONGTEXT | NVARCHAR(MAX) |
Not null. JSON event payload. JSONB on PostgreSQL; CLOB on H2/HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
CLOB | JSONB | LONGTEXT | NVARCHAR(MAX) |
Nullable. Optional event metadata. Same type as |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null, defaults to current timestamp. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. |
|
SMALLINT |
Not null. Stable FNV-1a bucket of the aggregate id in |
|
BIGINT |
Not null. Total-order position drawn from the global sequence or table. Indexed, and indexed again as the second column of |
|
ROWVERSION |
SQL Server only. Auto-generated per row; never named in an |
Override: @EventTable(name = "…") on the aggregate class.
|
SQL Server custom event tables must include the |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS account_event (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload JSONB NOT NULL,
metadata JSONB,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL DEFAULT nextval('EVENT_GLOBAL_POSITION'),
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- H2
CREATE TABLE IF NOT EXISTS account_event (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL DEFAULT NEXT VALUE FOR EVENT_GLOBAL_POSITION,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- HSQLDB
CREATE TABLE IF NOT EXISTS account_event (
id VARCHAR(36) PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT DEFAULT 0 NOT NULL,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
partition_key SMALLINT NOT NULL,
global_position BIGINT GENERATED BY DEFAULT AS SEQUENCE EVENT_GLOBAL_POSITION,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS account_event (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload LONGTEXT NOT NULL,
metadata LONGTEXT,
occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
UNIQUE KEY uq_account_event_aggregate_sequence (aggregate_id, sequence_no),
UNIQUE KEY uq_account_event_global (global_position),
INDEX idx_account_event_aggregate (aggregate_id, aggregate_type, sequence_no),
INDEX idx_account_event_partition (partition_key, global_position)
);
-- SQL Server
IF OBJECT_ID(N'account_event', N'U') IS NULL
CREATE TABLE account_event (
id VARCHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload NVARCHAR(MAX) NOT NULL,
metadata NVARCHAR(MAX),
occurred_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL DEFAULT (NEXT VALUE FOR EVENT_GLOBAL_POSITION),
row_version ROWVERSION NOT NULL,
CONSTRAINT uq_account_event_sequence UNIQUE (aggregate_id, sequence_no)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_aggregate'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_global'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_global
ON account_event (global_position);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_partition'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_partition
ON account_event (partition_key, global_position);
EVENT_GLOBAL_POSITION
Global position counter shared by all per-aggregate event tables. PostgreSQL, H2, HSQLDB, and SQL Server use a native sequence. MySQL and MariaDB use a single-row table instead — MySQL has no native sequence, and MariaDB’s cannot be used soundly for the concurrent frontier (see Choosing a database).
| Object | Type | Notes |
|---|---|---|
|
SEQUENCE or TABLE |
H2, HSQLDB, PostgreSQL, SQL Server: |
DDL — all dialects
-- PostgreSQL
CREATE SEQUENCE IF NOT EXISTS EVENT_GLOBAL_POSITION START WITH 1 CACHE 1;
-- H2
CREATE SEQUENCE IF NOT EXISTS EVENT_GLOBAL_POSITION START WITH 1;
-- HSQLDB
CREATE SEQUENCE EVENT_GLOBAL_POSITION START WITH 1;
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS EVENT_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
INSERT INTO EVENT_GLOBAL_POSITION (id, val) VALUES (1, 0)
ON DUPLICATE KEY UPDATE id = id;
-- SQL Server
IF NOT EXISTS (SELECT 1 FROM sys.sequences WHERE name = 'EVENT_GLOBAL_POSITION')
CREATE SEQUENCE EVENT_GLOBAL_POSITION AS BIGINT START WITH 1 INCREMENT BY 1 NO CACHE;
The CACHE 1 (PostgreSQL) and NO CACHE (SQL Server) clauses are load-bearing, not tuning: the
concurrent read frontier requires sequence values to be handed out in allocation order, which a larger
cache breaks.
The framework owns this sequence and enforces the clause at schema init (an idempotent ALTER SEQUENCE),
so it also corrects a sequence left over with a wider cache.
You never configure it.
PARTITION_METADATA
Singleton table (always one row with id = 1) that persists and guards the fixed partition count N for this event store.
On first startup the row is seeded from spring.ddd.partitioning.partitions.
On every later startup the configured value is verified against the persisted value; startup fails fast if they differ, because the partition count is immutable once initialized.
| Column | Type | Notes |
|---|---|---|
|
SMALLINT |
Primary key. Always |
|
INT |
Not null. The fixed partition count |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB / MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_METADATA (
id SMALLINT NOT NULL,
partitions INT NOT NULL,
PRIMARY KEY (id)
)
-- SQL Server
IF OBJECT_ID(N'PARTITION_METADATA', N'U') IS NULL
CREATE TABLE PARTITION_METADATA (
id SMALLINT NOT NULL,
partitions INT NOT NULL,
PRIMARY KEY (id)
)
READ_MODEL_PARTITION_INDEX
Per-consumer re-partition index. Each row is a pointer that co-locates one read model’s events into a
single bucket, in total order, so a cross-aggregate partitioned projection (for example an
AccountSummaryProjection reading both AccountOpened and DepositMoney events) can consume every
event for a given account_id from one partition without cross-partition ordering coordination.
The table is consumer-scoped, not read-model-scoped: the same read model can appear under different
partition_key values for different consumers. Rows are pruned per bucket behind the drained cursor
tracked in PARTITION_SOURCE_CURSOR for the owning partition, and the index itself is built by a
single-owner indexer.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Identifies the logical consumer this index belongs to. |
|
SMALLINT |
Part of composite primary key. |
|
VARCHAR(255) |
Part of composite primary key. The canonical read-model id the event belongs to (for example the |
|
BIGINT |
Part of composite primary key. The event’s global position, giving total order within a bucket. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS READ_MODEL_PARTITION_INDEX (
consumer_name VARCHAR(255) NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
global_position BIGINT NOT NULL,
CONSTRAINT PK_READ_MODEL_PARTITION_INDEX
PRIMARY KEY (consumer_name, partition_key, global_position, read_model_id)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS READ_MODEL_PARTITION_INDEX (
consumer_name VARCHAR(255) NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
global_position BIGINT NOT NULL,
PRIMARY KEY (consumer_name, partition_key, global_position, read_model_id)
);
-- SQL Server
IF OBJECT_ID(N'READ_MODEL_PARTITION_INDEX', N'U') IS NULL
CREATE TABLE READ_MODEL_PARTITION_INDEX (
consumer_name VARCHAR(255) NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
global_position BIGINT NOT NULL,
CONSTRAINT PK_READ_MODEL_PARTITION_INDEX
PRIMARY KEY (consumer_name, partition_key, global_position, read_model_id)
);
READ_MODEL_PARTITION_INDEX is created alongside PARTITION_METADATA: per event store, provisioned either by
auto-DDL (spring.ddd.eventsourcing.schema.auto) or by your own migration tool — see
Managing the schema with a migration tool. PARTITION_MEMBER, PARTITION_OWNERSHIP,
and PARTITION_SOURCE_CURSOR belong to a separate substrate with its own switch — see
Partition coordination substrate below.
Snapshot table
Stores periodic aggregate snapshots to avoid replaying the full event stream on load.
Created only when spring.ddd.eventsourcing.snapshot.enabled is true (default).
The table name defaults to <event_table>_SNAPSHOTS, or is overridden with @EventTable(snapshots = "…").
The representative name account_event_snapshots is used below.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) |
Primary key. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB, MySQL/MariaDB, and SQL Server. |
|
VARCHAR(255) |
Not null. Serialised aggregate identity. |
|
VARCHAR(255) |
Not null. Fully-qualified aggregate class name. |
|
BIGINT |
Not null. Event sequence number at the time the snapshot was taken. |
|
INT |
Not null, default 1. Snapshot schema revision. |
|
CLOB | JSONB | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised aggregate state. JSONB on PostgreSQL; CLOB on H2/HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null, defaults to current timestamp. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. |
Override: @EventTable(snapshots = "…") on the aggregate class.
Schema gate: spring.ddd.eventsourcing.snapshot.enabled (default true).
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- H2
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload CLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- HSQLDB
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id VARCHAR(36) PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT DEFAULT 1 NOT NULL,
payload CLOB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload LONGTEXT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_account_event_snapshots_sequence (aggregate_id, sequence_no),
INDEX idx_account_event_snapshots_lookup (aggregate_id, aggregate_type, snapshot_version, sequence_no)
);
-- SQL Server
IF OBJECT_ID(N'account_event_snapshots', N'U') IS NULL
CREATE TABLE account_event_snapshots (
id VARCHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload NVARCHAR(MAX) NOT NULL,
created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT uq_account_event_snapshots_sequence UNIQUE (aggregate_id, sequence_no)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_snapshots_lookup'
AND object_id = OBJECT_ID(N'account_event_snapshots'))
CREATE INDEX idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
Partition coordination substrate
Module: spring-ddd-messaging-jdbc.
Schema creation: spring.ddd.partitioning.schema.auto (default true); initializer: PartitionCoordinationSchemaInitializer.
Independent of the event store: these tables are created in any application using projections or
partitioned consumption, not only in an event-sourced one. All three JDBC starters — CQRS JSON, CQRS
JPA, and event sourcing — depend on spring-ddd-starter-messaging-jdbc, which activates this substrate.
PARTITION_MEMBER
Registry of the consumer instances currently participating in partitioned consumption. Created by the
partition coordination substrate (spring-ddd-messaging-jdbc), independent of the event store, so it
exists in any application using projections or partitioned consumption, with or without an event store.
Each instance renews its own row on spring.ddd.partitioning.instance.heartbeat-interval; a row whose
last_heartbeat is older than spring.ddd.partitioning.instance.stale-timeout (measured by the
database clock) is treated as a dead instance and its owned partitions become eligible for takeover.
Holds rows only for a consumer sharded across buckets. A consumer that declares a single partition
(partitions == 1 — the gate is the declaration, not how many buckets it happens to own) claims it
directly on the rebalance cadence instead of heartbeating and rebalancing across a live-member set, so this
table stays empty for it.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Identifies the logical consumer (for example a projection or saga inbound handler) whose instances share this membership set. |
|
VARCHAR(255) |
Part of composite primary key. The reporting instance’s id, resolved from |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME2 |
Not null, defaults to current timestamp. Renewed on every heartbeat. TIMESTAMPTZ on PostgreSQL; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB/MySQL/MariaDB. |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS PARTITION_MEMBER (
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, instance_id)
);
-- H2 / MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_MEMBER (
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
last_heartbeat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (consumer_name, instance_id)
);
-- HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_MEMBER (
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (consumer_name, instance_id)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_MEMBER', N'U') IS NULL
CREATE TABLE PARTITION_MEMBER (
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
last_heartbeat DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
PRIMARY KEY (consumer_name, instance_id)
);
PARTITION_OWNERSHIP
Per-partition ownership lease and feed sequence counters for a consumer. Created by the partition
coordination substrate (spring-ddd-messaging-jdbc), independent of the event store, so it exists in
any application using projections or partitioned consumption. Ownership is fenced: a row is only valid
while lease_expiry is in the future, and generation is bumped on every claim so a consumer can
detect and reject work performed under a since-superseded claim. next_change_seq and
published_change_seq track this partition’s output position in the partitioned change feed that
downstream cascades consume.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Identifies the logical consumer this ownership row belongs to. |
|
SMALLINT |
Part of composite primary key. The partition number, in |
|
VARCHAR(255) |
Nullable. The |
|
BIGINT |
Not null, default 0. Incremented every time the partition is claimed; fences stale work from a previous owner. |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME2 |
Nullable. When the current owner’s lease expires (database clock), derived from |
|
BIGINT |
Not null, default 1. Mints the next feed |
|
BIGINT |
Not null, default 0. High-water mark of how far this partition’s feed has been durably published. |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_name VARCHAR(255) NOT NULL,
partition SMALLINT NOT NULL,
owner VARCHAR(255),
generation BIGINT NOT NULL DEFAULT 0,
lease_expiry TIMESTAMPTZ,
next_change_seq BIGINT NOT NULL DEFAULT 1,
published_change_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, partition)
);
-- H2
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_name VARCHAR(255) NOT NULL,
partition SMALLINT NOT NULL,
owner VARCHAR(255),
generation BIGINT NOT NULL DEFAULT 0,
lease_expiry TIMESTAMP,
next_change_seq BIGINT NOT NULL DEFAULT 1,
published_change_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, partition)
);
-- HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_name VARCHAR(255) NOT NULL,
partition SMALLINT NOT NULL,
owner VARCHAR(255),
generation BIGINT DEFAULT 0 NOT NULL,
lease_expiry TIMESTAMP,
next_change_seq BIGINT DEFAULT 1 NOT NULL,
published_change_seq BIGINT DEFAULT 0 NOT NULL,
PRIMARY KEY (consumer_name, partition)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_name VARCHAR(255) NOT NULL,
`partition` SMALLINT NOT NULL,
owner VARCHAR(255),
generation BIGINT NOT NULL DEFAULT 0,
lease_expiry TIMESTAMP(3) NULL,
next_change_seq BIGINT NOT NULL DEFAULT 1,
published_change_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, `partition`)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_OWNERSHIP', N'U') IS NULL
CREATE TABLE PARTITION_OWNERSHIP (
consumer_name VARCHAR(255) NOT NULL,
[partition] SMALLINT NOT NULL,
owner VARCHAR(255),
generation BIGINT NOT NULL DEFAULT 0,
lease_expiry DATETIME2,
next_change_seq BIGINT NOT NULL DEFAULT 1,
published_change_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, [partition])
);
PARTITION_SOURCE_CURSOR
Per-(consumer, partition, source) durable read cursor: how far each input source has been drained
into a partition. Created by the partition coordination substrate (spring-ddd-messaging-jdbc),
independent of the event store, so it exists in any application using projections or partitioned
consumption. A source is either the event store itself (source_id = 'event-store') or an
upstream read-model feed edge (an upstream projection’s storage name). Tracking sources independently
lets a partition consume from several inputs — for example an event store and an upstream cascade
projection — without their cursors colliding.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. The projection/consumer name. |
|
SMALLINT |
Part of composite primary key. The partition bucket. A reserved word on some dialects — quoted ( |
|
VARCHAR(255) |
Part of composite primary key. The input source’s identity: |
|
BIGINT |
Not null, default 0. The cursor position drained for this source: the event store’s global position for |
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS PARTITION_SOURCE_CURSOR (
consumer_name VARCHAR(255) NOT NULL,
partition SMALLINT NOT NULL,
source_id VARCHAR(255) NOT NULL,
position BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, partition, source_id)
);
-- HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_SOURCE_CURSOR (
consumer_name VARCHAR(255) NOT NULL,
partition SMALLINT NOT NULL,
source_id VARCHAR(255) NOT NULL,
position BIGINT DEFAULT 0 NOT NULL,
PRIMARY KEY (consumer_name, partition, source_id)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_SOURCE_CURSOR (
consumer_name VARCHAR(255) NOT NULL,
`partition` SMALLINT NOT NULL,
source_id VARCHAR(255) NOT NULL,
position BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, `partition`, source_id)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_SOURCE_CURSOR', N'U') IS NULL
CREATE TABLE PARTITION_SOURCE_CURSOR (
consumer_name VARCHAR(255) NOT NULL,
[partition] SMALLINT NOT NULL,
source_id VARCHAR(255) NOT NULL,
position BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_name, [partition], source_id)
);
Projection bookkeeping
Module: spring-ddd-cqrs-projection-jdbc.
Schema creation: spring.ddd.cqrs.projection.jdbc.store.table-creation (default AUTO); initializer: ProjectionStoreTableInitializer.
MariaDB uses the MySQL DDL for all three tables (no separate MariaDB resource files).
PROJECTION_METADATA
Tracks per-projection replay state, rebuild generation, and halt status.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. Fully-qualified projection class name. |
|
TIMESTAMP | DATETIME | DATETIME2 |
Nullable. When the last full replay completed. DATETIME on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
|
TIMESTAMP | DATETIME | DATETIME2 |
Nullable. Replay distributed-lock expiry. Same type as |
|
VARCHAR(255) |
Nullable. Node identifier holding the replay lock. |
|
BIGINT |
Not null, default 0. Incremented on each rebuild to invalidate stale read models. |
|
BIGINT |
Nullable. Target event position for an in-progress replay. |
|
BIGINT |
Nullable. Last checkpointed event position during an in-progress replay. |
|
TIMESTAMP | DATETIME | DATETIME2 |
Nullable. When the current replay started. Same type as |
|
BOOLEAN | BIT |
Not null, default false. Whether the projection is halted. BIT on SQL Server. |
|
BIGINT |
Nullable. Event position at which the projection halted. |
|
VARCHAR(1000) |
Nullable. Truncated error message when halted. |
|
TIMESTAMP | DATETIME | DATETIME2 |
Nullable. When the projection halted. Same type as |
|
VARCHAR(16) |
Nullable. Reason code: |
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS PROJECTION_METADATA (
projection_name VARCHAR(255) NOT NULL,
last_replayed_at TIMESTAMP NULL,
replay_locked_until TIMESTAMP NULL,
replay_owner VARCHAR(255) NULL,
rebuild_generation BIGINT NOT NULL DEFAULT 0,
replay_target_position BIGINT NULL,
replay_checkpoint_position BIGINT NULL,
replay_started_at TIMESTAMP NULL,
halted BOOLEAN NOT NULL DEFAULT FALSE,
halted_position BIGINT NULL,
halted_error VARCHAR(1000) NULL,
halted_at TIMESTAMP NULL,
halted_reason VARCHAR(16) NULL,
PRIMARY KEY (projection_name)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS PROJECTION_METADATA (
projection_name VARCHAR(255) NOT NULL,
last_replayed_at TIMESTAMP NULL,
replay_locked_until TIMESTAMP NULL,
replay_owner VARCHAR(255) NULL,
rebuild_generation BIGINT DEFAULT 0 NOT NULL,
replay_target_position BIGINT NULL,
replay_checkpoint_position BIGINT NULL,
replay_started_at TIMESTAMP NULL,
halted BOOLEAN DEFAULT FALSE NOT NULL,
halted_position BIGINT NULL,
halted_error VARCHAR(1000) NULL,
halted_at TIMESTAMP NULL,
halted_reason VARCHAR(16) NULL,
PRIMARY KEY (projection_name)
)
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PROJECTION_METADATA (
projection_name VARCHAR(255) NOT NULL,
last_replayed_at DATETIME NULL,
replay_locked_until DATETIME NULL,
replay_owner VARCHAR(255) NULL,
rebuild_generation BIGINT NOT NULL DEFAULT 0,
replay_target_position BIGINT NULL,
replay_checkpoint_position BIGINT NULL,
replay_started_at DATETIME NULL,
halted BOOLEAN NOT NULL DEFAULT FALSE,
halted_position BIGINT NULL,
halted_error VARCHAR(1000) NULL,
halted_at DATETIME NULL,
halted_reason VARCHAR(16) NULL,
PRIMARY KEY (projection_name)
)
-- SQL Server
IF OBJECT_ID(N'PROJECTION_METADATA', N'U') IS NULL
CREATE TABLE PROJECTION_METADATA (
projection_name VARCHAR(255) NOT NULL,
last_replayed_at DATETIME2 NULL,
replay_locked_until DATETIME2 NULL,
replay_owner VARCHAR(255) NULL,
rebuild_generation BIGINT NOT NULL DEFAULT 0,
replay_target_position BIGINT NULL,
replay_checkpoint_position BIGINT NULL,
replay_started_at DATETIME2 NULL,
halted BIT NOT NULL DEFAULT 0,
halted_position BIGINT NULL,
halted_error VARCHAR(1000) NULL,
halted_at DATETIME2 NULL,
halted_reason VARCHAR(16) NULL,
PRIMARY KEY (projection_name)
)
HIGH_WATER_MARK
Singleton table (always one row with id = 1) that tracks the global high-water mark used to coordinate projection consumers.
| Column | Type | Notes |
|---|---|---|
|
SMALLINT |
Primary key. Always |
|
BIGINT |
Not null, default 0. Current high-water mark position. |
|
BIGINT |
Nullable. Highest event position witnessed by any consumer. |
|
BIGINT |
Opaque transaction-visibility marker captured when the frontier first witnessed an unresolved gap
below |
|
VARCHAR(255) |
Nullable. Node identifier holding the HWM advance lease. |
|
TIMESTAMP | DATETIME | DATETIME2 |
Nullable. Lease expiry. DATETIME on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS HIGH_WATER_MARK (
id SMALLINT NOT NULL,
h BIGINT NOT NULL DEFAULT 0,
witnessed_ceiling BIGINT NULL,
blocked_horizon BIGINT NULL,
lease_owner VARCHAR(255) NULL,
lease_expires_at TIMESTAMP NULL,
PRIMARY KEY (id)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS HIGH_WATER_MARK (
id SMALLINT NOT NULL,
h BIGINT DEFAULT 0 NOT NULL,
witnessed_ceiling BIGINT NULL,
blocked_horizon BIGINT NULL,
lease_owner VARCHAR(255) NULL,
lease_expires_at TIMESTAMP NULL,
PRIMARY KEY (id)
)
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS HIGH_WATER_MARK (
id SMALLINT NOT NULL,
h BIGINT NOT NULL DEFAULT 0,
witnessed_ceiling BIGINT NULL,
blocked_horizon BIGINT NULL,
lease_owner VARCHAR(255) NULL,
lease_expires_at DATETIME NULL,
PRIMARY KEY (id)
)
-- SQL Server
IF OBJECT_ID(N'HIGH_WATER_MARK', N'U') IS NULL
CREATE TABLE HIGH_WATER_MARK (
id SMALLINT NOT NULL,
h BIGINT NOT NULL DEFAULT 0,
witnessed_ceiling BIGINT NULL,
blocked_horizon BIGINT NULL,
lease_owner VARCHAR(255) NULL,
lease_expires_at DATETIME2 NULL,
PRIMARY KEY (id)
)
READ_MODEL_METADATA
Tracks per-read-model metadata within a projection, used to detect stale entries after a rebuild.
The table name defaults to READ_MODEL_METADATA, overridable via spring.ddd.cqrs.projection.jdbc.store.read-model-metadata-table-name.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Fully-qualified projection class name. |
|
VARCHAR(255) |
Part of composite primary key. Read-model identity value. |
|
SMALLINT |
Nullable. The read model’s partition bucket, |
|
BIGINT |
Nullable. Monotonic per- |
|
BIGINT |
Not null. Generation counter matching |
|
BIGINT |
Nullable. Last event position applied to this read model. |
|
TIMESTAMP | DATETIME2 |
Nullable. Source-event time ( |
|
TIMESTAMP | DATETIME2 |
Not null. DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
Override: spring.ddd.cqrs.projection.jdbc.store.read-model-metadata-table-name.
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB / MySQL / MariaDB
CREATE TABLE IF NOT EXISTS READ_MODEL_METADATA (
projection_name VARCHAR(255) NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
partition_key SMALLINT,
change_seq BIGINT,
rebuild_generation BIGINT NOT NULL,
last_event_position BIGINT,
last_event_time TIMESTAMP,
last_modified_at TIMESTAMP NOT NULL,
PRIMARY KEY (projection_name, read_model_id)
);
CREATE INDEX IF NOT EXISTS idx_READ_MODEL_METADATA_feed
ON READ_MODEL_METADATA (projection_name, partition_key, change_seq);
-- SQL Server
IF OBJECT_ID(N'READ_MODEL_METADATA', N'U') IS NULL
CREATE TABLE READ_MODEL_METADATA (
projection_name VARCHAR(255) NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
partition_key SMALLINT,
change_seq BIGINT,
rebuild_generation BIGINT NOT NULL,
last_event_position BIGINT,
last_event_time DATETIME2,
last_modified_at DATETIME2 NOT NULL,
PRIMARY KEY (projection_name, read_model_id)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_READ_MODEL_METADATA_feed'
AND object_id = OBJECT_ID(N'READ_MODEL_METADATA'))
CREATE INDEX idx_READ_MODEL_METADATA_feed
ON READ_MODEL_METADATA (projection_name, partition_key, change_seq);
READ_MODEL_CASCADE_APPLIED
Exactly-once dedup markers for the partitioned read-model cascade. Each row records that a downstream
projection has already applied one upstream feed entry (identified by its change_seq) on a given
edge, so a re-read of that entry — from a batch retry or an ownership handover — is skipped instead of
applied twice. A marker is written in the same transaction as the apply it guards, and is pruned once
the consuming cursor has advanced past its change_seq.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(128) |
Part of composite primary key. The consuming (downstream) projection’s storage name. Narrower than a consumer-data column because it is a framework-controlled storage name, short by construction. |
|
VARCHAR(128) |
Part of composite primary key. The upstream projection (feed source) this marker is for. Narrower than
a consumer-data column for the same reason as |
|
VARCHAR(255) |
Part of composite primary key. The downstream read-model id the change was applied to. |
|
BIGINT |
Part of composite primary key. The upstream edge’s observed |
|
SMALLINT |
Not null. The partition bucket, stored outside the primary key so applied markers can be pruned per partition. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS READ_MODEL_CASCADE_APPLIED (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
change_seq BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, read_model_id, change_seq)
);
CREATE INDEX IF NOT EXISTS idx_rmca_prune
ON READ_MODEL_CASCADE_APPLIED (downstream_projection, upstream_edge, partition_key, change_seq);
-- MySQL / MariaDB: downstream_projection / upstream_edge narrowed to VARCHAR(128) to keep the
-- composite primary key within InnoDB's 3072-byte DYNAMIC index-key limit under utf8mb4
CREATE TABLE IF NOT EXISTS READ_MODEL_CASCADE_APPLIED (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
change_seq BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, read_model_id, change_seq),
INDEX idx_rmca_prune (downstream_projection, upstream_edge, partition_key, change_seq)
);
-- SQL Server
IF OBJECT_ID(N'READ_MODEL_CASCADE_APPLIED', N'U') IS NULL
CREATE TABLE READ_MODEL_CASCADE_APPLIED (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
read_model_id VARCHAR(255) NOT NULL,
change_seq BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, read_model_id, change_seq)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_rmca_prune'
AND object_id = OBJECT_ID(N'READ_MODEL_CASCADE_APPLIED'))
CREATE INDEX idx_rmca_prune
ON READ_MODEL_CASCADE_APPLIED (downstream_projection, upstream_edge, partition_key, change_seq);
PROJECTION_REKEY_INDEX
Routing pointers for a re-key read-model cascade edge: a stateless indexer appends one pointer per
upstream change, and each downstream partition owner reads its own bucket from this table. Append-only
and idempotent on the primary key; index_seq is a single monotonic counter per
(downstream_projection, upstream_edge) edge, independent of any partition.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(128) |
Part of composite primary key. The consuming (downstream) projection’s storage name. |
|
VARCHAR(128) |
Part of composite primary key. The upstream projection (feed source) this pointer is for. |
|
BIGINT |
Part of composite primary key. Monotonic per |
|
SMALLINT |
Not null. The routed-to bucket ( |
|
VARCHAR(256) |
Not null. The routed-to downstream row’s canonical id. |
|
VARCHAR(256) |
Not null. The canonical upstream id whose current snapshot the downstream owner applies. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS PROJECTION_REKEY_INDEX (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
index_seq BIGINT NOT NULL,
downstream_partition SMALLINT NOT NULL,
downstream_id VARCHAR(256) NOT NULL,
upstream_id VARCHAR(256) NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, index_seq)
);
CREATE INDEX IF NOT EXISTS idx_rekey_bucket
ON PROJECTION_REKEY_INDEX (downstream_projection, upstream_edge, downstream_partition, index_seq);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PROJECTION_REKEY_INDEX (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
index_seq BIGINT NOT NULL,
downstream_partition SMALLINT NOT NULL,
downstream_id VARCHAR(256) NOT NULL,
upstream_id VARCHAR(256) NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, index_seq),
INDEX idx_rekey_bucket (downstream_projection, upstream_edge, downstream_partition, index_seq)
);
-- SQL Server
IF OBJECT_ID(N'PROJECTION_REKEY_INDEX', N'U') IS NULL
CREATE TABLE PROJECTION_REKEY_INDEX (
downstream_projection VARCHAR(128) NOT NULL,
upstream_edge VARCHAR(128) NOT NULL,
index_seq BIGINT NOT NULL,
downstream_partition SMALLINT NOT NULL,
downstream_id VARCHAR(256) NOT NULL,
upstream_id VARCHAR(256) NOT NULL,
PRIMARY KEY (downstream_projection, upstream_edge, index_seq)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_rekey_bucket'
AND object_id = OBJECT_ID(N'PROJECTION_REKEY_INDEX'))
CREATE INDEX idx_rekey_bucket
ON PROJECTION_REKEY_INDEX (downstream_projection, upstream_edge, downstream_partition, index_seq);
JSON projection store
Module: spring-ddd-cqrs-jdbc.
Schema creation: spring.ddd.cqrs.projection.json.table-creation (default AUTO); initializer: JsonProjectionTableInitializer.
MariaDB uses the MySQL DDL (no separate MariaDB resource file).
PROJECTION_STORE
Default table for all @JsonProjection read models.
Each @JsonProjection class can declare its own dedicated table via @JsonProjection(table = "…").
The default table name is PROJECTION_STORE, overridable via spring.ddd.cqrs.projection.json.default-read-model-table-name.
@QueryField annotations on the read-model class add typed columns to this table at startup; they are placed immediately before the primary-key constraint.
Supported types are NUMBER (mapped to DECIMAL or BIGINT), TEMPORAL (mapped to TIMESTAMP(6) / DATETIME(6) / DATETIME2), and BOOLEAN (mapped to BOOLEAN / BIT).
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Read-model identity value. |
|
VARCHAR(255) |
Part of composite primary key. Fully-qualified projection class name. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Nullable. Full JSON snapshot of the read model. TEXT on H2 and PostgreSQL; LONGVARCHAR on HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
BIGINT |
Not null, default 0. Optimistic-lock version. |
(typed |
varies |
Zero or more typed columns injected at table creation time from |
Override: @JsonProjection(table = "…") on the read-model class, or spring.ddd.cqrs.projection.json.default-read-model-table-name for the application-wide default.
DDL — all dialects
-- PostgreSQL / H2
-- Typed @QueryField columns, if any, are inserted where {queryColumns} appears.
CREATE TABLE IF NOT EXISTS PROJECTION_STORE (
read_model_id VARCHAR(255) NOT NULL,
projection_name VARCHAR(255) NOT NULL,
read_model_json TEXT,
version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (read_model_id, projection_name)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS PROJECTION_STORE (
read_model_id VARCHAR(255) NOT NULL,
projection_name VARCHAR(255) NOT NULL,
read_model_json LONGVARCHAR,
version BIGINT DEFAULT 0 NOT NULL,
PRIMARY KEY (read_model_id, projection_name)
)
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PROJECTION_STORE (
read_model_id VARCHAR(255) NOT NULL,
projection_name VARCHAR(255) NOT NULL,
read_model_json LONGTEXT,
version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (read_model_id, projection_name)
)
-- SQL Server
IF OBJECT_ID(N'PROJECTION_STORE', N'U') IS NULL
CREATE TABLE PROJECTION_STORE (
read_model_id VARCHAR(255) NOT NULL,
projection_name VARCHAR(255) NOT NULL,
read_model_json NVARCHAR(MAX),
version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (read_model_id, projection_name)
)
Sagas
Module: spring-ddd-saga-jdbc.
SAGA_INSTANCE
Stores the serialised state of each saga instance.
The table name defaults to SAGA_INSTANCE, overridable via spring.ddd.saga.table-name or @SagaTable on the saga class.
Schema creation: spring.ddd.saga.schema.auto (default true); initializer: SagaTableInitializer.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. Fully-qualified saga class name. |
|
VARCHAR(255) |
Part of composite primary key. Saga identity value. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised saga state. TEXT on H2 and PostgreSQL; LONGVARCHAR on HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
BIGINT |
Not null, default 0. Optimistic-lock version. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Nullable. The completion timestamp; |
Override: spring.ddd.saga.table-name or @SagaTable on the saga class.
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS SAGA_INSTANCE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
completed_at TIMESTAMP,
PRIMARY KEY (saga_type, saga_id)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_INSTANCE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json LONGVARCHAR NOT NULL,
version BIGINT DEFAULT 0 NOT NULL,
completed_at TIMESTAMP,
PRIMARY KEY (saga_type, saga_id)
)
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_INSTANCE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json LONGTEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
completed_at DATETIME(6),
PRIMARY KEY (saga_type, saga_id)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_INSTANCE')
CREATE TABLE SAGA_INSTANCE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json NVARCHAR(MAX) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
completed_at DATETIME2,
PRIMARY KEY (saga_type, saga_id)
)
SAGA_DEADLINE
Stores pending saga deadlines (scheduled timeouts).
The table name defaults to SAGA_DEADLINE, overridable via spring.ddd.saga.deadline.table-name.
Schema creation: spring.ddd.saga.deadline.schema.auto (default true); initializer: SagaDeadlineTableInitializer.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Part of composite primary key. |
|
VARCHAR(255) |
Part of composite primary key. |
|
VARCHAR(255) |
Part of composite primary key. Deadline name within the saga. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Not null. When the deadline fires. Indexed for efficient polling. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Nullable. Optional payload attached to the deadline. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Nullable. Saga metadata snapshot at deadline scheduling time. |
Override: spring.ddd.saga.deadline.table-name.
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at TIMESTAMP NOT NULL,
payload_json TEXT,
metadata_json TEXT,
PRIMARY KEY (saga_type, saga_id, name)
);
CREATE INDEX IF NOT EXISTS idx_SAGA_DEADLINE_due_at
ON SAGA_DEADLINE (due_at);
-- HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at TIMESTAMP NOT NULL,
payload_json LONGVARCHAR,
metadata_json LONGVARCHAR,
PRIMARY KEY (saga_type, saga_id, name)
);
CREATE INDEX IF NOT EXISTS idx_SAGA_DEADLINE_due_at
ON SAGA_DEADLINE (due_at);
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at DATETIME(6) NOT NULL,
payload_json LONGTEXT,
metadata_json LONGTEXT,
PRIMARY KEY (saga_type, saga_id, name),
INDEX idx_SAGA_DEADLINE_due_at (due_at)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_DEADLINE')
CREATE TABLE SAGA_DEADLINE (
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at DATETIME2 NOT NULL,
payload_json NVARCHAR(MAX),
metadata_json NVARCHAR(MAX),
PRIMARY KEY (saga_type, saga_id, name)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_SAGA_DEADLINE_due_at'
AND object_id = OBJECT_ID(N'SAGA_DEADLINE'))
CREATE INDEX idx_SAGA_DEADLINE_due_at
ON SAGA_DEADLINE (due_at);
SAGA_DEADLINE_LOCK
Distributed lock table that ensures only one node polls for expired deadlines at a time.
The table name defaults to SAGA_DEADLINE_LOCK, overridable via spring.ddd.saga.deadline.lock-table-name.
Schema creation: spring.ddd.saga.deadline.schema.auto (default true); initializer: SagaDeadlineTableInitializer.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. Name of the lock. |
|
VARCHAR(255) |
Not null. Node identifier holding the lock. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Not null. Lock expiry. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
Override: spring.ddd.saga.deadline.lock-table-name.
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE_LOCK (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (lock_name)
)
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE_LOCK (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at DATETIME(6) NOT NULL,
PRIMARY KEY (lock_name)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_DEADLINE_LOCK')
CREATE TABLE SAGA_DEADLINE_LOCK (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at DATETIME2 NOT NULL,
PRIMARY KEY (lock_name)
)
SAGA_INBOUND_CHECKPOINT
Checkpoints the ordered inbound event position per saga type when the ordered-source inbound path is active.
The table name defaults to SAGA_INBOUND_CHECKPOINT, overridable via spring.ddd.saga.inbound.table-name.
Schema creation: spring.ddd.saga.inbound.schema.auto (default true); initializer: SagaInboundCheckpointTableInitializer.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. Fully-qualified saga class name. |
|
BIGINT |
Not null, default 0. Last acknowledged event position for this saga type. |
|
VARCHAR(255) |
Nullable. Node identifier holding the dispatch lease. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Nullable. Lease expiry. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
Override: spring.ddd.saga.inbound.table-name.
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS SAGA_INBOUND_CHECKPOINT (
saga_type VARCHAR(255) NOT NULL,
checkpoint_position BIGINT NOT NULL DEFAULT 0,
lease_owner VARCHAR(255),
lease_expires_at TIMESTAMP,
PRIMARY KEY (saga_type)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_INBOUND_CHECKPOINT (
saga_type VARCHAR(255) NOT NULL,
checkpoint_position BIGINT DEFAULT 0 NOT NULL,
lease_owner VARCHAR(255),
lease_expires_at TIMESTAMP,
PRIMARY KEY (saga_type)
)
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_INBOUND_CHECKPOINT (
saga_type VARCHAR(255) NOT NULL,
checkpoint_position BIGINT NOT NULL DEFAULT 0,
lease_owner VARCHAR(255),
lease_expires_at DATETIME(6),
PRIMARY KEY (saga_type)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_INBOUND_CHECKPOINT')
CREATE TABLE SAGA_INBOUND_CHECKPOINT (
saga_type VARCHAR(255) NOT NULL,
checkpoint_position BIGINT NOT NULL DEFAULT 0,
lease_owner VARCHAR(255),
lease_expires_at DATETIME2,
PRIMARY KEY (saga_type)
)
Durable domain-event metadata
Module: spring-ddd-starter-domain-events-modulith-jdbc.
Schema creation: spring.modulith.events.jdbc.schema-initialization.enabled (Modulith-owned property); initializer: MetadataSchemaInitializer.
This table is created alongside the Spring Modulith EVENT_PUBLICATION table and stores serialised MessageMetadata keyed to the Modulith publication ID.
It enables metadata (tenant ID, actor, clock) to survive an application restart and be restored when Modulith resubmits outstanding publications.
EVENT_PUBLICATION_METADATA
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(36) |
Primary key. Matches |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIMEOFFSET |
Nullable. Mirrors the Modulith completion date for retention alignment. |
|
CLOB | LONGVARCHAR | TEXT | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP WITH TIME ZONE,
METADATA TEXT NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- H2
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP(9) WITH TIME ZONE,
METADATA CLOB NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- HSQLDB
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP WITH TIME ZONE,
METADATA LONGVARCHAR NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- MySQL
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE DATETIME(6) NULL,
METADATA LONGTEXT NOT NULL,
PRIMARY KEY (PUBLICATION_ID),
INDEX idx_event_pub_meta_completion (COMPLETION_DATE)
);
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF OBJECT_ID(N'EVENT_PUBLICATION_METADATA', N'U') IS NULL
CREATE TABLE EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE DATETIMEOFFSET,
METADATA NVARCHAR(MAX) NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_event_pub_meta_completion'
AND object_id = OBJECT_ID(N'EVENT_PUBLICATION_METADATA'))
CREATE INDEX idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
Schema creation control
| Subsystem | Property | Default | Initializer |
|---|---|---|---|
Event store (event/snapshot tables, EVENT_GLOBAL_POSITION, PARTITION_METADATA, READ_MODEL_PARTITION_INDEX) |
|
|
|
Partition coordination substrate (PARTITION_MEMBER, PARTITION_OWNERSHIP, PARTITION_SOURCE_CURSOR) |
|
|
|
Projection bookkeeping (PROJECTION_METADATA, HIGH_WATER_MARK, READ_MODEL_METADATA) |
|
|
|
JSON projection store (PROJECTION_STORE) |
|
|
|
Saga instances (SAGA_INSTANCE) |
|
|
|
Saga deadlines (SAGA_DEADLINE, SAGA_DEADLINE_LOCK) |
|
|
|
Saga inbound checkpoint (SAGA_INBOUND_CHECKPOINT) |
|
|
|
Durable domain-event metadata (EVENT_PUBLICATION_METADATA) |
|
(Modulith default) |
|
See Configuration properties for the full property reference.
Managing the schema with a migration tool
Disable automatic schema creation with spring.ddd.eventsourcing.schema.auto=false and apply the
bundled DDL scripts through your migration tool of choice.
When disabled, you are responsible for creating the event store’s own JDBC tables from the bundled
scripts — including PARTITION_METADATA and READ_MODEL_PARTITION_INDEX — before the application
starts. The framework itself still seeds and verifies the partition-count row in PARTITION_METADATA
at runtime regardless of this setting, so spring.ddd.partitioning.partitions remains the single
source of truth for the partition count whether the table was created by the framework or by your
migration tool.
The partition coordination substrate’s tables — PARTITION_MEMBER, PARTITION_OWNERSHIP, and
PARTITION_SOURCE_CURSOR — have their own, independent switch, spring.ddd.partitioning.schema.auto,
and their own bundled scripts (see Coordination substrate scripts
below), because that substrate runs in any application using projections or partitioned consumption,
with or without an event store.
The spring.ddd.eventsourcing.schema.auto switch, and the DDL scripts below, only govern the
event store’s own JDBC tables. Tables backing JPA-mapped domain entities and @JpaProjection read
models are managed separately by Hibernate; validate them with spring.jpa.hibernate.ddl-auto: validate
rather than creating them with this framework’s DDL.
|
Bundled DDL scripts
The scripts ship inside the spring-ddd-eventsourcing-jdbc JAR under db/eventsourcing/<dialect>/,
one file per object type:
-
global-position-schema.sql— theEVENT_GLOBAL_POSITIONsequence (or allocator table on MySQL/MariaDB). -
event-store-schema.sql— the event table. Contains the placeholder text${event_table}. -
snapshot-schema.sql— the snapshot table. Contains the placeholder text${snapshot_table}. -
partition-metadata-schema.sql— thePARTITION_METADATAsingleton table. -
read-model-partition-index-schema.sql— theREAD_MODEL_PARTITION_INDEXtable.
Dialects with a dedicated directory: postgres, mysql (also used for MariaDB), h2, hsqldb, sqlserver.
MariaDB has no separate directory — use the mysql scripts.
Coordination substrate scripts
The partition coordination substrate’s scripts ship separately, inside the spring-ddd-messaging-jdbc
JAR under db/messaging/<dialect>/:
-
partition-coordination-schema.sql— thePARTITION_MEMBERandPARTITION_OWNERSHIPtables. -
partition-source-cursor-schema.sql— thePARTITION_SOURCE_CURSORtable.
Same dialect directories as above (postgres, mysql, h2, hsqldb, sqlserver; MariaDB reuses
mysql). Disable auto-creation with spring.ddd.partitioning.schema.auto=false and apply these two
scripts the same way as the event store’s own scripts, following the same Flyway/Liquibase pattern
shown below.
Always apply global-position-schema.sql before the event-table script, because the
event table depends on the EVENT_GLOBAL_POSITION object.
On PostgreSQL, H2, HSQLDB, and SQL Server the global_position column’s DEFAULT expression calls
the EVENT_GLOBAL_POSITION sequence, so the sequence must exist first.
On MySQL and MariaDB the column has no DEFAULT; the framework assigns global_position from the
EVENT_GLOBAL_POSITION allocator table at insert time, so that table must exist before the first
event is written.
|
Placeholder substitution
The event-store-schema.sql and snapshot-schema.sql scripts contain the literal tokens
${event_table} and ${snapshot_table}.
Flyway and Liquibase do not resolve these tokens.
Before applying the scripts you must substitute the real table names manually — one copy of each
script per distinct resolved table name.
If your application uses @EventTable on multiple aggregates that resolve to different table names,
create one pair of event + snapshot scripts per distinct name (for example bank_account_events and
bank_account_snapshots for a BankAccount aggregate that declares @EventTable).
The default table names — when @EventTable is absent — are EVENT_STORE and AGGREGATE_SNAPSHOT.
Flyway example
spring:
flyway:
locations: classpath:db/migration # your own migration directory
ddd:
eventsourcing:
schema:
auto: false
Copy the bundled scripts out of the JAR (under db/eventsourcing/<dialect>/), substitute the real
table names, and place the results inside your Flyway migration directory as versioned migrations —
Flyway does not pick them up from their bundled location, because those filenames do not follow its
V naming convention.
Flyway applies all scripts in filename order, so name global-position-schema.sql with a lower
version prefix than the event and snapshot scripts (for example V1global_position.sql,
V2bank_account_events.sql, V3bank_account_snapshots.sql).
Liquibase example
Create a changelog that applies the scripts in the correct order, with one changeset pair per distinct aggregate table name:
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 1-global-position
author: spring-ddd
changes:
- sqlFile:
path: db/eventsourcing/postgres/global-position-schema.sql
relativeToChangelogFile: false
- changeSet:
id: 2-bank-account-events
author: spring-ddd
changes:
- sqlFile:
path: db/migrations/bank_account_events.sql (1)
relativeToChangelogFile: false
- changeSet:
id: 3-bank-account-snapshots
author: spring-ddd
changes:
- sqlFile:
path: db/migrations/bank_account_snapshots.sql (2)
relativeToChangelogFile: false
| 1 | A copy of event-store-schema.sql with ${event_table} replaced by bank_account_events. |
| 2 | A copy of snapshot-schema.sql with ${snapshot_table} replaced by bank_account_snapshots. |
Then point Liquibase at the changelog and disable auto-creation:
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
ddd:
eventsourcing:
schema:
auto: false