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 add an item to Alice's basket and check it out, waiting until the order the checkout submits is committed in the ordering context:
BUYER=(-H 'X-User: alice')
curl -s -X POST localhost:8082/eshop-basket/basket/alice/add-basket-item "${BUYER[@]}" \
-H 'Content-Type: application/json' -d '{"productId":"mug","unitPrice":8.50,"quantity":2}'
curl -s -X POST localhost:8082/eshop-basket/basket/alice/checkout-basket "${BUYER[@]}" \
-H 'Content-Type: application/json' -d '{}' \
-H 'Wow-Wait: committed > handled(eshop-basket/BasketCheckoutSaga.onBasketCheckedOut) > committed(eshop-ordering)'
curl -s localhost:8083/eshop-ordering/order/alice-1/state "${BUYER[@]}"The eShop's servers read the caller from an X-User header 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. Adding an item checks only what needs the state: that the basket is the caller's own:
@OnCommand(produces = [BasketItemAdded::class])
fun onCommand(command: Command<AddBasketItem>): Decision {
val owner = command.scope.ownerId
if (owner != null && owner != command.ref.id) {
return Decision.reject(Rejections.NOT_OWNER, "The basket ${command.ref.id} is not $owner's.")
}
val adding = command.body
return Decision.events(BasketItemAdded(adding.productId, adding.unitPrice, adding.quantity))
}Checking out refuses an empty basket, names the order it will become, and returns that id to the caller through the command's result:
@Suppress("UnusedParameter") // The command has no body: its type is what the function handles.
@OnCommand(produces = [BasketCheckedOut::class])
fun onCommand(command: CheckoutBasket, scope: DecideScope): Decision {
if (state.items.isEmpty()) {
return Decision.reject(Rejections.EMPTY, "The basket is empty.")
}
val orderId = "${scope.command.ref.id}-${state.checkouts + 1}"
scope.putResult(ORDER_ID_RESULT, orderId)
return Decision.events(BasketCheckedOut(orderId, 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(calls = [SubmitOrder::class])
suspend fun onBasketCheckedOut(event: StateTypedEvent<BasketState, BasketCheckedOut>, context: SagaContext) {
val checkedOut = event.event
context.call(
context.command(
ORDER.ref(event.ref.tenantId, checkedOut.orderId),
SubmitOrder(checkedOut.items.map { OrderItem(it.productId, it.unitPrice, it.quantity) }),
),
)
}context.command gives the derived command an id derived from the commit, so a retry of the saga sends the same command, and the ordering context answers it as a duplicate 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:
@OnCommand(produces = [OrderSubmitted::class])
fun onCommand(command: SubmitOrder): Decision = Decision.events(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 `basketId` 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 `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,
basketId: string,
onProgress: (progress: CheckoutProgress) => void,
): Promise<CheckoutProgress> {
const stream = await clients.baskets.checkoutBasketStream(
basketId,
{},
{
wait: {
plan: 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 === "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.