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.