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

id

UUID | VARCHAR(36)

Primary key. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB, MySQL/MariaDB, and SQL Server.

aggregate_id

VARCHAR(255)

Not null. Serialised aggregate identity.

aggregate_type

VARCHAR(255)

Not null. Fully-qualified aggregate class name.

sequence_no

BIGINT

Not null. Per-aggregate monotonic sequence starting at 0. Unique composite constraint with aggregate_id.

event_type

VARCHAR(255)

Not null. Fully-qualified event class name.

event_revision

INT

Not null, default 0. Schema revision for upcasting.

payload

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.

metadata

CLOB | JSONB | LONGTEXT | NVARCHAR(MAX)

Nullable. Optional event metadata. Same type as payload.

occurred_at

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.

partition_key

SMALLINT

Not null. Stable FNV-1a bucket of the aggregate id in [0, N), where N is the fixed partition count (spring.ddd.partitioning.partitions). Stamped at append time; never recomputed. Indexed together with global_position (idx_<event_table>_partition) so a consumer can read one partition’s events in position order.

global_position

BIGINT

Not null. Total-order position drawn from the global sequence or table. Indexed, and indexed again as the second column of idx_<event_table>_partition.

row_version

ROWVERSION

SQL Server only. Auto-generated per row; never named in an INSERT. The concurrent read frontier reads it to tell a committed append from an in-flight one. Other dialects do not have this column.

Override: @EventTable(name = "…") on the aggregate class.

SQL Server custom event tables must include the row_version ROWVERSION NOT NULL column. On SQL Server the read frontier derives transaction visibility from rowversion; a custom @EventTable that omits the column silently breaks catch-up for that table. The column is auto-generated — it never appears in an INSERT — so adding it is DDL-only. Every other dialect needs no such column.

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

EVENT_GLOBAL_POSITION

SEQUENCE or TABLE

H2, HSQLDB, PostgreSQL, SQL Server: CREATE SEQUENCE. MySQL/MariaDB: a single-row table with id TINYINT PK and val BIGINT; the row is inserted on first use.

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

id

SMALLINT

Primary key. Always 1.

partitions

INT

Not null. The fixed partition count N, seeded from spring.ddd.partitioning.partitions on first startup.

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

consumer_name

VARCHAR(255)

Part of composite primary key. Identifies the logical consumer this index belongs to.

partition_key

SMALLINT

Part of composite primary key. bucket(read_model_id, N), the partition a given read model is co-located into for this consumer, where N is spring.ddd.partitioning.partitions.

read_model_id

VARCHAR(255)

Part of composite primary key. The canonical read-model id the event belongs to (for example the account_id).

global_position

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

id

UUID | VARCHAR(36)

Primary key. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB, MySQL/MariaDB, and SQL Server.

aggregate_id

VARCHAR(255)

Not null. Serialised aggregate identity.

aggregate_type

VARCHAR(255)

Not null. Fully-qualified aggregate class name.

sequence_no

BIGINT

Not null. Event sequence number at the time the snapshot was taken.

snapshot_version

INT

Not null, default 1. Snapshot schema revision.

payload

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.

created_at

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

consumer_name

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.

instance_id

VARCHAR(255)

Part of composite primary key. The reporting instance’s id, resolved from spring.ddd.partitioning.instance.id.

last_heartbeat

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

consumer_name

VARCHAR(255)

Part of composite primary key. Identifies the logical consumer this ownership row belongs to.

partition

SMALLINT

Part of composite primary key. The partition number, in [0, N) where N is spring.ddd.partitioning.partitions. A reserved word on some dialects — quoted ( partition on MySQL/MariaDB, [partition] on SQL Server) where required.

owner

VARCHAR(255)

Nullable. The instance_id currently holding the lease, or null when the partition is unclaimed.

generation

BIGINT

Not null, default 0. Incremented every time the partition is claimed; fences stale work from a previous owner.

lease_expiry

TIMESTAMP | TIMESTAMPTZ | DATETIME2

Nullable. When the current owner’s lease expires (database clock), derived from spring.ddd.partitioning.instance.stale-timeout. null when unclaimed.

next_change_seq

BIGINT

Not null, default 1. Mints the next feed change_seq for this partition (range-reserved per batch under the ownership fence).

published_change_seq

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

consumer_name

VARCHAR(255)

Part of composite primary key. The projection/consumer name.

partition

SMALLINT

