Per-aggregate event tables
By default all event-sourced aggregates write to the shared EVENT_STORE table.
@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 cross-table
projection replay always sees events in the correct order.
@EventTable lives in spring-ddd-eventsourcing-jdbc and is activated by
spring-ddd-starter-eventsourcing-jdbc (or a full bundle such as spring-ddd-starter-jdbc).
The @EventTable annotation
Place @EventTable on an event-sourced aggregate class:
import de.dwittkoetter.ddd.annotation.Aggregate
import de.dwittkoetter.ddd.eventsourcing.EventSourced
import de.dwittkoetter.ddd.eventsourcing.jdbc.EventTable
import de.dwittkoetter.ddd.eventsourcing.aggregate.EventSourcingAggregateRoot
@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
private set
}
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
private set
}
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
|
|
|
Event table name. Blank ⇒ |
|
|
|
Snapshot table name. Blank ⇒ |
<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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Constraints
-
@EventTablemust be paired with@EventSourced. Using it on a non-event-sourced class is a configuration error detected at startup. -
@EventTableis 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 must match
^[A-Za-z_][A-Za-z0-9_]{0,62}$. The 62-character length limit applies equally to auto-derived names — keep aggregate class names short enough that the resulting<NAME>_EVENTS/<NAME>_SNAPSHOTSidentifiers stay within this bound.
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
private set
}
@Aggregate(namespace = "payments")
@EventSourced
@EventTable("PAYMENTS_EVENTS") // same resolved name — rows tagged aggregate_type = "payments.Refund"
class Refund private constructor() : EventSourcingAggregateRoot<Refund, RefundId>() {
override lateinit var id: RefundId
private set
}
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 multiple physical tables — one shared EVENT_STORE by
default, or a dedicated table per aggregate via @EventTable — no single table’s own auto-increment
could order events that live in different tables.
That split is the reason a separate counter exists at all: the framework maintains one
application-wide position counter, EVENT_GLOBAL_POSITION, shared by every event table.
Each append draws the next value from it and stamps it on the event’s global_position column, so every
event — whichever table it lands in — receives a globally unique, monotonically increasing position.
That single counter is what gives the whole store a consistent total order: projection catch-up and replay read events in the correct cross-table order regardless of which table produced them. The order is tracked by the counter, not derived by counting rows — positions are assigned once and never reused, so they stay stable even if rows are later removed.
On PostgreSQL, H2, HSQLDB, and SQL Server the counter is a native EVENT_GLOBAL_POSITION sequence.
MySQL and MariaDB use a single-row EVENT_GLOBAL_POSITION allocator table instead — MySQL has no shared
sequence the framework can use across tables, and MariaDB’s native sequence cannot be used soundly for
the concurrent read frontier — and the framework populates global_position from the allocator at
insert time.
How each engine assigns the position, and what it means for append throughput, is covered in
Choosing a database.
|
|
Scanning for aggregates
The JDBC starter scans for @EventSourced classes at startup to build its event-table registry.
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), configure additional packages with the spring.ddd.eventsourcing.base-packages property:
spring:
ddd:
eventsourcing:
base-packages:
- com.example.banking
- com.example.payments
Declaring scan locations with @EventSourcedScan
As an alternative to (or alongside) the property, annotate any @Configuration class with
@EventSourcedScan:
import de.dwittkoetter.ddd.eventsourcing.jdbc.store.table.EventSourcedScan
import org.springframework.context.annotation.Configuration
@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 with
spring.ddd.eventsourcing.base-packages.
When at least one scan location is declared (annotation or property), it replaces the Spring Boot
auto-configuration default — aggregates outside the @SpringBootApplication package tree are
discovered and the application package is not scanned implicitly.
Auto schema creation
With spring.ddd.eventsourcing.schema.auto=true (the default), the starter creates all required
objects on startup — idempotently:
-
The
EVENT_GLOBAL_POSITIONsequence (or allocator table on MySQL/MariaDB). -
One event table for every distinct resolved event-table name across all scanned
@EventSourcedaggregates. -
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_STOREandAGGREGATE_SNAPSHOTtables 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
@EventSourcedaggregate is scanned, nothing is created — not evenEVENT_STOREor the global position object. A projection-only (read-side) application that consumes events must therefore have the producing aggregates'@EventSourcedtypes on its classpath and within a scanned package (via a shared domain module, orspring.ddd.eventsourcing.base-packages).
Set spring.ddd.eventsourcing.schema.auto=false when managing the schema with Flyway, Liquibase, or
another migration tool.
Per-dialect DDL
The per-dialect EVENT_GLOBAL_POSITION DDL and the event and snapshot table DDL are listed in the
appendix. Disable spring.ddd.eventsourcing.schema.auto to manage the schema yourself with Flyway,
Liquibase, or another migration tool, and 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.