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/main.cpp
auto workflow_route = options.add_route_mesh (workflow_channel);
workflow_route
.set_routing_id (
zlink::routing_id_t::from ("shoppingmall-" + instance.instance_id + "-workflow"))
.listen (instance.route_endpoint);
workflow_route.objects ()
.server ()
.add_instance_spot_factory<order_workflow_spot_t,
sample_topology_t,
workflow_instance_topology_t> (sample_names_t::order_workflow_spot)
.recreate_on_relocation ()
.add_spot_factory<planned_relocation_workflow_spot_t,
sample_topology_t,
workflow_instance_topology_t> ("shoppingmall.planned.relocation.workflow")
.set_execution_mode (user_spot_execution_mode_t::spot_wide)
.set_relocation_coordination_mode (spot_relocation_coordination_mode_t::application_signaled)
.recreate_on_relocation ();
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/main.cpp
auto workflow = options.add_route_mesh (sample_names_t::order_workflow_channel);
workflow
.set_routing_id (
zlink::routing_id_t::from ("shoppingmall-" + instance.instance_id + "-workflow"))
.listen (instance.route_endpoint);
workflow.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/main.cpp
auto plan = _store.update ([&] (nlohmann::json &state) {
auto &mappings = state["idempotency"];
if (!mappings.contains (request.idempotency_key)) {
const auto next = state.value ("nextOrderSequence", 0) + 1;
state["nextOrderSequence"] = next;
mappings[request.idempotency_key] = nlohmann::json{
{"orderId",
"order-" + std::string (4 - std::to_string (next).size (), '0')
+ std::to_string (next)},
{"started", false}};
}
const auto order_id = mappings[request.idempotency_key].value ("orderId",
std::string{});
const auto already_started = mappings[request.idempotency_key].value ("started", false);
/* 장바구니는 CommerceStateStore의 시드에서 읽어 검증한다 — 금액 범위나 cart id
* 문자열 비교로 성공·실패를 흉내내지 않는다. */
if (!state["carts"].contains (request.cart_id)) {
throw std::runtime_error ("Unknown cart: " + request.cart_id);
}
const auto cart = state["carts"][request.cart_id].get<cart_seed_t> ();
mappings[request.idempotency_key]["started"] = true;
return start_plan_t{start_order_workflow_req_t{order_id,
request.cart_id,
request.shipping_address_id,
request.payment_method_id,
request.idempotency_key,
"start:" + request.idempotency_key,
cart.lines,
cart.amount,
cart.currency},
already_started};
});
/* 공통 sample spec §6.1: "이미 확정된 idempotency mapping을 재사용하는 경우에는
* 현재 조회 모델을 반환할 수 있다." */
if (plan.already_started) {
for (int attempt = 0; attempt < 20; ++attempt) {
const auto projection = _store.read ([&] (const nlohmann::json &state) {
if (!state["readModels"].contains (plan.command.order_id)) {
return projection_lookup_t{};
}
return projection_lookup_t{
true, state["readModels"][plan.command.order_id].get<order_state_t> ()};
});
if (projection.found) {
co_return start_order_res_t{projection.state.order_id, projection.state};
}
std::this_thread::sleep_for (std::chrono::milliseconds (10));
}
}
auto state = (co_await request_workflow<start_order_workflow_res_t> (plan.command)).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/main.cpp
template <typename TReply, typename TRequest>
task_t<TReply> request_workflow (const TRequest &request)
{
co_return co_await _routes.request_to_spot (spot_id_t (request.order_id), request)
.instance_spot (sample_names_t::order_workflow_spot)
.timeout (std::chrono::milliseconds (5000))
.template async<TReply> ();
}
On the Spot side the request handler passes the request to the Spot's start method.
Server/OrderWorkflow/main.cpp
void configure () override
{
_context.handlers ()
.add_handler<&order_workflow_spot_t::start> (start_order_workflow_req_t::packet_name)
.add_handler<&order_workflow_spot_t::continue_> (
continue_order_workflow_req_t::packet_name)
.add_handler<&order_workflow_spot_t::continue_scheduled> (
continue_order_workflow_msg_t::packet_name)
.add_handler<&order_workflow_spot_t::rebuild> (
rebuild_order_projection_req_t::packet_name);
}
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/main.cpp
task_t<start_order_workflow_res_t> start (const start_order_workflow_req_t &request)
{
auto state = _store.update ([&] (nlohmann::json &json) {
return run_workflow (json,
request.order_id,
source_command_id (request),
&request,
/*max_steps=*/1);
});
const std::string started_line = std::format (
"shoppingmall-order started order={} spot={}\n", state.order_id, _order_id);
std::cerr << started_line;
if (state.status != order_status_t::confirmed && state.status != order_status_t::failed) {
schedule_continue (state.order_id);
}
co_await close_if_terminal (state);
co_return start_order_workflow_res_t{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/main.cpp
void schedule_continue (const std::string &order_id)
{
(void) _continue_timer.cancel ();
_scheduled_order_id = order_id;
_continue_timer = _context.add_timer<order_workflow_continue_timer_handler_t> (
"order-workflow-continue", std::chrono::milliseconds (1));
}
task_t<void> close_if_terminal (const order_state_t &state)
{
if (state.status == order_status_t::confirmed || state.status == order_status_t::failed) {
(void) _context.close ();
}
co_return;
}
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/Common/workflow_logic.hpp
auto before = fold (read_event_stream (state, order_id), order_id);
if (before.terminal ()) {
break;
}
if (before.exists () && stop_hook_reached (state, order_id, before.status)) {
break;
}
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/Common/workflow_logic.hpp
if (aggregate.status == order_status_t::created) {
const auto reservation_id = reservation_id_for (order_id);
const auto result = inventory_module_t::reserve (
state, reserve_inventory_command_t{order_id, reservation_id, aggregate.lines});
if (result.accepted) {
append_event (state,
order_id,
order_event_types_t::inventory_reserved,
source_command_id,
nlohmann::json{{"reservationId", reservation_id}},
aggregate.version);
} else {
append_event (state,
order_id,
order_event_types_t::inventory_reservation_failed,
source_command_id,
nlohmann::json{{"reason", result.reason}},
aggregate.version);
}
return true;
}
if (aggregate.status == order_status_t::inventory_reserved) {
const auto payment_id = payment_id_for (order_id);
const auto result = payment_module_t::authorize (
state,
authorize_payment_command_t{order_id,
payment_id,
aggregate.payment_method_id,
aggregate.amount,
aggregate.currency});
if (result.accepted) {
append_event (state,
order_id,
order_event_types_t::payment_authorized,
source_command_id,
nlohmann::json{{"paymentId", payment_id}},
aggregate.version);
} else {
append_event (state,
order_id,
order_event_types_t::payment_failed,
source_command_id,
nlohmann::json{{"reason", result.reason}},
aggregate.version);
}
return true;
}
if (aggregate.status == order_status_t::payment_authorized) {
append_event (state,
order_id,
order_event_types_t::order_confirmed,
source_command_id,
nlohmann::json{{"confirmedAtUnixMs", now_unix_ms ()}},
aggregate.version);
return true;
}
if (aggregate.status == order_event_types_t::payment_failed) {
(void) inventory_module_t::release (
state, release_inventory_command_t{order_id, aggregate.reservation_id, aggregate.reason});
append_event (
state,
order_id,
order_event_types_t::inventory_released,
source_command_id,
nlohmann::json{{"reservationId", aggregate.reservation_id}, {"reason", aggregate.reason}},
aggregate.version);
return true;
}
if (aggregate.status == order_event_types_t::inventory_released
|| aggregate.status == order_event_types_t::inventory_reservation_failed) {
append_event (
state,
order_id,
order_event_types_t::order_failed,
source_command_id,
nlohmann::json{{"reason", aggregate.reason}, {"failedAtUnixMs", now_unix_ms ()}},
aggregate.version);
return true;
}
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/Common/workflow_logic.hpp
inline void append_event (nlohmann::json &state,
const std::string &order_id,
const std::string &event_type,
const std::string &source_command_id,
nlohmann::json payload,
std::int64_t expected_version)
{
auto &stream = state["events"][order_id];
const auto current_version = static_cast<std::int64_t> (stream.size ());
if (current_version != expected_version) {
throw std::runtime_error ("Order event stream version conflict: " + order_id);
}
stored_order_event_t event;
event.event_id = order_id + ":" + std::to_string (expected_version + 1);
event.source_command_id = source_command_id;
event.order_id = order_id;
event.event_type = event_type;
event.payload = std::move (payload);
event.version = expected_version + 1;
event.created_at_unix_ms = now_unix_ms ();
stream.push_back (event);
}
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/main.cpp
get_order_state_res_t get_order (const get_order_state_req_t &request)
{
return _store.read ([&] (const nlohmann::json &state) {
if (!state["readModels"].contains (request.order_id)) {
throw framework_exception_t (framework_error_kind_t::not_found,
"Order read model is not available: "
+ request.order_id);
}
return get_order_state_res_t{
state["readModels"][request.order_id].get<order_state_t> ()};
});
}
When the read model is deleted or inconsistent, it is rebuilt from the event stream alone.
Server/Common/workflow_logic.hpp
inline order_state_t rebuild_projection (nlohmann::json &state, const std::string &order_id)
{
const auto stream = read_event_stream (state, order_id);
if (stream.empty ()) {
throw std::runtime_error ("Order event stream does not exist: " + order_id);
}
return save_projection (state, fold (stream, order_id));
}
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