The aggregate model

An aggregate is a consistency boundary — a root entity and the objects it owns, changed as a unit (see Building blocks & stereotypes).

A JPA aggregate stores its current state as a plain JPA entity, updated in place on every command. This is the alternative persistence model to event sourcing: the same aggregate concept, backed by Hibernate and a relational table rather than an event log. JPA aggregate support is provided by spring-ddd-cqrs-jpa / spring-ddd-starter-cqrs-jpa, or the full bundle spring-ddd-starter-jpa.

Classic Spring Data, plus a thin layer

JPA aggregate persistence is classic Spring Data JPA, not a Spring DDD invention. The foundational mechanisms — @Entity, JpaRepository, @Version optimistic locking, and the AbstractAggregateRoot event buffer with its @DomainEvents / @AfterDomainEventPublication publish-on-save contract — are provided by Spring Data.

Spring DDD adds a thin layer on top:

  • AbstractAggregateRootEntity<A, ID> (spring-ddd-cqrs-jpa) — a @MappedSuperclass extending Spring Data’s AbstractAggregateRoot, inheriting the event buffer and registerEvent. It contributes a @Version var version: Long? column so every subclass gets optimistic locking automatically, without any extra annotation.

  • @Aggregate — the domain-identity stereotype (from jMolecules), marking the class as an aggregate root. Recommended for domain clarity; required when you use the auto-managed @CommandHandler bridge, but not needed for JPA persistence or event publication on its own.

  • Auto-managed @CommandHandler — the optional cqrs-jpa bridge that loads a JPA aggregate before a command method and saves it after.

Defining a JPA aggregate

Annotate the class with @Entity and extend AbstractAggregateRootEntity. The @Aggregate stereotype is recommended for domain clarity and is required when you use the auto-managed @CommandHandler bridge, but it is not needed for JPA persistence or event publication on its own:

@Entity
@Table(name = "bank_accounts")
@Aggregate(namespace = "banking")
class BankAccount(
    @EmbeddedId
    @AttributeOverride(name = "value", column = Column(name = "id", nullable = false, updatable = false))
    @AggregateId
    override var id: AccountId = AccountId(),
) : AbstractAggregateRootEntity<BankAccount, AccountId>() {

    var balance: BigDecimal = BigDecimal.ZERO

    fun open(initialBalance: Money): BankAccount {
        balance = initialBalance.value
        registerEvent(AccountOpened(id, initialBalance))
        return this
    }

    fun deposit(cmd: DepositMoney) {
        balance = balance.add(cmd.amount.value)
        registerEvent(MoneyDeposited(id, cmd.amount))
    }
}

What AbstractAggregateRootEntity contributes versus what comes from Spring Data:

Source What you get

Spring Data AbstractAggregateRoot

registerEvent(event) to buffer events; @DomainEvents / @AfterDomainEventPublication to publish and clear them on save.

AbstractAggregateRootEntity

@Version var version: Long? for optimistic locking; equals by aggregate id; hashCode stable per class (JPA-safe).

The @EmbeddedId + @AttributeOverride pattern maps a value-class identifier (AccountId) into the aggregate’s own table column. nullable = false, updatable = false are the correct JPA flags for a primary key: it is assigned at construction and never changes.

registerEvent is inherited from Spring Data’s AbstractAggregateRoot. Events are published after the repository calls save, then the buffer is cleared automatically. See Event-Sourcing for the event-sourced model where events are the persistence unit, not a side effect of save.

Choosing JPA vs event sourcing

Both persistence models express the same aggregate concept and plug into the same command and projection runtimes. Choose one per aggregate — mixing both on the same aggregate class is not supported.

JPA aggregate Event-sourced aggregate

State stored as current values in a single table row.

State rebuilt by replaying an append-only event log.

Simple reads; no replay overhead.

Full audit trail; time-travel queries.

History not retained after each save.

Every state transition is a durable event.

Familiar Spring Data / Hibernate model.

Requires the event-sourcing mental model.

Use JPA aggregates when you want a familiar relational model and do not need a full event history. Use event sourcing when an audit trail, temporal queries, or a replay-based projection rebuild are central to the domain.

See Event-Sourcing for the event-sourced aggregate model.