Part of composite primary key. The partition bucket. A reserved word on some dialects — quoted ( partition on MySQL/MariaDB, [partition] on SQL Server) where required.

source_id

VARCHAR(255)

Part of composite primary key. The input source’s identity: event-store, or an upstream projection’s storage name for a cascade edge.

position

BIGINT

Not null, default 0. The cursor position drained for this source: the event store’s global position for event-store, or the upstream projection’s change_seq for a cascade edge.

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

projection_name

VARCHAR(255)

Primary key. Fully-qualified projection class name.

last_replayed_at

TIMESTAMP | DATETIME | DATETIME2

Nullable. When the last full replay completed. DATETIME on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere.

replay_locked_until

TIMESTAMP | DATETIME | DATETIME2

Nullable. Replay distributed-lock expiry. Same type as last_replayed_at.

replay_owner

VARCHAR(255)

Nullable. Node identifier holding the replay lock.

rebuild_generation

BIGINT

Not null, default 0. Incremented on each rebuild to invalidate stale read models.

replay_target_position

BIGINT

Nullable. Target event position for an in-progress replay.

replay_checkpoint_position

BIGINT

Nullable. Last checkpointed event position during an in-progress replay.

replay_started_at

TIMESTAMP | DATETIME | DATETIME2

Nullable. When the current replay started. Same type as last_replayed_at.

halted

BOOLEAN | BIT

Not null, default false. Whether the projection is halted. BIT on SQL Server.

halted_position

BIGINT

Nullable. Event position at which the projection halted.

halted_error

VARCHAR(1000)

Nullable. Truncated error message when halted.

halted_at

TIMESTAMP | DATETIME | DATETIME2

Nullable. When the projection halted. Same type as last_replayed_at.

halted_reason

VARCHAR(16)

Nullable. Reason code: ERROR, MANUAL, or REBUILD. REBUILD means a rebuild is quiescing the projection before resetting its epoch; it is released by the rebuild itself, not by an operator resume or by the startup auto-resume.

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

id

SMALLINT

Primary key. Always 1.

h

BIGINT

Not null, default 0. Current high-water mark position.

witnessed_ceiling

BIGINT

Nullable. Highest event position witnessed by any consumer.

blocked_horizon

BIGINT

Opaque transaction-visibility marker captured when the frontier first witnessed an unresolved gap below witnessed_ceiling. The gap is collapsed once the database’s oldest in-flight transaction has advanced past this marker. NULL when the frontier is at the tip.

lease_owner

VARCHAR(255)

Nullable. Node identifier holding the HWM advance lease.

lease_expires_at

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

projection_name

VARCHAR(255)

Part of composite primary key. Fully-qualified projection class name.

read_model_id

VARCHAR(255)

Part of composite primary key. Read-model identity value.

partition_key

SMALLINT

Nullable. The read model’s partition bucket, floorMod(hash(read_model_id), N); stamped on every apply once partitioned. Part of the partitioned change feed downstream cascades consume.

change_seq

BIGINT

Nullable. Monotonic per-(projection, partition) output sequence, advanced on every apply; the feed address a downstream cascade reads and checkpoints against.

rebuild_generation

BIGINT

Not null. Generation counter matching PROJECTION_METADATA.rebuild_generation.

last_event_position

BIGINT

Nullable. Last event position applied to this read model.

last_event_time

TIMESTAMP | DATETIME2

Nullable. Source-event time (occurredAt) of the last event applied to this read model; the as-of watermark for bounded ad-hoc replays. DATETIME2 on SQL Server; TIMESTAMP elsewhere.

last_modified_at

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

downstream_projection

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.

upstream_edge

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 downstream_projection.

read_model_id

VARCHAR(255)

Part of composite primary key. The downstream read-model id the change was applied to.

change_seq

BIGINT

Part of composite primary key. The upstream edge’s observed change_seq that was applied — not the downstream projection’s own output sequence.

partition_key

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

downstream_projection

VARCHAR(128)

Part of composite primary key. The consuming (downstream) projection’s storage name.

upstream_edge

VARCHAR(128)

Part of composite primary key. The upstream projection (feed source) this pointer is for.

index_seq

BIGINT

Part of composite primary key. Monotonic per (downstream_projection, upstream_edge) — a single per-edge counter, not per-partition.

downstream_partition

SMALLINT

