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;
the buffered events are published right after the aggregate is saved, then the buffer is cleared.
That holds whether your own Spring Data repository saves the aggregate or an auto-managed @CommandHandler does.
|
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 — triggering downstream sagas, or applying cross-cutting interceptors — see Domain Events and Message interceptors.