Quick start
This page builds one runnable slice with spring-ddd-starter-jpa on PostgreSQL: an event-sourced bank account, a read model in a JPA entity, and a query that reads it.
1. Dependencies
Add the starter and the PostgreSQL driver, with the Kotlin Spring and JPA compiler plugins:
plugins {
kotlin("jvm") version "2.4.21"
kotlin("plugin.spring") version "2.4.21"
kotlin("plugin.jpa") version "2.4.21"
id("org.springframework.boot") version "4.1.1"
}
repositories {
mavenCentral()
}
dependencies {
implementation("de.dwittkoetter:spring-ddd-starter-jpa:0.0.1-SNAPSHOT")
runtimeOnly("org.postgresql:postgresql")
}
How to obtain the starter is described under Obtaining the artifacts.
Start a local PostgreSQL:
docker run -d --name banking-db -e POSTGRES_USER=banking -e POSTGRES_PASSWORD=banking -e POSTGRES_DB=banking -p 5432:5432 postgres:17
and point the application at it in application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/banking
spring.datasource.username=banking
spring.datasource.password=banking
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=update lets Hibernate create the read model’s table, for local development only: in production the application’s migrations create it.
Spring DDD creates its own tables at startup; see Database schema.
2. Commands and events
AccountId and Money are the smallest value objects that work here: AccountId is also the read model’s embedded id.
@Embeddable
data class AccountId(val value: UUID = UUID.randomUUID())
data class Money(val amount: BigDecimal) {
operator fun plus(other: Money) = Money(amount + other.amount)
}
Mark commands with @Command and events with @DomainEvent; @AggregateId marks the account each one belongs to.
@Command
data class OpenAccount(@AggregateId val accountId: AccountId, val initialBalance: Money)
@Command
data class DepositMoney(@AggregateId val accountId: AccountId, val amount: Money)
@DomainEvent(namespace = "banking")
data class AccountOpened(@AggregateId val accountId: AccountId, val initialBalance: Money)
@DomainEvent(namespace = "banking")
data class MoneyDeposited(@AggregateId val accountId: AccountId, val amount: Money)
3. The aggregate
Mark the aggregate with @Aggregate and extend EventSourcingAggregateRoot, which makes it event-sourced and gives it registerEvent.
A @CommandHandler constructor opens a new account, and a @CommandHandler method changes an existing one: the framework creates or loads the aggregate, calls the handler, and saves it.
Each handler only registers events; the @EventSourcingHandler methods fold them into state.
The @CommandHandler constructor sets id before it registers the first event.
@Aggregate(namespace = "banking")
class BankAccount private constructor() : EventSourcingAggregateRoot<BankAccount, AccountId>() {
override lateinit var id: AccountId
var balance = Money(BigDecimal.ZERO)
private set
@CommandHandler
constructor(command: OpenAccount) : this() {
id = command.accountId
registerEvent(AccountOpened(command.accountId, command.initialBalance))
}
@CommandHandler
fun deposit(command: DepositMoney) {
registerEvent(MoneyDeposited(command.accountId, command.amount))
}
@EventSourcingHandler
fun on(event: AccountOpened) {
id = event.accountId
balance = event.initialBalance
}
@EventSourcingHandler
fun on(event: MoneyDeposited) {
balance += event.amount
}
}
interface BankAccountRepository : EventSourcingRepository<BankAccount, AccountId>
The repository interface needs no registration: Spring DDD finds it in the application’s package. The application class is the usual Spring Boot one:
@SpringBootApplication
class BankingApplication
fun main(args: Array<String>) {
runApplication<BankingApplication>(*args)
}
4. A read model
The read model is a JPA entity with an embedded id and a nullable @Version.
A @JpaProjection keeps it up to date: each @ProjectionListener receives an event and the read model stored under the event’s account.
Read it through a Spring Data repository.
@Entity
class AccountSummary(
@EmbeddedId var accountId: AccountId = AccountId(),
@Version var version: Long? = null,
var balance: BigDecimal = BigDecimal.ZERO,
)
@JpaProjection(name = "account-summary")
class AccountSummaryProjection {
@ProjectionListener
fun on(event: AccountOpened, summary: AccountSummary) {
summary.balance = event.initialBalance.amount
}
@ProjectionListener
fun on(event: MoneyDeposited, summary: AccountSummary) {
summary.balance += event.amount.amount
}
}
interface AccountSummaryRepository : JpaRepository<AccountSummary, AccountId>
See JPA store for the rules a read-model entity follows.
5. A query
@Query
data class GetAccountBalance(val accountId: AccountId)
@Component
class AccountQueries(private val summaries: AccountSummaryRepository) {
@QueryHandler
fun balance(query: GetAccountBalance): BigDecimal? = summaries.findByIdOrNull(query.accountId)?.balance
}
6. Run it
Inject CommandGateway and QueryGateway where the application is driven, for example from a controller:
@ApplicationService
class AccountService(
private val commands: CommandGateway,
private val queries: QueryGateway,
) {
fun open(accountId: AccountId, initialBalance: Money) = commands.send(OpenAccount(accountId, initialBalance))
fun deposit(accountId: AccountId, amount: Money) = commands.send(DepositMoney(accountId, amount))
fun balance(accountId: AccountId): BigDecimal? = queries.query(GetAccountBalance(accountId))
}
send applies the command before it returns.
The query sees the deposit once the projection has applied it, about two seconds later with the defaults; see Eventual consistency.
@ApplicationService marks the application layer and is a Spring stereotype; see Application layering.
7. Next steps
-
Event sourcing: snapshots, schema evolution and per-aggregate event tables.
-
JPA aggregates: aggregates stored as JPA entities instead of events.
-
CQRS: commands and queries in depth, including handlers in components of their own.
-
Projections: keys, stores, ad-hoc projections and rebuilding.
-
Testing: testing aggregates and waiting for projections.