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@MappedSuperclassextending Spring Data’sAbstractAggregateRoot, inheriting the event buffer andregisterEvent. 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@CommandHandlerbridge, 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 |
|
|
|
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.
|
|
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 |
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.