Skip to content
Kumar Chandrachooda
Microservices

The Saga That Orchestrates a Checkout

Operations drives an order from created to approved across three services, and rolls it back when a step fails - a textbook process manager with compensation, read from the real Chronicle-backed source. Part 6 of Nine Services and a Message Bus.

By Kumar Chandrachooda 26 Aug 2025 6 min read
A chain of steps with a reverse arrow unwinding the finished ones

Placing an order in DShop touches three services and cannot be a transaction. Orders creates the order; Products reserves the stock; Orders approves it once the stock is held. Three databases, three services, no shared transaction — so the sequence has to be driven by something, and any step can fail after an earlier one succeeded, which means the sequence also has to be undoable. That driver-and-undoer is a saga, and in DShop it lives in Operations. This part reads its real source.

A process manager, not a chain of handlers

The naive way to sequence this is to chain handlers: Orders' create-handler calls Products, Products' reserve-handler calls back to Orders. That couples every service to the next and scatters the workflow across the estate — nobody owns “the checkout.” DShop takes the other route. Operations runs a process manager: one object that receives the events, decides the next command, and remembers where the workflow is. The workflow lives in one class, ApproveOrderSaga, built on the Chronicle saga library:

public class ApproveOrderSaga : Saga,
    ISagaStartAction<OrderCreated>,
    ISagaAction<ProductsReserved>,
    ISagaAction<ReserveProductsRejected>,
    ISagaAction<OrderApproved>,
    ISagaAction<ApproveOrderRejected>,
    // ...revoke branches

The interface list is the workflow's alphabet: every message the saga reacts to, declared as a type. ISagaStartAction<OrderCreated> means an OrderCreated event begins a new saga instance; the rest are steps within a running one. There is no state machine diagram anywhere — the diagram is the set of HandleAsync overloads, and reading them in order reconstructs the checkout.

The happy path is three sends

Here is the whole forward path, three handlers:

public async Task HandleAsync(OrderCreated message, ISagaContext context)
    => await _busPublisher.SendAsync(
        new ReserveProducts(message.Id, message.Products), CorrelationContext.Empty);

public async Task HandleAsync(ProductsReserved message, ISagaContext context)
    => await _busPublisher.SendAsync(
        new ApproveOrder(message.OrderId), CorrelationContext.Empty);

public async Task HandleAsync(OrderApproved message, ISagaContext context)
    => Complete();

Read it as a sentence. An order was created, so send a ReserveProducts command to Products. Products reserved them and published ProductsReserved, so send an ApproveOrder command back to Orders. Orders approved it and published OrderApproved, so Complete() the saga. Three steps, each a reaction to the previous step's event, each producing the next step's command. The saga never calls a service directly — it only publishes to the bus, and the bus routes each command to its owner. Operations is the conductor; it does not play any instrument.

How does an event three services away find the right running saga? ResolveId maps each message to the order id it concerns:

public override Guid ResolveId(object message, ISagaContext context)
{
    switch (message)
    {
        case OrderCreated m: return m.Id;
        case ProductsReserved m: return m.OrderId;
        case OrderApproved m: return m.Id;
        // ...
    }
}

OrderCreated.Id, ProductsReserved.OrderId, OrderApproved.Id — all the same GUID, the order's id, just named differently by each producer. That shared id is the correlation key that threads a ProductsReserved from Products back into the exact saga instance that a ReserveProducts started. It is the Correlation Identifier pattern, resolved by hand per message type because the message shapes don't agree on the field name.

The unhappy path is where the pattern earns its keep

Any step can fail. Products might not have the stock; ReserveProductsRejected arrives instead of ProductsReserved. Orders might refuse to approve; ApproveOrderRejected arrives instead of OrderApproved. The saga handles both by rejecting:

public async Task HandleAsync(ReserveProductsRejected message, ISagaContext context)
    => Reject();

public async Task HandleAsync(ApproveOrderRejected message, ISagaContext context)
    => Reject();

Reject() triggers Chronicle's compensation: it replays the saga's handled messages in reverse and calls each one's CompensateAsync. This is where the undo lives, and DShop writes exactly the two that matter:

public async Task CompensateAsync(OrderCreated message, ISagaContext context)
    => await _busPublisher.SendAsync(
        new RevokeOrder(message.Id, message.CustomerId), CorrelationContext.Empty);

public async Task CompensateAsync(ProductsReserved message, ISagaContext context)
    => await _busPublisher.SendAsync(
        new ReleaseProducts(message.OrderId, message.Products), CorrelationContext.Empty);

The compensation for “an order was created” is RevokeOrder. The compensation for “products were reserved” is ReleaseProducts — put the stock back. A compensation is not a rollback; it is a new, forward business action that undoes an earlier effect. You cannot un-reserve stock by deleting a database row across a service boundary — you reserve it back by publishing a command Products understands. Every other CompensateAsync in the class is Task.CompletedTask, which is correct: rejecting and approving have no side-effect to undo, so their compensation is genuinely nothing. The two that publish are exactly the two that changed something.

To be fair to the design, this is a clean, honest process manager: the happy path is legible, the compensations are real forward actions, and the workflow is one file you can read end to end. Chronicle handles the replay-in-reverse and the per-saga locking; DShop just declares the steps.

The branches the class also carries

ApproveOrderSaga is not only the create-reserve-approve line. Read its interface list and it also implements ISagaAction<OrderRevoked> and ISagaAction<RevokeOrderRejected> — a revoke branch that lets an approved order be unwound after the fact, with OrderRevoked completing the saga and RevokeOrderRejected rejecting it (the source even labels the latter //Edge case). And it has siblings: the Sagas/ folder holds a CancelOrderSaga and a DiscountCreatedSaga beside it, each a small state machine of its own. Operations is not “the checkout saga” — it is a family of process managers, one per cross-service workflow, all driven by the same reflective subscribe that feeds them their messages (Part 8). That is the shape to copy: one saga class per workflow, each owning its own happy path and its own compensations, rather than one god-saga branching on a status field. The workflows stay independent because the sagas do.

Two sharp edges worth naming

Reading the source turns up two things I would flag in review.

First, every publish uses CorrelationContext.Empty. The saga drops the correlation context on the floor for every command it sends — ReserveProducts, ApproveOrder, RevokeOrder, ReleaseProducts all go out with an empty context instead of the one that arrived. So the trace that let you follow a checkout across services snaps at the saga boundary: the reservation looks like it came from nowhere. The companion series shows how easily this context gets mangled even when it is passed — The Envelope That Scrambled Itself — and here it is simply not passed at all. For a component whose entire job is coordinating across services, losing correlation is the one telemetry gap you least want.

Second, compensation inherits Chronicle's redelivery semantics, so the compensations must be idempotent, and ReleaseProducts lands in the same non-idempotent stock code the reservation used. If a ReleaseProducts is redelivered, it releases twice. The saga is correct; the handler it calls is not reliably re-runnable. That thread runs straight into Idempotency Aspired, Not Enforced.

The rule of thumb

  • Put the workflow in one place and let it speak only to the bus. A process manager that reacts to events and emits commands keeps the choreography out of the participating services — nobody but Operations knows the checkout exists, and that is the point.
  • A compensation is a forward action, and it must be idempotent. You undo a distributed effect by publishing its inverse, not by rolling back a transaction — and because the bus can redeliver, “release the stock” has to be safe to run twice.

Operations does not only run this checkout saga. Next: A Saga With a Memory — a stateful saga that remembers when a customer signed up and grants a discount only if their first order lands within a day.