Tutorial: Two Contexts End to End
The Quickstart builds one service. A system is several, each a bounded context that owns its aggregates, talking through commands and events. This tutorial follows one flow of the eShop in the Wow repository, a buyer checking out a basket that becomes an order, from the basket context into the ordering context, and back out to a read model and a web page. Every sample is the eShop's own code.
Run it
From a clone of the Wow repository, with JDK 21, start the eShop's five services in one JVM on the memory backend, their topics kept in process:
./gradlew :eshop-e2e:eshopLocalThen take ten mugs into stock, which the order will reserve, add two to Alice's basket and check it out, waiting until the order the checkout submits is committed in the ordering context:
BUYER=(-H 'Wow-User: alice' -H 'Wow-Owner: alice')
curl -s -X POST localhost:8084/eshop-inventory/stock/mug/receive-stock \
-H 'Content-Type: application/json' -d '{"quantity":10}'
curl -s -X POST localhost:8082/eshop-basket/basket/add-basket-item "${BUYER[@]}" \
-H 'Content-Type: application/json' -d '{"productId":"mug","unitPrice":8.50,"quantity":2}'
ORDER=$(curl -s -X POST localhost:8082/eshop-basket/basket/checkout-basket "${BUYER[@]}" \
-H 'Content-Type: application/json' -d '{}' \
-H 'Wow-Wait: committed > handled(eshop-basket/BasketCheckoutSaga.onBasketCheckedOut) > committed(eshop-ordering)' |
jq -r .ref.id)
echo "$ORDER" # SO-261031-1
curl -s "localhost:8083/eshop-ordering/order/$ORDER/state" "${BUYER[@]}"A buyer has one basket, whose id is the buyer's user id, so the basket's routes name no id: they mean the caller's own basket, alice. The ordering context numbers the order the checkout submits, SO-261031-1: the day it was issued, then a sequence. The checkout waits for the order's commit in the ordering context, so its answer is that commit's result, and the order's number is its aggregate id. In development the servers read the caller from the Wow-User and Wow-Owner headers to keep the example short; a real service authenticates it (Secure a Deployment). The eShop README runs the services apart, on Kafka and PostgreSQL.
1. The basket decides
A command states the shape it must have with Jakarta constraints, checked when it is admitted, before any aggregate sees it; one that breaks them is refused with wow.command.invalid:
/**
* Adds [quantity] of [productId] at [unitPrice] to the basket, creating the basket on its first item. Adding a product
* already in the basket adds to its quantity, at the new price. The constraints are the command's shape: the gateway
* checks them before the basket decides, and answers a violation `wow.command.invalid` (400).
*/
@AllowCreate
@Summary("Add an item to the basket")
data class AddBasketItem(
val productId: String,
@field:PositiveOrZero val unitPrice: BigDecimal,
@field:Positive val quantity: Int,
)The basket context's Basket aggregate then takes the command, looks at its state, and decides the events. A basket's id is its owner's (@AggregateRoot(idSource = AggregateIdSource.OWNER)), so the kernel already keeps each buyer to its own basket, and adding an item has nothing left to check (One aggregate per owner):
fun onCommand(command: AddBasketItem): BasketItemAdded =
BasketItemAdded(command.productId, command.unitPrice, command.quantity)Checking out refuses an empty basket, and empties it into the event the saga below turns into an order:
@Suppress("UnusedParameter") // The command has no body: its type is what the function handles.
fun onCommand(command: CheckoutBasket): BasketCheckedOut {
if (state.items.isEmpty()) {
reject(Rejections.EMPTY, "The basket is empty.")
}
return BasketCheckedOut(state.items)
}Neither function writes anything. The kernel appends the events in one commit, at the next version, or refuses the command with its rejection code (Aggregates).
2. A saga crosses into ordering
The basket context does not create orders: the ordering context owns them. A saga in the basket context handles BasketCheckedOut and calls the ordering context's SubmitOrder, whose class it knows from the ordering context's *-api jar:
@OnEvent
@Sends(calls = [SubmitOrder::class])
suspend fun onBasketCheckedOut(event: StateTypedEvent<BasketState, BasketCheckedOut>, context: SagaContext) {
val checkedOut = event.event
context.call(
CommandRequest.of(SubmitOrder(checkedOut.items.map { OrderItem(it.productId, it.unitPrice, it.quantity) })),
)
}The call names no order: the ordering context numbers the order it creates from its order numbers' generator. context.call gives the command a request id derived from the commit, so a retry of the saga sends the same command, and the ordering context answers it as a duplicate, with the same order and number, instead of creating a second order. calls = [SubmitOrder::class] declares the call in the contract, and the service refuses to start when no peer decides it (Sagas).
In the ordering context, SubmitOrder's constraints were checked on admission, so the Order aggregate records the order:
fun onCommand(command: SubmitOrder): OrderSubmitted = OrderSubmitted(command.items)From there the ordering context's own saga reserves the stock in the inventory context and asks the payment context for the payment, each step a command and the events it commits (The order flow).
3. A read model follows
Every order is queryable from its snapshot without a line of code, but a buyer's order list wants its own shape. A projection follows the ordering context's log and keeps a summary per order, guarded by the version so a replayed commit never moves it back:
@Blocking
@OnEvent
fun onEvent(event: StateEvent<OrderState>) {
val state = event.state
summaries.save(
OrderSummary(
tenantId = event.ref.tenantId,
orderId = event.ref.id,
buyerId = event.scope.ownerId,
status = state.status,
totalAmount = state.totalAmount,
units = state.quantities().values.sum(),
updatedAt = event.commitTime,
),
version = event.version,
)
}A projection that fails is retried, then parks its one order in the failure ledger; the others go on (Projections). The summaries can be dropped and rebuilt from the log at any time.
The orders themselves are queried through the snapshot query route, here Alice's paid orders:
curl -s -X POST localhost:8083/eshop-ordering/order/snapshot/query "${BUYER[@]}" \
-H 'Content-Type: application/json' \
-d '{"kind":"list","filter":{"op":"eq","field":"state.status","value":"PAID"}}'Each caller sees only its own orders: the order took its owner from the command that created it (Query Snapshots).
4. The web page waits
The eShop's web UI calls the services through TypeScript clients generated from their Manifests (Generate a Client). Its checkout sends CheckoutBasket with a wait that follows the whole chain, every saga on the way and the commit it leads to, until the payment is recorded on the order, and shows each hop as it arrives:
/**
* Checks the buyer's own basket out, waiting for the whole chain as server-sent events:
* `onProgress` gets each hop's result as it arrives. An admission refusal
* (an empty basket, say) rejects with its problem (a `WowProblemError`); a hop that does
* not succeed ends the progress with its result as the `failure`, and a chain
* that ends before its plan does with its last result as `ended`.
*/
export async function checkout(
clients: ShopClients,
onProgress: (progress: CheckoutProgress) => void,
): Promise<CheckoutProgress> {
const stream = await clients.basket.basket.commands.checkoutBasketOwnStream(
{},
{
wait: WaitPlan.of(...CHECKOUT_TO_PAID.map((step) => step.hop)),
timeout: CHECKOUT_TIMEOUT,
},
);
const results: CommandResult[] = [];
let progress: CheckoutProgress = { results, done: false };
const reader = stream.getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
results.push(value);
progress = {
results: [...results],
orderId: progress.orderId ?? orderIdOf(value),
failure:
value.status === CommandResultStatus.SUCCEEDED ? undefined : value,
ended: value.chainEnded ? value : undefined,
done: false,
};
onProgress(progress);
if (progress.failure || progress.ended) break;
}
progress = { ...progress, done: true };
onProgress(progress);
return progress;
}A refusal at admission, an empty basket say, rejects with its problem; a hop that does not succeed ends the progress with its result; and a chain a saga ends early, a declined payment, ends with the hops that will not come (Send Commands).
What you have seen
Two contexts, each a service with its own log, wrote nothing outside their aggregates: the routes, the cross-context command, the duplicate check, the read model's retries, the query and the typed client came from the domain model. Next: Guide for each of these in depth, or Running Wow to deploy them.