Not null. The routed-to bucket (floorMod(hash(downstream_id), N)), stored outside the primary key so a partition owner’s bucket read hits idx_rekey_bucket instead of scanning the whole edge.

downstream_id

VARCHAR(256)

Not null. The routed-to downstream row’s canonical id.

upstream_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

read_model_id

VARCHAR(255)

Part of composite primary key. Read-model identity value.

projection_name

VARCHAR(255)

Part of composite primary key. Fully-qualified projection class name.

read_model_json

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.

version

BIGINT

Not null, default 0. Optimistic-lock version.

(typed @QueryField columns)

varies

Zero or more typed columns injected at table creation time from @QueryField annotations. Inserted before the primary-key constraint.

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

saga_type

VARCHAR(255)

Part of composite primary key. Fully-qualified saga class name.

saga_id

VARCHAR(255)

Part of composite primary key. Saga identity value.

state_json

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.

version

BIGINT

Not null, default 0. Optimistic-lock version.

completed_at

TIMESTAMP | DATETIME(6) | DATETIME2

Nullable. The completion timestamp; null while the saga is active. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. Drives retention.

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

saga_type

VARCHAR(255)

Part of composite primary key.

saga_id

VARCHAR(255)

Part of composite primary key.

name

VARCHAR(255)

Part of composite primary key. Deadline name within the saga.

due_at

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.

payload_json

TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX)

Nullable. Optional payload attached to the deadline.

metadata_json

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

lock_name

VARCHAR(255)

Primary key. Name of the lock.

owner

VARCHAR(255)

Not null. Node identifier holding the lock.

expires_at

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

saga_type

VARCHAR(255)

Primary key. Fully-qualified saga class name.

checkpoint_position

BIGINT

Not null, default 0. Last acknowledged event position for this saga type.

lease_owner

VARCHAR(255)

Nullable. Node identifier holding the dispatch lease.

lease_expires_at

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

PUBLICATION_ID

VARCHAR(36)

Primary key. Matches EVENT_PUBLICATION.ID (UUID as string).

COMPLETION_DATE

TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIMEOFFSET

Nullable. Mirrors the Modulith completion date for retention alignment. TIMESTAMP(9) WITH TIME ZONE on H2; TIMESTAMP WITH TIME ZONE on HSQLDB and PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIMEOFFSET on SQL Server. Indexed.

METADATA

CLOB | LONGVARCHAR | TEXT | LONGTEXT | NVARCHAR(MAX)

Not null. Serialised MessageMetadata JSON. CLOB on H2; LONGVARCHAR on HSQLDB; TEXT on PostgreSQL; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server.

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)

spring.ddd.eventsourcing.schema.auto

true

EventStoreSchemaInitializer

Partition coordination substrate (PARTITION_MEMBER, PARTITION_OWNERSHIP, PARTITION_SOURCE_CURSOR)

spring.ddd.partitioning.schema.auto

true

PartitionCoordinationSchemaInitializer

Projection bookkeeping (PROJECTION_METADATA, HIGH_WATER_MARK, READ_MODEL_METADATA)

spring.ddd.cqrs.projection.jdbc.store.table-creation

AUTO

ProjectionStoreTableInitializer

JSON projection store (PROJECTION_STORE)

spring.ddd.cqrs.projection.json.table-creation

AUTO

JsonProjectionTableInitializer

Saga instances (SAGA_INSTANCE)

spring.ddd.saga.schema.auto

true

SagaTableInitializer

Saga deadlines (SAGA_DEADLINE, SAGA_DEADLINE_LOCK)

spring.ddd.saga.deadline.schema.auto

true

SagaDeadlineTableInitializer

Saga inbound checkpoint (SAGA_INBOUND_CHECKPOINT)

spring.ddd.saga.inbound.schema.auto

true

SagaInboundCheckpointTableInitializer

Durable domain-event metadata (EVENT_PUBLICATION_METADATA)

spring.modulith.events.jdbc.schema-initialization.enabled (Modulith-owned)

(Modulith default)

MetadataSchemaInitializer

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 — the EVENT_GLOBAL_POSITION sequence (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 — the PARTITION_METADATA singleton table.

  • read-model-partition-index-schema.sql — the READ_MODEL_PARTITION_INDEX table.

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 — the PARTITION_MEMBER and PARTITION_OWNERSHIP tables.

  • partition-source-cursor-schema.sql — the PARTITION_SOURCE_CURSOR table.

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