Aggregates
An aggregate is a consistency boundary — a root entity and the objects it owns, changed as a unit (see Building blocks & stereotypes).
An event-sourced aggregate persists its state as an append-only stream of domain events.
Instead of storing current state, the repository replays the event history to reconstruct the aggregate on load.
The event-sourcing feature is provided by spring-ddd-eventsourcing; add spring-ddd-starter-eventsourcing-jdbc (or the full-stack bundle spring-ddd-starter-jpa) to make it runnable.
JPA aggregates (current-state persistence) are covered under JPA Aggregates.
Annotation contract
The repository validates the aggregate’s annotation contract at startup and throws IllegalStateException with a clear message if any requirement is missing.
| Annotation / requirement | Required | Notes |
|---|---|---|
|
yes |
Stable domain identity; see |
|
yes |
Activates the event-sourcing persistence path.
Inherited when extending |
|
yes |
The aggregate’s identity field. The framework walks the class hierarchy, so re-declaring it on an overriding property is harmless but not required. |
|
yes |
Managed by the repository; do not mutate in business code. |
|
yes |
Provided by |
|
yes |
Folds events into state on save and on replay; see |
No-arg (all-params-optional) constructor |
yes |
Used by the repository to create a blank instance before replaying the event stream. |
|
no |
Opt-in logical-deletion marker; see Deleting an aggregate. |
Base-class options
EventSourcingAggregateRoot (recommended)
Extend EventSourcingAggregateRoot<Self, ID> for the minimal-ceremony path.
The base class carries @EventSourced (inherited by all subclasses), declares an abstract @AggregateId var id, declares @Version var version, and provides registerEvent via AbstractAggregateRoot — the full annotation contract is satisfied without redeclaring anything.
Override id to supply the concrete type; there is no need to repeat @AggregateId.
@Aggregate(namespace = "banking")
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
var balance: Money = Money.ZERO
private set
fun open(accountId: AccountId, initialBalance: Money) {
id = accountId
registerEvent(AccountOpened(accountId, initialBalance))
}
fun deposit(amount: Money) {
registerEvent(MoneyDeposited(id, amount))
}
fun withdraw(amount: Money) {
require(balance >= amount) { "Insufficient funds" }
registerEvent(MoneyWithdrawn(id, amount))
}
@EventSourcingHandler
fun on(event: AccountOpened) {
id = event.accountId
balance = event.initialBalance
}
@EventSourcingHandler
fun on(event: MoneyDeposited) {
balance += event.amount
}
@EventSourcingHandler
fun on(event: MoneyWithdrawn) {
balance -= event.amount
}
}
Alternatives
Spring Data AbstractAggregateRoot — extend AbstractAggregateRoot<Self> and declare @EventSourced, @AggregateId, and @Version yourself.
No self-referential <Self, ID> type bounds required; otherwise equivalent to the recommended path.
No base class (full control) — any class that carries @Aggregate + @EventSourced and implements @DomainEvents / @AfterDomainEventPublication manually.
The @AfterDomainEventPublication method must clear the event collection — omitting this causes events to be re-persisted on every subsequent save.
|
Every aggregate must have a no-arg (or all-params-optional) constructor. The repository uses it to create a blank instance before applying the event stream during replay. |
Recording events
Business methods call registerEvent(event) to mark an event as pending.
The event is neither persisted nor published until repository.save is called.
Every event an event-sourced aggregate records must be annotated @DomainEvent, directly or through an annotation that carries it; save refuses any other event, a @Command or @Query included, and stores nothing.
Apply business-rule guards before calling registerEvent;
@EventSourcingHandler methods are pure state transitions — no decisions, no external calls, no side effects beyond mutating aggregate fields.
fun withdraw(amount: Money) {
require(balance >= amount) { "Insufficient funds" } // guard first
registerEvent(MoneyWithdrawn(id, amount)) // then record
}
On save, the repository:
-
Dispatches each pending event to its
@EventSourcingHandler, folding events into state. -
Appends the events atomically to the event store.
-
Publishes the events as Spring application events.
-
Increments
@Version.
A listener that registers a further event on the same aggregate instance while its events are published fails the save with an IllegalStateException, as a Spring Data repository does.
Load the aggregate again in the listener and save that instance instead.
@EventSourcingHandler
An @EventSourcingHandler method is the state-transition handler for one event type.
It is called both when a new event is applied (during save) and when the aggregate is reconstructed from the event store (during replay).
Side effects that must not repeat on replay do not belong here.
@EventSourcingHandler
fun on(event: MoneyDeposited) {
balance += event.amount // pure state mutation
}
Rules:
-
Exactly one parameter whose type is the handled event.
-
One handler per event type; handlers are matched by parameter type.
-
If no handler matches during replay,
EventSourcingHandlerNotFoundExceptionis thrown.
Handler coverage and parameter shape are validated when an event is dispatched (on save or replay), not at startup.
Version & identity
@Version var version: Long is the persisted event count at the last save.
The repository manages it exclusively — do not modify it in business code.
Declare it as Long = 0L or Long? = null; null is treated as 0.
Set the aggregate’s id before registering its first event: saving reads the aggregate’s id before dispatching any @EventSourcingHandler, and refuses an event whose @AggregateId is not that id, or one whose @MessageKey names another value, so setting it only inside the creational handler is too late.
The typical pattern is to set it in the constructor or business method, immediately before the first registerEvent call, then override id as lateinit var; the creational @EventSourcingHandler may assign it too, so replay folds it from the event the same way.
An aggregate’s @AggregateId must be a var: the framework sets an aggregate’s id when it loads it from its events, and refuses one declared val at startup.
@Aggregate properties
| Property | Required | Default | Description |
|---|---|---|---|
|
yes |
— |
Bounded-context identifier (e.g. |
|
no |
simple class name |
Stable type name within the namespace. Defaults to the class’s simple name. |
@EventSourced carries the per-aggregate snapshot configuration via its snapshotPolicy parameter — covered in Snapshots.
Child entities
"Entity" here means the DDD concept: a child object inside the aggregate that has its own identity, marked with jMolecules' @Entity (org.jmolecules.ddd.annotation.Entity).
This is distinct from a JPA @Entity (jakarta.persistence.Entity).
Because snapshot deserialization uses Jackson’s readerForUpdating, state fields that must survive a snapshot load must be var (not val) and backed by a mutable collection:
// Correct
var transactions: MutableList<Transaction> = mutableListOf()
// Wrong — val cannot be reassigned; deserialized value is silently dropped
val transactions: MutableList<Transaction> = mutableListOf()
// Wrong — immutable collection; Jackson's .add() throws UnsupportedOperationException on load
var transactions: List<Transaction> = emptyList()
Deleting an aggregate
Deletion is an ordinary domain event — no special framework call is needed.
Declare a Boolean property annotated with @AggregateDeleted (de.dwittkoetter.ddd.annotation.AggregateDeleted);
an @EventSourcingHandler sets it to true when the business event that signals deletion is applied.
The repository then hides the aggregate: findById returns Optional.empty() and existsById returns false.
@Aggregate(namespace = "banking")
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
@AggregateDeleted
var deleted: Boolean = false
fun close() {
check(!deleted) { "Account $id is already closed" }
registerEvent(AccountClosed(id))
}
@EventSourcingHandler
fun on(event: AccountClosed) { deleted = true }
}
After save the aggregate is hidden:
val account = repository.findById(accountId).orElseThrow()
account.close()
repository.save(account)
repository.findById(accountId) // Optional.empty()
repository.existsById(accountId) // false
Semantics:
-
Terminal. A deleted aggregate cannot be reloaded or re-saved. Retaining an instance across the delete and calling
saveagain throwsAggregateDeletedException. For a reversible lifecycle, model an explicit status (e.g.ACTIVE/SUSPENDED) and do not use@AggregateDeleted— the aggregate remains visible and the domain decides what each state allows. -
Events are retained. Delete events stay in the event store for audit.
-
Opt-in. An aggregate without
@AggregateDeletedhas no deletion concept and behaves unchanged.