Skip to content

Reading Along: ShoppingMall

Guide Home | Previous: Reading Along: DeliveryDispatch | Next: Reading Along: GameQuest

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.

↗ View larger

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/java/systems/zlink/samples/shoppingmall/server/orderworkflow/Program.java

ZLinkMeshNodeBuilder node = options.addRouteMesh(SampleNames.OrderSpotDiscovery);
node.listen(workflow.spotRouterEndpoint())
        .setRoutingId(RoutingId.from(workflow.instanceName()));
node.objects()
        .server()
        .addInstanceSpotFactory(
                SampleNames.OrderWorkflowSpotType,
                OrderWorkflowSpot.class,
                factory -> factory.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/java/systems/zlink/samples/shoppingmall/server/commerceapi/Program.java

options.addRouteMesh(SampleNames.OrderSpotDiscovery)
        .setRoutingId(RoutingId.from(api.instanceName()))
        .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.

↗ View larger

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

validate(request);
Messages.CartSeed cart = store.getCart(request.cartId());
store.validateShippingAddress(request.shippingAddressId());
store.getPaymentMethod(request.paymentMethodId());
OrderDomain.IdempotencyMapping mapping =
        store.reserveIdempotency(request.idempotencyKey(), topology.api().instanceName());
store.saveOrderPaymentMethod(mapping.orderId(), request.paymentMethodId());
Messages.OrderState existing = store.findProjection(mapping.orderId());
if (existing != null) {
    return CompletableFuture.completedFuture(
            new Messages.StartOrderRes(mapping.orderId(), existing.status()));
}

Messages.StartOrderWorkflowReq workflowRequest =
        workflowRequest(request, mapping.orderId(), cart);
return workflowRequest(mapping.orderId(), workflowRequest)
        .timeout(SampleTimings.WorkflowTimeout)
        .submit(Messages.StartOrderWorkflowRes.class)
        .thenApply(
                started -> {
                    store.markIdempotencyStarted(request.idempotencyKey());
                    return new Messages.StartOrderRes(
                            mapping.orderId(), started.state().status());
                })
        .exceptionallyCompose(
                failure -> {
                    Throwable cause =
                            failure instanceof CompletionException
                                            && failure.getCause() != null
                                    ? failure.getCause()
                                    : failure;
                    if (cause instanceof ZLinkFrameworkException framework
                            && framework.kind() == ZLinkFrameworkErrorKind.REJECTED) {
                        OrderDomain.IdempotencyMapping stored =
                                store.findIdempotency(request.idempotencyKey());
                        Messages.OrderState state =
                                stored == null
                                        ? null
                                        : store.findProjection(stored.orderId());
                        if (state != null) {
                            return CompletableFuture.completedFuture(
                                    new Messages.StartOrderRes(
                                            stored.orderId(), state.status()));
                        }
                    }
                    return CompletableFuture.failedFuture(cause);
                });

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/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

private ZLinkSpotRequestCall workflowRequest(String orderId, Object request) {
    return routes.requestToSpot(orderId, request)
            .instanceSpot(SampleNames.OrderWorkflowSpotType)
            .inMesh(SampleNames.OrderSpotDiscovery);
}

On the Spot side the request handler passes the request to the Spot's start method.

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/spots/handlers/StartOrderWorkflowSpotHandler.java

public final class StartOrderWorkflowSpotHandler
        implements ZLinkSpotRequestHandler<
                OrderWorkflowSpot, Messages.StartOrderWorkflowReq, Messages.StartOrderWorkflowRes> {
    private final OrderWorkflowService workflow;

    public StartOrderWorkflowSpotHandler(OrderWorkflowService workflow) {
        this.workflow = workflow;
    }

    @Override
    public CompletionStage<Messages.StartOrderWorkflowRes> handle(
            OrderWorkflowSpot spot, Messages.StartOrderWorkflowReq request) {
        System.out.println(
                "shoppingmall-order started order="
                        + request.orderId()
                        + " spot="
                        + spot.context().spotId());
        return workflow.startInSpot(spot, request)
                .thenCompose(
                        state ->
                                spot.closeIfTerminal(state)
                                        .thenApply(
                                                ignored ->
                                                        new Messages.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/java/systems/zlink/samples/shoppingmall/server/orderworkflow/spots/handlers/StartOrderWorkflowSpotHandler.java

@Override
public CompletionStage<Messages.StartOrderWorkflowRes> handle(
        OrderWorkflowSpot spot, Messages.StartOrderWorkflowReq request) {
    System.out.println(
            "shoppingmall-order started order="
                    + request.orderId()
                    + " spot="
                    + spot.context().spotId());
    return workflow.startInSpot(spot, request)
            .thenCompose(
                    state ->
                            spot.closeIfTerminal(state)
                                    .thenApply(
                                            ignored ->
                                                    new Messages.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/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

Messages.OrderState state = store.findProjection(request.orderId());
if (!spot.isTerminal(state)) {
    spot.context()
            .outbound()
            .sendToSpot(
                    spot.context().spotId(),
                    new Messages.RunOrderWorkflowMsg(request.orderId()))
            .submit();
}
return CompletableFuture.completedFuture(state);

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/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

Messages.OrderState state = saveProjection(orderId);
if (Messages.OrderStatuses.InventoryReserved.equals(state.status())) {
    System.out.println(
            "shoppingmall-order replayed order="
                    + orderId
                    + " generation="
                    + spot.context().objectGeneration());
}

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.

↗ View larger

Server/OrderWorkflow/src/main/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

if (Messages.OrderStatuses.Created.equals(state.status())) {
    state = reserveInventory(orderId, state);
}
if (Messages.OrderStatuses.InventoryReserved.equals(state.status())) {
    state = authorizePayment(orderId, state);
}
if (Messages.OrderStatuses.PaymentAuthorized.equals(state.status())) {
    state = confirm(orderId);
}
return state;

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/java/systems/zlink/samples/shoppingmall/server/orderworkflow/OrderWorkflowService.java

private void append(String orderId, long expectedVersion, List<OrderDomain.OrderEvent> events) {
    store.appendEvents(orderId, expectedVersion, events);
    saveProjection(orderId);
}

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.

↗ View larger

A query reads only the read model and does not advance the order.

Server/CommerceApi/src/main/java/systems/zlink/samples/shoppingmall/server/commerceapi/CommerceApiService.java

public CompletionStage<Messages.GetOrderStateRes> getOrder(String orderId) {
    return CompletableFuture.completedFuture(
            new Messages.GetOrderStateRes(store.findProjection(orderId)));
}

When the read model is deleted or inconsistent, it is rebuilt from the event stream alone.

Server/Shared/src/main/java/systems/zlink/samples/shoppingmall/server/shared/store/RedisCommerceStore.java

public Messages.OrderState rebuildProjection(String orderId) {
    Messages.OrderState rebuilt = OrderProjection.fold(readEvents(orderId));
    if (rebuilt == null) {
        throw new IllegalStateException("Order '" + orderId + "' does not exist.");
    }
    saveProjection(rebuilt);
    return rebuilt;
}

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.

↗ View larger

// This implementation's order workflow Spot does not Close on a 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.

framework/languages/java/samples/java/ShoppingMall/run_sample.sh

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.