Domain events & optimistic locking
A JPA aggregate publishes domain events and protects against concurrent updates through two mechanisms
inherited from AbstractAggregateRootEntity: an event buffer wired by Spring Data, and a @Version
column for optimistic locking.
Publishing domain events
AbstractAggregateRootEntity extends Spring Data’s
AbstractAggregateRoot,
which supplies the domain-event buffer.
Call registerEvent(event) inside a command method to add an event to that buffer;
Spring Data will publish all buffered events right after the aggregate is saved by the repository,
then clear the buffer.
|
Domain-event publication on |
Example: registering an event during a command method
@Entity
@Table(name = "bank_accounts")
class BankAccount(
override var id: AccountId = AccountId(),
// id mapping and @Version omitted — see "The aggregate model"
) : AbstractAggregateRootEntity<BankAccount, AccountId>() {
var balance: BigDecimal = BigDecimal.ZERO
/** Deposits the given amount and records the event for publication on save. */
fun deposit(cmd: DepositMoney) {
balance = balance.add(cmd.amount.value)
registerEvent(MoneyDeposited(id, cmd.amount)) (1)
}
}
// In your command handler or application service:
val account = accountRepository.findById(cmd.accountId).orElseThrow()
account.deposit(cmd)
accountRepository.save(account) (2)
| 1 | registerEvent buffers the MoneyDeposited event.
It does not publish the event at this point — no listeners are notified yet. |
| 2 | Spring Data publishes every buffered event immediately after save completes,
then clears the buffer.
This is Spring Data’s @DomainEvents / @AfterDomainEventPublication mechanism. |
The registerEvent call and the publish-on-save behaviour are both inherited from Spring Data’s
AbstractAggregateRoot.
AbstractAggregateRootEntity adds nothing to the publication path; it only ensures every JPA
aggregate subclass starts with an event buffer ready to use.
Optimistic locking
AbstractAggregateRootEntity declares a @Version column:
@MappedSuperclass
abstract class AbstractAggregateRootEntity<A : AbstractAggregateRootEntity<A, ID>, ID : Any> : AbstractAggregateRoot<A>() {
@Version
var version: Long? = null (1)
}
| 1 | Contributed by Spring DDD’s AbstractAggregateRootEntity; every subclass table gets this column
automatically.
The field is nullable because Spring Data JPA uses null to detect a not-yet-persisted entity
with an assigned id. |
When two concurrent command handlers load the same BankAccount, modify it, and both call save,
JPA uses the version column to detect the conflict.
The first save succeeds and increments the version; the second save sees a stale version and
throws jakarta.persistence.OptimisticLockException (wrapped by Spring as
ObjectOptimisticLockingFailureException).
// Thread A and Thread B both load the same account at version 5.
val accountA = accountRepository.findById(id).orElseThrow() // version = 5
val accountB = accountRepository.findById(id).orElseThrow() // version = 5
accountA.deposit(DepositMoney(id, Money(100.toBigDecimal(), EUR)))
accountRepository.save(accountA) // succeeds — version becomes 6
accountB.deposit(DepositMoney(id, Money(50.toBigDecimal(), EUR)))
accountRepository.save(accountB) // throws OptimisticLockException — version is still 5, not 6
The caller is responsible for retrying or propagating the exception. Handle the exception at the application-service or API layer.
Where to go next
To act on the domain events published by a JPA aggregate — routing them to projections, triggering downstream sagas, or applying cross-cutting interceptors — see Domain Events and Message interceptors.