V10 Quick Start
Wow V10 is the next major version of Wow: you write the domain as plain Kotlin classes (commands, events, a state and an aggregate that decides), and the compiler turns them into models and a Manifest that a Spring Boot host serves over HTTP. It is a rewrite on a new kernel, log and cluster, so its API, configuration and storage differ from 9.x.
10.0.0-alpha.3 is for evaluation only
- No compatibility promise. An alpha may change the API, the configuration, the HTTP routes and the storage and wire formats of the alpha before it.
- No migration from 9.x yet. Importing 9.x event streams is unfinished (milestone M8): do not migrate production data to an alpha.
- Try it, and tell us. Use it to evaluate V10 and report what does not work in the issues. The rest of this site, outside the V10 section, describes 9.x.
This page builds a small bank service in one Gradle module: an account aggregate, its test, and a server on the memory backend. The eShop is the full example.
What you need
- JDK 21.
- Gradle 9 with the Kotlin JVM plugin. The Wow Gradle plugin applies KSP and the Wow compiler for you.
Set up Gradle
settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "bank"build.gradle.kts:
plugins {
kotlin("jvm") version "2.4.21"
// Applies KSP with wow-compiler of the same version, and assembles the service's Manifest.
id("me.ahoo.wow") version "10.0.0-alpha.3"
application
}
kotlin {
jvmToolchain(21)
}
application {
mainClass.set("me.ahoo.wow.example.quickstart.BankServerKt")
}
dependencies {
// The V10 BOM: one version for every Wow module.
implementation(platform("me.ahoo.wow:wow-bom:10.0.0-alpha.3"))
// The kernel the generated models build on.
implementation("me.ahoo.wow:wow-kernel")
// The Spring Boot host: auto-configuration, the HTTP routes, the memory backend.
implementation("me.ahoo.wow:wow-spring-boot")
// The aggregate test DSL, package me.ahoo.wow.test.
testImplementation("me.ahoo.wow:wow-test")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("me.ahoo.test:fluent-assert-core:1.2.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}The plugin is published to Maven Central with its marker, so pluginManagement needs mavenCentral(). Maven Central has only exact versions of an alpha: write 10.0.0-alpha.3, not a range. A real service splits into an -api, a -domain and a -server module (Write a V10 Service); one module is enough to try V10.
Write the commands and events
A context is named once, with @BoundedContext. Commands and events are plain classes; a command carries no aggregate id, which comes from the route.
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.annotation.BoundedContext
import me.ahoo.wow.api.annotation.CreateAggregate
import me.ahoo.wow.api.annotation.Summary
@BoundedContext(name = "bank", description = "Accounts and their balances")
object BankBoundedContext
@CreateAggregate
@Summary("Open an account")
data class OpenAccount(val owner: String)
@Summary("Deposit money")
data class Deposit(val amount: Long)
@Summary("Withdraw money")
data class Withdraw(val amount: Long)
@Summary("An account was opened")
data class AccountOpened(val owner: String)
@Summary("Money was deposited")
data class Deposited(val amount: Long)
@Summary("Money was withdrawn")
data class Withdrawn(val amount: Long)Write the state and the aggregate
The state applies events in its onSourcing functions. The aggregate decides each command in an onCommand function: it returns the events, or a Decision that may reject the command with an error code. A rejection writes nothing.
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.annotation.AggregateRoot
import me.ahoo.wow.api.annotation.ErrorCode
import me.ahoo.wow.api.annotation.OnCommand
import me.ahoo.wow.api.decide.Decision
class AccountState {
var owner: String = ""
private set
var balance: Long = 0
private set
fun onSourcing(event: AccountOpened) {
owner = event.owner
}
fun onSourcing(event: Deposited) {
balance += event.amount
}
fun onSourcing(event: Withdrawn) {
balance -= event.amount
}
}
@AggregateRoot
class Account(private val state: AccountState) {
fun onCommand(command: OpenAccount): AccountOpened = AccountOpened(command.owner)
@OnCommand(produces = [Deposited::class])
fun onCommand(command: Deposit): Decision =
if (command.amount <= 0) {
Decision.reject(Rejections.INVALID_AMOUNT, "${command.amount} is not positive.")
} else {
Decision.events(Deposited(command.amount))
}
@OnCommand(produces = [Withdrawn::class])
fun onCommand(command: Withdraw): Decision = when {
command.amount <= 0 ->
Decision.reject(Rejections.INVALID_AMOUNT, "${command.amount} is not positive.")
command.amount > state.balance ->
Decision.reject(Rejections.INSUFFICIENT_BALANCE, "The balance is ${state.balance}.")
else -> Decision.events(Withdrawn(command.amount))
}
/** The codes it rejects with, by local name: the Manifest and its answers say `bank.account.invalid-amount`. */
object Rejections {
@ErrorCode(description = "An amount is always positive.")
const val INVALID_AMOUNT = "invalid-amount"
@ErrorCode(description = "The balance does not cover the withdrawal.", status = 409)
const val INSUFFICIENT_BALANCE = "insufficient-balance"
}
}@OnCommand(produces) tells the Manifest which events a function returning Decision can produce. Programming Model has the full rules.
Test it
The compiler generated accountModel beside Account. aggregateSpec runs it on the real kernel with an in-memory log:
package me.ahoo.wow.example.quickstart
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.aggregateSpec
import org.junit.jupiter.api.Test
class AccountSpec {
private val model = accountModel("bank")
@Test
fun `opening an account names its owner`() = aggregateSpec(model, id = "alice") {
whenCommand(OpenAccount("Alice")) {
expectEvents(AccountOpened("Alice"))
expectState { owner.assert().isEqualTo("Alice") }
}
}
@Test
fun `a deposit adds to the balance`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"))
whenCommand(Deposit(100)) {
expectEvents(Deposited(100))
expectState { balance.assert().isEqualTo(100) }
}
}
@Test
fun `a withdrawal the balance does not cover is rejected`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"), Deposited(100))
whenCommand(Withdraw(150)) { expectRejected(Account.Rejections.INSUFFICIENT_BALANCE) }
whenCommand(Withdraw(30)) {
expectEvents(Withdrawn(30))
expectState { balance.assert().isEqualTo(70) }
}
}
}./gradlew testTesting lists every expectation.
Run the service
The server is a Spring Boot application. The host finds the generated models on the class path and serves them:
package me.ahoo.wow.example.quickstart
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication(proxyBeanMethods = false)
class BankServer
fun main(args: Array<String>) {
runApplication<BankServer>(*args)
}src/main/resources/application.yaml runs one node with its data in memory, and serves the snapshot queries from memory too (wow.query.memory.enabled):
spring:
application:
name: bank-service
server:
port: 8080
wow:
# One node, its data in memory: for trying the service out, not for production.
backend: memory
# Serve the snapshot queries from memory too.
query:
memory:
enabled: true
internal:
port: 9090./gradlew runThe node serves the public routes on port 8080 and its internal port on 9090. It warns that its data is in memory, and that every request is the anonymous caller until the application declares a CallerResolver bean (see HTTP API).
Send commands over HTTP
Each command has a route, POST /<context>/<aggregate>/<id>/<command>, its name in kebab case:
curl -X POST localhost:8080/bank/account/alice/open-account \
-H 'Content-Type: application/json' -d '{"owner":"Alice"}'
curl -X POST localhost:8080/bank/account/alice/deposit \
-H 'Content-Type: application/json' -d '{"amount":100}'
curl -X POST localhost:8080/bank/account/alice/withdraw \
-H 'Content-Type: application/json' -d '{"amount":150}'A command answers its CommandResult ("status":"SUCCEEDED", the new version) once the snapshot holds its commit, so a read right after it sees the change. The withdrawal is rejected: the answer is an RFC 9457 problem, status 409, "type":"urn:wow:error:bank.account.insufficient-balance".
Query the snapshots
Read one aggregate's snapshot, then query them all with a filter:
curl localhost:8080/bank/account/alice/state
curl -X POST localhost:8080/bank/account/snapshot/query \
-H 'Content-Type: application/json' \
-d '{"kind":"list","filter":{"op":"GTE","field":"state.balance","value":100}}'The first answers the version and the state, {"balance":100,"owner":"Alice"}; the second a list of snapshots, alice's among them. Query Language describes the filters, sorts, paging and the other kinds of query. On the memory backend the queries are served only with wow.query.memory.enabled; PostgreSQL and MongoDB serve them themselves (Deployment).
Send commands in process
Application code sends commands through the node's CommandGateway, a bean of the host. It runs the same admission as the HTTP routes:
package me.ahoo.wow.example.quickstart
import me.ahoo.wow.api.result.CommandResult
import me.ahoo.wow.runtime.admission.CommandBody
import me.ahoo.wow.runtime.admission.CommandGateway
import me.ahoo.wow.runtime.admission.CommandRequest
import org.springframework.stereotype.Service
@Service
class Teller(private val commands: CommandGateway) {
suspend fun deposit(accountId: String, amount: Long): CommandResult {
val request = CommandRequest.builder(
context = "bank",
aggregate = "account",
command = "Deposit",
body = CommandBody.of("""{"amount":$amount}"""),
).aggregateId(accountId).build()
return commands.execute(request)
}
}execute waits as an HTTP command does and returns the last result; send returns each one as a Flow.
Where to go next
- The full example. The eShop is five services on PostgreSQL, Kafka and ClickHouse: sagas between contexts, scheduled commands, query indexes, datasets and a web UI.
- The TypeScript client.
npm install @ahoo-wang/wow-client@alphainstalls the client of the same alpha; wow-generator generates typed command and query clients from a service's Manifest. - The V10 guide. Write a V10 Service lays out a service in modules, then Programming Model, Testing, HTTP API, Configuration Keys and Deployment.
- This sample. It is
example/quickstartin the Wow repository, built and tested with every change.