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 a full bundle such as spring-ddd-starter-jdbc)
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.
import de.dwittkoetter.ddd.annotation.Aggregate
import de.dwittkoetter.ddd.eventsourcing.EventSourcingHandler
import de.dwittkoetter.ddd.eventsourcing.aggregate.EventSourcingAggregateRoot
@Aggregate(namespace = "banking")
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
private set
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.
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.
@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.
The aggregate ID must be set before save is called.
The typical pattern is to set it in the creational @EventSourcingHandler
(or in the constructor before calling registerEvent), then override id as lateinit var.
@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.
import de.dwittkoetter.ddd.annotation.Aggregate
import de.dwittkoetter.ddd.annotation.AggregateDeleted
import de.dwittkoetter.ddd.eventsourcing.EventSourcingHandler
import de.dwittkoetter.ddd.eventsourcing.aggregate.EventSourcingAggregateRoot
@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 log for audit and projection replay; a projection can react to the
AccountClosedevent to remove the corresponding read model. -
Opt-in. An aggregate without
@AggregateDeletedhas no deletion concept and behaves unchanged.