Kotlin Order and Cart
This page is a traceable reference for the real example, not a second simplified model. Every API contract, domain decision, runtime step, and HTTP result links back to source or tests.
What You Will Learn
- the
api → domain → serverdependency direction and responsibilities; - why the command aggregate decides while the state aggregate only sources events;
- how
OrderCreateddrives state, projections, and the cart Saga; - how to verify HTTP routes from generated OpenAPI instead of bounded-context names;
- evidence for success, validation failure, illegal state, and duplicate payment.
Module Map
| Module | Responsibility | Exact source |
|---|---|---|
example-api | The example-service context, order/cart aggregates, commands, events, and values | ExampleService.kt, CreateOrder.kt |
example-domain | Invariants, command handling, sourcing, Sagas, and domain tests | Order.kt, OrderState.kt |
example-server | Spring Boot wiring, generated WebFlux routes, projections, and queries | ExampleServer.kt, OrderProjector.kt |
1. Declare the Context and Aggregates
ExampleService declares context name example-service, alias example, and maps order and cart contracts with packageScopes. The order implementation adds @AggregateRoute(resourceName = "sales-order", spaced = true, owner = ALWAYS).
Both declarations participate in route generation. Neither example-service nor order alone is enough to infer the URL.
2. Separate Intent from Fact
| Command | Domain decision | Event | State change |
|---|---|---|---|
CreateOrder | Country must be China; items must exist; inventory and price specifications must pass | OrderCreated | Set items/address/totalAmount and CREATED |
ChangeAddress | Allowed only in CREATED | AddressChanged | Replace address; status unchanged |
PayOrder | Allowed only in CREATED; supports partial and excess payment signals | OrderPaid, plus OrderOverPaid when needed | Add paidAmount; become PAID when fully paid |
ShipOrder | Allowed only in PAID | OrderShipped | SHIPPED |
ReceiptOrder | Allowed only in SHIPPED | OrderReceived | RECEIVED |
CreateOrder is caller intent; OrderCreated is an immutable business fact. Callers never submit the event directly. See the complete order API package.
3. Keep Business Decisions in the Aggregate
Order.onCommand(CreateOrder) runs each item specification, assigns OrderItem.id, returns OrderCreated, and publishes totalAmount in the command result. It never mutates OrderState directly.
payable >= amount -> OrderPaid(amount, fullyPaid)
payable < amount -> OrderPaid(payable, true), OrderOverPaid(paymentId, excess)
status != CREATED -> OrderPayDuplicated error eventThe list order is the publication order. External refund work consumes OrderOverPaid; it does not belong inside an aggregate transaction.
4. Change State Only Through Events
All mutable properties in OrderState have private setters. onSourcing rebuilds state deterministically:
OrderCreated -> CREATED, totalAmount = sum(item.totalPrice)
OrderPaid -> paidAmount += amount; PAID when fully paid
OrderShipped -> SHIPPED
OrderReceived -> RECEIVEDpayable is derived as totalAmount - paidAmount. Sourcing functions do not query a database, call a remote service, or read the clock, so the same event history always yields the same state.
5. Connect Aggregates with a Saga
CartSaga emits RemoveCartItem only when OrderCreated.fromCart == true, using the event owner as the cart aggregate ID:
Saga success proves that the downstream command was sent; it does not make two aggregates an ACID transaction. @Retry(maxRetries = 5, minBackoff = 60, executionTimeout = 10) also requires idempotent downstream handling.
6. Projections and Event Processors
OrderProjector shows domain-event and state-event projections. OrderEventProcessor shows a general subscription. The current handlers mainly log; they demonstrate registration and dispatch, not a production read model.
7. Run the Tests
./gradlew :example-domain:checkGradle should end with BUILD SUCCESSFUL. OrderSpec covers create, full/duplicate payment, shipping, receipt, address change, deletion, inventory shortage, and price mismatch. OrderTest.should handle over payment proves the real overpayment branch while the order is still CREATED. CartSagaSpec covers both the command and no-command branches.
8. Start the Service and Send a Command
The default configuration uses MongoDB. For a local single-process proof, select in-memory storage and disable PrepareKey, whose default still requires MongoDB:
mkdir -p example/example-server/logs
test -e example/example-server/config || \
ln -s src/main/resources example/example-server/config
SERVER_PORT=8080 \
WOW_EVENTSOURCING_STORE_STORAGE=in_memory \
WOW_EVENTSOURCING_SNAPSHOT_STORAGE=in_memory \
WOW_PREPARE_ENABLED=false \
./gradlew :example-server:runExpect Netty started on port 8080 and Started ExampleServerKt. Current generated OpenAPI maps operation example.order.create_order to POST /tenant/{tenantId}/owner/{ownerId}/sales-order.
curl -X POST \
'http://localhost:8080/tenant/tenant-1/owner/customer-1/sales-order' \
-H 'Content-Type: application/json' \
-H 'Wow-Space-Id: store-1' \
-H 'Command-Aggregate-Id: order-1' \
-H 'Command-Request-Id: create-order-1' \
-H 'Command-Wait-Stage: SNAPSHOT' \
-d '{"items":[{"productId":"product-1","price":10,"quantity":2}],"address":{"country":"China","province":"Shanghai","city":"Shanghai","district":"Pudong","detail":"Road 1"},"fromCart":false}'Expected key result fields:
{
"succeeded": true,
"stage": "SNAPSHOT",
"aggregateId": "order-1",
"aggregateVersion": 1,
"result": { "totalAmount": 20 }
}curl 'http://localhost:8080/tenant/tenant-1/owner/customer-1/sales-order/order-1/state'Expect status=CREATED, totalAmount=20, paidAmount=0, and payable=20. In-memory mode loses data when the process exits. Use an isolated MongoDB and the default storage settings to verify restart recovery.
Failure behavior is part of the contract: empty items or a non-China address fail validation; inventory or price failure does not create an aggregate; shipping before PAID and receipt before SHIPPED fail; payment after deletion returns deleted-aggregate access failure. OrderSpec owns these assertions, so controllers do not duplicate them.
The sample is not a security baseline
Generated routes are technical contracts, not proof of production authentication or authorization. Configure command authorization, tenant/owner/space binding, and fail-closed query tests before deployment.
Completion Criteria
- trace
CreateOrderthroughOrderCreated,OrderState, projection, andCartSaga; - pass
:example-domain:check; - obtain the route from current
/v3/api-docs, not a context-name guess; - reach
SNAPSHOTand read backCREATEDplus the amounts; - identify validation, illegal-state, duplicate-payment, and in-memory restart boundaries.