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/OrderWorkflowServerHostFactory.cs

var mesh = options
    .AddRouteMesh(SampleNames.MeshName)
    .Listen(instance.MeshEndpoint)
    .SetRoutingIdPrefix("order-workflow");
mesh.Objects()
    .Server()
    .AddInstanceSpotFactory<OrderWorkflowSpot>(
        SampleNames.OrderWorkflowSpotType,
        factory => factory.RecreateOnRelocation()
    );
mesh.Channel(SampleNames.OrderProjectionChannel).Server();

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/Program.cs

var mesh = options
    .AddRouteMesh(SampleNames.MeshName)
    .Listen(instance.MeshEndpoint)
    .SetRoutingIdPrefix("commerce-api");
mesh.Objects().Client();
mesh.Channel(SampleNames.OrderProjectionChannel).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/Application/OrderWorkflow/StartOrderUseCase.cs

var existing = await commerce.FindIdempotencyAsync(
    request.IdempotencyKey,
    cancellationToken
);
if (existing is { Started: true })
{
    var existingState =
        await readModels.FindAsync(existing.OrderId, cancellationToken)
        ?? throw new InvalidOperationException(
            $"Started order '{existing.OrderId}' has no projection."
        );
    return new StartOrderRes(
        existingState.OrderId,
        OrderContractMapper.ToContract(existingState)
    );
}

var cart = await preparation.LoadCartAndValidateAsync(request, cancellationToken);

var reservation = existing is null
    ? await commerce.ReserveIdempotencyAsync(request.IdempotencyKey, cancellationToken)
    : new IdempotencyReservation(existing, false);
var mapping = reservation.Mapping;
var command = await preparation.BuildCommandAsync(
    request,
    mapping,
    cart,
    cancellationToken
);
var state = reservation.Created
    ? await workflows.StartAsync(command, cancellationToken)
    : await ReadOrStartAsync(mapping.OrderId, command, cancellationToken);
return new StartOrderRes(state.OrderId, state);

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/Infrastructure/ZLink/ZLinkOrderWorkflowRouter.cs

private IZLinkSpotRequestCall Request<TMessage>(string orderId, TMessage command) =>
    spots
        .RequestToSpot(orderId, command)
        // The first command cold-activates the order owner. Existing
        // orders use the authority already published for this SpotId.
        .InstanceSpot(SampleNames.OrderWorkflowSpotType)
        .InMesh(SampleNames.MeshName);

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

Server/OrderWorkflow/Infrastructure/ZLink/Spots/OrderWorkflowSpot/Handlers/StartOrderWorkflowHandler.cs

internal sealed class StartOrderWorkflowHandler
    : IZLinkSpotRequestHandler<OrderWorkflowSpot, StartOrderWorkflowReq, StartOrderWorkflowRes>
{
    public ValueTask<StartOrderWorkflowRes> HandleAsync(
        OrderWorkflowSpot spot,
        StartOrderWorkflowReq request,
        CancellationToken cancellationToken
    )
    {
        return spot.StartOrderWorkflowAsync(request, cancellationToken);
    }
}

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/Infrastructure/ZLink/Spots/OrderWorkflowSpot/OrderWorkflowSpot.cs

