Reading Along: ShoppingMall¶
Guide Home | Previous: Reading Along: DeliveryDispatch | Next: Reading Along: GameQuest
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
What you get from this chapter
You can open the ShoppingMall sample in an editor and follow the code from an order accepted over HTTP, through inventory reservation → payment authorization → confirmation inside the owner Spot, and through compensation when a step fails. The code in this chapter comes from the ShoppingMall sample in the per-language example repositories.
Picking a Sample introduced what this sample demonstrates. This chapter is what you read after that introduction — the roles and where their code lives, the message flow of the main scenarios, and, for each flow, the framework feature it uses and the chapter that explains it, in the order the source is laid out. This chapter explains the ShoppingMall sample's roles and code locations, its main message flows, and its run verification in source order. See the ShoppingMall scenario for requirements, message contracts, and verification criteria.
1. What This Sample Demonstrates¶
One order is owned by an Instance Spot whose id is the OrderId. The API terminates HTTP, never
changes order state itself, and only sends requests to that id. The code inside the Spot re-reads the
event stream to learn the current step, executes exactly one next step, and records an event — there
is no separate coordination state, scheduler or outbox layer.
Event sourcing is not a framework feature; it is an application design this sample builds on top of
a Spot. What the framework provides is routing to the current owner by OrderId, creation on the
first request, and executing one order's handlers one at a time.
2. Roles and Where the Code Lives¶
| Role | Processes | Owns | Code |
|---|---|---|---|
| CommerceApi | 2 | HTTP input validation, idempotency mapping, workflow requests, read model queries | Server/CommerceApi |
| OrderWorkflow | 2 | The order workflow Instance Spot, replay, next step and event recording, external module adapters | Server/OrderWorkflow |
| Client | 1 | Order start, polling, and the duplicate, failure, resume and rebuild assertions | Client |
State transitions, event creation and compensation rules live in Server/OrderWorkflow/Domain and
reference no framework type. Replay, fold, the next-step decision and expected-version recording
belong to Server/OrderWorkflow/Application. Messages are set by the JSON contract in shared.
3. Server Configuration¶
OrderWorkflow registers the order workflow Instance Spot factory on the mesh's object server. The factory chooses the policy that recreates the Spot on the target during relocation instead of moving state — the state can be restored from the event stream.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/OrderWorkflowApplication.kt
configurer
.addRouteMesh(SampleNames.OrderWorkflowMesh)
.setRoutingId(RoutingId.from(role.instanceId))
.listen(role.channelEndpoint)
.objects()
.server()
.addInstanceSpotFactory<OrderWorkflowSpot>(SampleNames.OrderWorkflowSpotType) {
recreateOnRelocation()
}
CommerceApi is an object client of the same mesh. There are two APIs and two Workflows, and neither side knows the other's endpoint.
Server/CommerceApi/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/commerceapi/CommerceApiApplication.kt
configurer
.addRouteMesh(SampleNames.OrderWorkflowMesh)
.setRoutingId(RoutingId.from(role.instanceId))
.listen()
.objects()
.client()
The creation policy of an Instance Spot is covered by Activation and Lifetime, and the recreate-on-move policy by Relocation.
4. Starting an Order — the First Request Creates the Owner¶
When the client starts an order over HTTP, the API checks the idempotency key. If the order has already started it returns the read model; otherwise it reserves the mapping and asks the workflow to Start.
Server/CommerceApi/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/commerceapi/StartOrderUseCase.kt
val existing = store.findIdempotency(request.idempotencyKey)
if (existing != null && existing.started) {
val state = store.findReadModel(existing.orderId) ?: store.placeholder(existing.orderId)
return StartOrderRes(state.orderId, state.status)
}
try {
if (existing != null && existing.ownerInstanceId != instanceId) {
return forwardToOwner(existing.ownerInstanceId, request)
}
val command = buildCommand(request, existing)
val state = workflows.startWorkflow(command)
return StartOrderRes(state.orderId, state.status)
} catch (error: ZLinkFrameworkException) {
if (error.kind() == ZLinkFrameworkErrorKind.REJECTED) {
val stored = store.findIdempotency(request.idempotencyKey)
val state = stored?.let { store.findReadModel(it.orderId) }
if (state != null) return StartOrderRes(state.orderId, state.status)
}
throw error
}
Every request to the workflow has the same shape — the OrderId as the Spot id, together with the
Instance Spot type and the mesh. If no Spot has that id, the first request creates one on one of the
eligible nodes; if one exists, the current owner receives the request.
Server/CommerceApi/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/commerceapi/OrderWorkflowRouter.kt
private suspend inline fun <reified TReply : Any> request(
orderId: String,
payload: Any,
): TReply {
return kotlinRoutes
.requestToSpot<TReply>(orderId, payload)
.instanceSpot(SampleNames.OrderWorkflowSpotType)
.inMesh(SampleNames.OrderWorkflowMesh)
.timeout(SampleTimings.WorkflowTimeout)
.await()
}
On the Spot side the request handler passes the request to the Spot's start method.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/handlers/StartOrderWorkflowHandler.kt
class StartOrderWorkflowHandler(
private val workflow: OrderWorkflowService,
private val continuations: WorkflowContinuationQueue,
) : ZLinkSpotRequestHandler<OrderWorkflowSpot, StartOrderWorkflowReq, StartOrderWorkflowRes> {
override fun handle(
spot: OrderWorkflowSpot,
request: StartOrderWorkflowReq,
): CompletionStage<StartOrderWorkflowRes> {
spot.requireOrder(request.orderId)
val state = workflow.start(request)
println(
"shoppingmall-order started order=${request.orderId} spot=${spot.context().spotId()}"
)
if (!spot.isTerminal(state)) {
continuations.enqueue(request.orderId)
}
return spot.closeIfTerminal(state).thenApply { StartOrderWorkflowRes(state) }
}
}
Start records the started event in the event stream and replies with the Created state.
Reservation, authorization and confirmation follow after the reply.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/handlers/StartOrderWorkflowHandler.kt
spot.requireOrder(request.orderId)
val state = workflow.start(request)
println(
"shoppingmall-order started order=${request.orderId} spot=${spot.context().spotId()}"
)
if (!spot.isTerminal(state)) {
continuations.enqueue(request.orderId)
}
return spot.closeIfTerminal(state).thenApply { StartOrderWorkflowRes(state) }
Calling an Instance Spot is covered by Spot, and the rule that concurrent requests for the same id wait for a single creation by Activation and Lifetime.
5. Advancing the Steps — Replay, Next Step, Record¶
The HTTP reply ends at Created; the remaining steps are driven by a background continuation. The
client polls until it sees Confirmed or Failed.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/WorkflowSagaWorker.kt
private fun pump() {
while (running.get()) {
val orderId =
try {
queue.take()
} catch (ignored: InterruptedException) {
Thread.currentThread().interrupt()
return
} ?: return
try {
routes
.sendToSpot(orderId, RunOrderWorkflowMsg(orderId))
.instanceSpot(SampleNames.OrderWorkflowSpotType)
.inMesh(SampleNames.OrderWorkflowMesh)
.submit_sync()
System.err.println("shoppingmall order: advanced order=$orderId")
} catch (error: RuntimeException) {
System.err.println(
"shoppingmall order: saga failed order=$orderId error=${error.message}"
)
}
}
}
One turn of the continuation starts by reading the event stream and restoring the aggregate. No progress pointer is stored separately — the last event is the current step.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/OrderWorkflowService.kt
val stored = store.readEvents(orderId)
val aggregate = OrderAggregate.rehydrate(orderId, stored.toDomainEvents())
if (aggregate.isTerminal() || !aggregate.hasStarted()) {
return store.findReadModel(orderId) ?: store.placeholder(orderId)
}
Depending on the current step, exactly one next step runs. If the inventory reservation is refused, payment is not called; if payment is refused, the reservation already recorded is released and the order ends as failed.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/OrderWorkflowService.kt
val next: List<Any> =
when (status) {
OrderStatus.Created -> {
val reserve =
store.reserveInventory(orderId, aggregate.lines().toContractLines())
aggregate.applyInventoryResult(
InventoryReservationResult(
reserve.accepted,
reserve.reservationId,
reserve.reason,
),
newEventId("reserved", orderId),
newEventId("inv-fail", orderId),
ts,
)
}
OrderStatus.InventoryReserved -> {
val payment =
store.authorizePayment(
orderId,
aggregate.paymentMethodId(),
aggregate.amount(),
aggregate.currency(),
)
aggregate.applyPaymentResult(
PaymentAuthorizationResult(
payment.accepted,
payment.paymentId,
payment.reason,
),
newEventId("paid", orderId),
newEventId("released", orderId),
newEventId("pay-fail", orderId),
ts,
)
}
OrderStatus.PaymentAuthorized ->
aggregate.confirm(newEventId("confirmed", orderId), ts)
else -> emptyList()
}
if (next.isEmpty()) {
return requireProjection(orderId)
}
appendAndProject(orderId, stored.size.toLong(), next)
Events are recorded with an expected version. If a previous owner is still around and tries to write to the same stream, the version mismatches and the write fails, so two owners never execute the same step twice. After recording, the read model is updated.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/OrderWorkflowService.kt
private fun appendAndProject(orderId: String, expectedVersion: Long, events: List<Any>) {
val toStore = events.map { toStored(orderId, it) }
store.appendEvents(orderId, expectedVersion, toStore)
var state: OrderProjection.State? = null
for (stored in store.readEvents(orderId).toDomainEvents()) {
state = OrderProjection.apply(state, stored)
}
store.saveReadModel(state!!.toReadModel())
}
All of these steps run inside the same Spot's turn, so one order has no competing writers. The serialization boundary is covered by The Execution Model.
6. Duplicates, Resume and Projection Rebuild¶
When the same key starts twice, both requests use the OrderId of the one that won the mapping
reservation, and a command already in the stream is not recorded again. An interrupted order is
resumed by a resume command that runs the same continuation again — replay reports the last step, so
completed steps are skipped.
A query reads only the read model and does not advance the order.
Server/CommerceApi/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/commerceapi/handlers/GetOrderStateHandler.kt
@ZLinkHandlerGroup("commerce")
class GetOrderStateHandler(private val store: CommerceStore) :
ZLinkSuspendingRequestHandler<GetOrderStateReq, GetOrderStateRes> {
override suspend fun handle(
request: GetOrderStateReq,
context: ZLinkMessageContext,
): GetOrderStateRes {
val state =
store.findReadModel(request.orderId)
?: throw IllegalStateException("Order '${request.orderId}' does not exist.")
return GetOrderStateRes(state)
}
}
When the read model is deleted or inconsistent, it is rebuilt from the event stream alone.
Server/OrderWorkflow/src/main/kotlin/systems/zlink/samples/kotlin/shoppingmall/server/orderworkflow/OrderWorkflowService.kt
fun rebuildProjection(orderId: String): OrderState {
val stored = store.readEvents(orderId)
if (stored.isEmpty()) {
throw IllegalStateException("No order event stream for '$orderId'.")
}
var state: OrderProjection.State? = null
for (event in stored.toDomainEvents()) {
state = OrderProjection.apply(state, event)
}
val readModel = state!!.toReadModel()
store.saveReadModel(readModel)
return readModel
}
7. Termination and Lifecycle¶
Once the order reaches Confirmed or Failed, the Spot may request closure through context close.
Context close returns the close result when the termination outcome is settled. A request with the
same OrderId after the close creates a new Spot generation, which replays the stream and returns
the terminal state.
If the Ready owner's process disappears, the request in flight ends as Unavailable, and the framework does not create the same order on another node by itself. Planned relocation is a separate procedure, and the resume after it continues through the same replay shown above. Close and generations are covered by Activation and Lifetime, and planned moves by Relocation.
8. Running and Verifying¶
One runner starts the Redis container, the server processes and the client scenario together.
The client asserts the success and failure results, the duplicate start, the resume and the projection rebuild. The checks and the exact log strings are set by the ShoppingMall scenario.
9. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: ShoppingMall scenario
- The same owner Spot and event sourcing applied to a domain that tolerates loss: Reading Along: GameQuest