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

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, BankAccountBANK_ACCOUNT). Each parameter resolves independently.

Resolution rules

Declaration Event table Snapshot table

(absent)

EVENT_STORE

AGGREGATE_SNAPSHOT

@EventTable

<NAME>_EVENTS

<NAME>_SNAPSHOTS

@EventTable("X")

X

<NAME>_SNAPSHOTS

@EventTable(snapshots = "Y")

<NAME>_EVENTS

Y

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

X

Y

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 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>_SNAPSHOTS identifiers 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.

EVENT_GLOBAL_POSITION must be created before any event table that references it, because the global_position column’s default expression depends on it. When using spring.ddd.eventsourcing.schema.auto=true (the default), this ordering is handled automatically.

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:

  1. The EVENT_GLOBAL_POSITION sequence (or allocator table on MySQL/MariaDB).

  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_SNAPSHOT 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 global position object. A projection-only (read-side) application that consumes events must therefore have the producing aggregates' @EventSourced types on its classpath and within a scanned package (via a shared domain module, or spring.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.