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

@Aggregate(namespace = …)

yes

Stable domain identity; see @Aggregate properties.

@EventSourced

yes

Activates the event-sourcing persistence path. Inherited when extending EventSourcingAggregateRoot; declare it explicitly on POJO aggregates or to override the snapshot policy.

@AggregateId field

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.

@Version Long field

yes

Managed by the repository; do not mutate in business code.

@DomainEvents / @AfterDomainEventPublication methods

yes

Provided by AbstractAggregateRoot (all base-class options below). POJO aggregates must implement both — @AfterDomainEventPublication must clear the event collection.

@EventSourcingHandler method per event type

yes

Folds events into state on save and on replay; see @EventSourcingHandler.

No-arg (all-params-optional) constructor

yes

Used by the repository to create a blank instance before replaying the event stream.

@AggregateDeleted Boolean field

no

Opt-in logical-deletion marker; see [deleting-an-aggregate].

Base-class options

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:

  1. Dispatches each pending event to its @EventSourcingHandler, folding events into state.

  2. Appends the events atomically to the event store.

  3. Publishes the events as Spring application events.

  4. 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, EventSourcingHandlerNotFoundException is 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

namespace

yes

Bounded-context identifier (e.g. "banking"). Together with name, forms the stable type key $namespace.$name stored in the event log. Choose a value independent of the JVM package path so it stays stable across refactorings.

name

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 save again throws AggregateDeletedException. 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 AccountClosed event to remove the corresponding read model.

  • Opt-in. An aggregate without @AggregateDeleted has no deletion concept and behaves unchanged.