Per-aggregate event tables

By default all event-sourced aggregates write to the shared EVENT_STORE table (spring.ddd.eventsourcing.jdbc.table-names.event-store). @EventTable routes a specific aggregate type to its own event (and snapshot) table — useful for domain separation, access control, or multi-tenant architectures where mixing events from unrelated bounded contexts in one table is undesirable. A single monotonic global position is maintained across every table, so events read across tables always come back in the correct order.

@EventTable lives in spring-ddd-eventsourcing-jdbc and is activated by spring-ddd-starter-eventsourcing-jdbc (or the full-stack bundle spring-ddd-starter-jpa).

The @EventTable annotation

Place @EventTable on an event-sourced aggregate class:

@Aggregate(namespace = "banking")
@EventSourced
@EventTable                        // resolves to BANK_ACCOUNT_EVENTS / BANK_ACCOUNT_SNAPSHOTS
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {

    override lateinit var id: AccountId
}

To use explicit names, pass them as parameters:

@Aggregate(namespace = "banking")
@EventSourced
@EventTable("BANK_ACCOUNT_EVENTS", snapshots = "BANK_ACCOUNT_SNAPSHOTS")
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {

    override lateinit var id: AccountId
}

Parameters

Parameter Type Default Description

value

String

"" (blank)

Event table name. Blank ⇒ <NAME>_EVENTS.

snapshots

String

"" (blank)

Snapshot table name. Blank ⇒ <NAME>_SNAPSHOTS.

<NAME> is the aggregate’s @Aggregate name converted to SCREAMING_SNAKE_CASE (for example, BankAccount → BANK_ACCOUNT). Each parameter resolves independently.

Resolution rules

Declaration Event table Snapshot table

(absent)

EVENT_STORE

AGGREGATE_SNAPSHOTS

@EventTable

<NAME>_EVENTS

<NAME>_SNAPSHOTS

@EventTable("X")

X

<NAME>_SNAPSHOTS

@EventTable(snapshots = "Y")

<NAME>_EVENTS

Y

@EventTable("X", snapshots = "Y")

X

Y

spring.ddd.eventsourcing.jdbc.table-prefix goes before the shared names and before every <NAME>_EVENTS and <NAME>_SNAPSHOTS: with ACME_, a bare @EventTable on BankAccount resolves to ACME_BANK_ACCOUNT_EVENTS. A name written on @EventTable (X, Y above) is used as written, without the prefix.

Constraints

  • @EventTable must be paired with @EventSourced. Using it on a non-event-sourced class is a configuration error detected at startup.

  • @EventTable is declared directly on each aggregate that needs it; it is not inherited by subclasses.

  • Table names are physical SQL identifiers — they are used verbatim in DDL and DML statements, not as bindable parameters. Each name uses letters, digits and _ and does not start with a digit. The index and constraint names built from a table add to its length, so an event table name, prefix included, is at most 41 characters and a snapshot table name at most 51, whether derived or written on @EventTable; a longer one fails the startup naming the aggregate class.

Grouping aggregate types onto one table

Several aggregate types may resolve to the same table name. The aggregate_type column (set to "$namespace.$name" from @Aggregate) disambiguates rows within the shared table, so each type’s events remain distinct even when co-located:

@Aggregate(namespace = "payments")
@EventSourced
@EventTable("PAYMENTS_EVENTS")   // Payment and Refund share one table
class Payment private constructor() : EventSourcingAggregateRoot<Payment, PaymentId>() {

    override lateinit var id: PaymentId
}

@Aggregate(namespace = "payments")
@EventSourced
@EventTable("PAYMENTS_EVENTS")   // same resolved name — rows tagged with the aggregate type "payments.Refund"
class Refund private constructor() : EventSourcingAggregateRoot<Refund, RefundId>() {

    override lateinit var id: RefundId
}

Grouping by bounded context keeps the event history of closely related types on the same table while still isolating them from unrelated contexts.

Single global position across all tables

Because an event store can be split across several tables, one shared EVENT_STORE by default or a table of its own per aggregate via @EventTable, no single table’s own numbering could order events that live in different tables. The framework therefore gives every event one application-wide position, whichever table it lands in.

Positions give the whole store one total order: events are read in the same order across tables, whichever table holds them. A position is assigned once and never reused, so it stays stable even when rows are later removed. On PostgreSQL an append that rolls back can leave a position unused; on MySQL, MariaDB, SQL Server, H2 and HSQLDB positions have no gaps. On every engine a save through the event-sourcing repository appends in a transaction on the event store’s DataSource: the one the repository opens, or one of the append’s own when the application has no transaction manager, so the events of one save are stored together or not at all. What each engine means for append throughput is covered in Choosing a database.

The object that numbers the positions must exist before the first append. spring.ddd.eventsourcing.jdbc.initialize-schema=always, the default, creates it; a schema you create yourself needs it too, from Database table schemas.

Scanning for aggregates

Spring DDD scans for @EventSourced aggregates at startup. The scan decides which aggregates get event tables, command handlers and a checked @AggregateId, and which events sagas and capture treat as event-sourced.

By default it uses the Spring Boot auto-configuration base packages — that is, the @SpringBootApplication package tree. When aggregates live outside that tree (for example, in a sibling module with a different root package), declare where they are with @EventSourcedScan.

Declaring scan locations with @EventSourcedScan

Annotate any @Configuration class with @EventSourcedScan:

@Configuration
@EventSourcedScan // no attributes: scans this class's own package (and sub-packages)
class BankingModuleConfig

Explicit locations can be provided as package strings or type-safely via a marker class in the target package:

@Configuration
@EventSourcedScan(basePackages = ["com.example.banking"])
class BankingConfig

@Configuration
@EventSourcedScan(basePackageClasses = [BankAccount::class])   // scans BankAccount's package
class BankingConfig

All @EventSourcedScan annotations across the context are unioned. When at least one scan location is declared, it replaces the Spring Boot auto-configuration default — aggregates outside the @SpringBootApplication package tree are discovered and the application package is not scanned implicitly.

The events, commands and queries in those packages are found as well. For them the packages are added to the @SpringBootApplication package tree rather than replacing it.

Auto schema creation

With spring.ddd.eventsourcing.jdbc.initialize-schema=always (the default), the starter creates all required objects on startup — idempotently:

  1. The object that numbers the events' positions.

  2. One event table for every distinct resolved event-table name across all scanned @EventSourced aggregates.

  3. One snapshot table per distinct resolved snapshot-table name — unless snapshots are disabled with spring.ddd.eventsourcing.snapshot.enabled=false.

A few behaviours to be aware of:

  • The shared EVENT_STORE and AGGREGATE_SNAPSHOTS tables are created only when at least one aggregate maps to them (that is, omits @EventTable). If every aggregate declares a custom table, the shared defaults are not created.

  • When no @EventSourced aggregate is scanned, nothing is created — not even EVENT_STORE or the object that numbers positions. An application that only consumes events must therefore have the producing aggregates' @EventSourced types on its classpath and within a scanned package (via a shared domain module, or @EventSourcedScan).

Set spring.ddd.eventsourcing.jdbc.initialize-schema=never when managing the schema with Flyway, Liquibase, or another migration tool, or embedded to create it only in an embedded database such as H2 in tests.

Per-dialect DDL

The per-dialect DDL of the object that numbers positions, and of the event and snapshot tables, is listed in the appendix. To manage the schema yourself, apply that DDL.

See Database table schemas. For step-by-step instructions on applying the bundled scripts with Flyway or Liquibase — including the required script ordering and placeholder substitution — see Managing the schema with a migration tool.