public async ValueTask<StartOrderWorkflowRes> StartOrderWorkflowAsync(
    StartOrderWorkflowReq request,
    CancellationToken cancellationToken
)
{
    var state = await workflow.StartAndContinueAsync(
        request,
        cancellationToken,
        state => CloseIfTerminalAsync(state, CancellationToken.None)
    );
    logger.LogInformation(
        "shoppingmall-order started order={OrderId} spot={SpotId}",
        state.OrderId,
        Context.SpotId
    );
    await CloseIfTerminalAsync(state, cancellationToken);
    return new 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/Application/OrderWorkflow/OrderWorkflowService.cs

public async ValueTask<OrderState> StartAndContinueAsync(
    StartOrderWorkflowReq command,
    CancellationToken cancellationToken,
    Func<OrderState, ValueTask>? onTerminal = null
)
{
    var state = await StartAsync(command, cancellationToken);
    if (state.Status is nameof(OrderStatus.Confirmed) or nameof(OrderStatus.Failed))
        return state;

    _ = ContinueWorkflowInBackgroundAsync(command.OrderId, onTerminal)
        .ContinueWith(
            static task => _ = task.Exception,
            CancellationToken.None,
            TaskContinuationOptions.OnlyOnFaulted
                | TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default
        );
    return 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/Application/OrderWorkflow/OrderWorkflowService.cs

var stored = await events.ReadAsync(orderId, cancellationToken);
var aggregate = OrderAggregate.Rehydrate(stored.Select(static item => item.Decode()));
var current = await SaveProjectionFromEventsAsync(stored, cancellationToken);

if (aggregate.IsTerminal || shouldStop(aggregate.Status))
    return current;

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/Application/OrderWorkflow/OrderWorkflowService.cs

var next = aggregate.Status switch
{
    OrderStatus.Created => await ReserveInventoryAsync(
        aggregate,
        stored,
        orderId,
        cancellationToken,
        externalEffectRepeated
    ),
    OrderStatus.InventoryReserved => await AuthorizePaymentAsync(
        aggregate,
        current,
        stored,
        await commerce.GetOrderPaymentMethodAsync(orderId, cancellationToken),
        cancellationToken,
        externalEffectRepeated
    ),
    OrderStatus.PaymentAuthorized => aggregate.Confirm(
        NewEventId("confirmed", orderId),
        NowUnixMs()
    ),
    OrderStatus.PaymentFailed => await ReleaseInventoryAsync(
        aggregate,
        current,
        stored,
        cancellationToken,
        externalEffectRepeated
    ),
    OrderStatus.InventoryReleased => aggregate.FailAfterInventoryRelease(
        NewEventId("failed", orderId),
        NowUnixMs()
    ),
    _ => [],
};

if (next.Count == 0)
    return current;

await AppendAndProjectAsync(orderId, stored.Count, next, cancellationToken);

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/Application/OrderWorkflow/OrderWorkflowService.cs

private async ValueTask AppendAndProjectAsync(
    string orderId,
    long expectedVersion,
    IReadOnlyList<OrderDomainEvent> domainEvents,
    CancellationToken cancellationToken
)
{
    await events.AppendAsync(orderId, expectedVersion, domainEvents, cancellationToken);
    var current = await readModels.FindAsync(orderId, cancellationToken);
    foreach (var domainEvent in domainEvents)
    {
        current = OrderProjection.Apply(current, domainEvent);
        await readModels.SaveAsync(current, cancellationToken);
    }
}

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/Application/OrderWorkflow/StartOrderUseCase.cs

internal sealed class GetOrderStateUseCase(IOrderReadModelStore readModels)
{
    public async ValueTask<GetOrderStateRes> ExecuteAsync(
        GetOrderStateReq request,
        CancellationToken cancellationToken
    )
    {
        var state =
            await readModels.FindAsync(request.OrderId, cancellationToken)
            ?? throw new InvalidOperationException(
                $"Order projection '{request.OrderId}' does not exist."
            );
        return new GetOrderStateRes(OrderContractMapper.ToContract(state));
    }
}

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

Server/OrderWorkflow/Application/OrderWorkflow/OrderWorkflowService.cs

public async ValueTask<OrderState> RebuildProjectionAsync(
    string orderId,
    CancellationToken cancellationToken
)
{
    var stored = await events.ReadAsync(orderId, cancellationToken);
    OrderProjectionState? state = null;
    foreach (var storedEvent in stored)
        state = OrderProjection.Apply(state, storedEvent.Decode());

    if (state is null)
        throw new InvalidOperationException($"Order '{orderId}' has no event stream.");

    await readModels.SaveAsync(state, cancellationToken);
    return OrderContractMapper.ToContract(state);
}

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

Server/OrderWorkflow/Infrastructure/ZLink/Spots/OrderWorkflowSpot/OrderWorkflowSpot.cs

private ValueTask CloseIfTerminalAsync(OrderState state, CancellationToken cancellationToken)
{
    if (state.Status is OrderStatuses.Confirmed or OrderStatuses.Failed)
        _ = Context.CloseAsync(cancellationToken);
    return ValueTask.CompletedTask;
}

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/dotnet/samples/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.