Mert Özen Further With Every Line
Backend .NET Microservices in Practice — #07

The Checkout Saga: Orchestrating Order, Stock and Payment with Compensation

Mert Özen Nov 14, 2026 12 min 7 views
The Checkout Saga: Orchestrating Order, Stock and Payment with Compensation

A Wolverine saga in Ordering owns checkout: it asks for stock and payment with messages, releases the stock and cancels the order on a declined payment or a 15-minute timeout, and turns a late payment for a cancelled order into a refund. Orders are now accepted with 202; even with Inventory down the API answers in 23 ms and the order is confirmed once Inventory is back. The saga's steps are checked with unit tests that need no database.

Version note: The code in this post was built and run with .NET 10, Aspire 13.6, WolverineFx 6.64, RabbitMQ 4.3, Entity Framework Core 10 and xUnit v3.

Guess First

In 05 we sent an order while Inventory was down: HTTP 503 in 2.616873s, no order. By the end of this post checkout is a saga and Ordering no longer calls Inventory synchronously. With Inventory down again, what does POST /api/v1/orders return, and how long does it take? Second question: if a payment succeeds after the 15-minute window, that is, Payments says "payment received" after the order has been cancelled, what should the system do? The answers are in the runs below.

Why Orchestration, and Why Ordering?

Checkout changes data in three services: the order in Ordering, the stock in Inventory, the payment in Payments. There is no transaction between them; in the Saga Pattern post of the Messaging and Event-Driven Architecture series we compared the two ways to handle that. With choreography every service reacts to an event and publishes its own, and the flow is written down nowhere. With orchestration one party owns the flow and tells the others what to do with commands. In Bookstore, the online bookstore used as the shared example across this site's series, we chose orchestration, and the owner is Ordering: the order is its data, it is the one that tells the customer "your order is confirmed", and the flow has four steps and three failure paths. Spreading a flow with that many branches across events would make failures much harder to trace.

The saga's steps and the Order statuses: an order is born Pending, becomes StockReserved when the stock is reserved, Paid when the payment is taken and Confirmed when the stock is committed. If there is no stock, the payment is declined or the payment does not arrive within 15 minutes, it becomes Cancelled.

07-checkout-saga-en.png

Commands to Queues, Events to Exchanges

The messages the saga speaks are in Bookstore.Contracts. Commands ask for something to be done and have one receiver; events announce something that happened and don't know who listens:

namespace Bookstore.Contracts.Inventory;

// Commands Ordering's checkout saga sends to Inventory. OrderId makes every one of them idempotent.
public sealed record ReserveStock(Guid OrderId, IReadOnlyList<StockLine> Lines);

public sealed record StockLine(string Isbn, int Quantity);

public sealed record CommitStock(Guid OrderId);

public sealed record ReleaseStock(Guid OrderId, string Reason);

// Inventory's answers, published as events.
public sealed record StockReserved(Guid OrderId, Guid ReservationId, DateTimeOffset ExpiresAtUtc);

public sealed record StockReservationFailed(Guid OrderId, IReadOnlyList<string> UnavailableIsbns);

public sealed record StockCommitted(Guid OrderId);

Payments has the same shape: AuthorizePayment and RefundPayment commands, PaymentSucceeded and PaymentFailed events. Commands go to the receiver's queue (inventory.commands, payments.commands), events to the publisher's exchange. Ordering binds its own ordering.checkout queue to both reply exchanges (bookstore.inventory, bookstore.payments). The difference between channel types is in the Channels post of the same series.

On the first run Ordering did not start. Inventory had created its queue with the inventory.dead-letter DLQ; Ordering, to be able to send to that queue, tried to declare it once more with Wolverine's default settings. Wolverine's error message names the problem and three fixes itself:

System.InvalidOperationException: Rabbit MQ rejected Wolverine's declaration of queue 'inventory.commands' because the queue already exists
with a different configuration, and the broker closed the channel. Wolverine tried to declare it as IsDurable=True, IsExclusive=False, AutoDelete=False,
x-dead-letter-exchange=wolverine-dead-letter-queue.
...
Fix this in one of three ways: (1) align the endpoint's configuration with the existing queue, (...) (2) delete
the existing queue and let Wolverine recreate it; or (3) mark the queue as ExternallyOwned() so Wolverine stops declaring it at all.

The third one is right. Inventory owns the queue; the sender should not have to know its settings:

// Commands go to the receiver's queue. Inventory and Payments own those queues (and their dead letter
// settings): Ordering sends to them but never declares them.
opts.PublishMessage<ReserveStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<CommitStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<ReleaseStock>().ToRabbitQueue(CommandQueues.Inventory).ExternallyOwned();
opts.PublishMessage<AuthorizePayment>().ToRabbitQueue(CommandQueues.Payments).ExternallyOwned();
opts.PublishMessage<RefundPayment>().ToRabbitQueue(CommandQueues.Payments).ExternallyOwned();
opts.Policies.UseDurableOutboxOnAllSendingEndpoints();
// StartCheckout and the scheduled CheckoutTimedOut stay in Ordering, persisted in orderingdb.
opts.Policies.UseDurableLocalQueues();

On the Inventory side the StockReservations class from 05 now serves two doors: the gRPC API and the ReserveStock message. When there is not enough stock it no longer throws an RpcException but returns an outcome; the gRPC service turns that into FailedPrecondition, the message handler into a StockReservationFailed event. The reservation being idempotent per order id comes from 05 and pays off most here: if RabbitMQ delivers the same ReserveStock twice, the stock does not drop twice. There is one new step as well: CommitStock turns the reservation into a sale and takes the books off the shelf for good.

The Saga Itself: CheckoutSaga

In Wolverine a saga is a handler that derives from the Saga class and keeps its state in its properties. In the .NET saga post of the Messaging and Event-Driven Architecture series we wrote the persisted state machine ourselves; here we build the same idea with Wolverine's saga support and Entity Framework Core:

public static (CheckoutSaga, ReserveStock) Start(StartCheckout command) =>
(
    new CheckoutSaga
    {
        Id = command.OrderId,
        Step = CheckoutStep.AwaitingStock,
        Total = command.Total,
        Currency = command.Currency,
        CustomerEmail = command.CustomerEmail
    },
    new ReserveStock(command.OrderId, command.Lines)
);

public async Task<(AuthorizePayment, CheckoutTimedOut)> Handle(
    [SagaIdentityFrom(nameof(StockReserved.OrderId))] StockReserved message,
    IOrderRepository orders, IOptions<CheckoutOptions> options, CancellationToken ct)
{
    Order order = await orders.GetAsync(Id, ct);
    order.MarkStockReserved(message.ReservationId, message.ExpiresAtUtc.UtcDateTime);
    Step = CheckoutStep.AwaitingPayment;

    // Payment is requested and, in the same transaction, a timeout is scheduled for it.
    return (new AuthorizePayment(Id, Total, Currency, CustomerEmail), new CheckoutTimedOut(Id, options.Value.PaymentTimeout));
}

public async Task<(ReleaseStock, OrderCancelled)> Handle(
    [SagaIdentityFrom(nameof(PaymentFailed.OrderId))] PaymentFailed message, IOrderRepository orders,
    TimeProvider clock, CancellationToken ct)
{
    // Compensation: the stock reserved in an earlier step goes back on sale.
    return (new ReleaseStock(Id, "payment-failed"), await CancelAsync(orders, $"Payment failed: {message.Reason}", clock, ct));
}

A few decisions can be read from this code. The messages the handlers return are "cascading messages" for Wolverine: the saga state, the change to the Order and the outgoing messages are written to the outbox in the same transaction. The saga's identity is the order id; Wolverine looks for it in a message field named CheckoutSagaId, SagaId or Id, but in our messages the field is called OrderId. The [SagaIdentityFrom] attribute says so on the handler side. Putting Wolverine's [SagaIdentity] attribute on the contracts would also work, but then Bookstore.Contracts would depend on a package and the architecture test from 01 would reject it.

CheckoutTimedOut derives from Wolverine's TimeoutMessage: it is delivered not when it is sent but after the given delay, and because it is stored in orderingdb it is not lost when Ordering restarts. If the saga no longer exists at that point (completed or cancelled), Wolverine quietly ignores it.

The saga changes the Order aggregate through IOrderRepository. Its EF Core implementation gets the same OrderingDbContext that Wolverine uses for the saga. Rather than assume that, we checked the log; this is the single SQL batch sent while handling StockReserved:

[ordering]       SET NOCOUNT ON;
[ordering]       UPDATE [CheckoutSagas] SET [Step] = @p0, [Version] = @p1
[ordering]       OUTPUT 1
[ordering]       WHERE [Id] = @p2 AND [Version] = @p3;
[ordering]       UPDATE [Orders] SET [Status] = @p4, [StockReservationId] = @p5, [StockReservedUntilUtc] = @p6
[ordering]       OUTPUT INSERTED.[RowVersion]
[ordering]       WHERE [Id] = @p7 AND [RowVersion] = @p8;

Saga state and order in one SaveChanges, both with optimistic concurrency. Version comes from Wolverine's Saga class and grows on every save; we made it a concurrency token in the EF Core mapping. If two messages for the same order are handled at the same time (a payment arriving just as the timeout fires), the second save is rejected; Ordering retries that message with short pauses, and the retry sees the saga's current state. When the saga completes (MarkCompleted()) its row is deleted; after a successful checkout we checked that no row was left in the CheckoutSagas table.

The Order API: 202, Not 201

The code that creates the order no longer calls Inventory. It saves the order, publishes the OrderPlaced event and sends the local StartCheckout command that starts the saga, all three in the same transaction:

// No call to Inventory here: the saga reserves stock with a message. Checkout no longer stops when Inventory is down.
await outbox.SendAsync(new StartCheckout(
    order.Id.Value,
    [.. order.Items.Select(i => new StockLine(i.Isbn, i.Quantity))],
    order.Total.Amount,
    order.Total.Currency,
    order.Buyer.Email));

// INSERT Orders/OrderItems and both outbox rows in one transaction, then the messages leave.
await outbox.SaveChangesAndFlushMessagesAsync(ct);

The endpoint now returns 202 Accepted instead of 201: the order exists, but its outcome is not known yet. That is a contract change, and the oasdiff check we put into CI in 03 caught it:

1 changes: 1 error, 0 warning, 0 info
error	[response-success-status-removed] at src/Services/Ordering/openapi/ordering.json
	in API POST /api/v1/orders
		removed the success response with the status `201`

No client uses this API yet, so we accepted the change on purpose; the Angular client in 11 will be written against 202 from the start. Grace's order, and polling the address in its Location header every half second:

HTTP 202 in 0.367596s
Location: http://localhost:5102/api/v1/orders/01a1da7d-057f-70b4-bd4d-8621c2cbe883
{"id":"01a1da7d-057f-70b4-bd4d-8621c2cbe883","status":"Pending", ... "stockReservedUntilUtc":null}
0.5s Pending
1.0s Pending
1.5s Pending
2.0s StockReserved
2.5s StockReserved
3.0s Confirmed

Most of those three seconds are Wolverine generating and compiling its handler code on the first messages; the next three orders were Confirmed 0.13–0.15 seconds after the POST. The Inventory and Payments logs show the same order's path:

[inventory]       Reserved 1 line(s) for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 until 2026-11-14T14:56:30.8791400Z
[payments]       Authorized 399.80 TRY for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883: pay_01a1da7d109974a0a08049a974e1bf4b
[inventory]       Reservation 01a1da7d-0d1f-76eb-b28b-58b2cb317406 for order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 Committed
[notifications]       Order 01a1da7d-057f-70b4-bd4d-8621c2cbe883 confirmed for grace@example.com

The first question from Guess First: we stopped Inventory and sent an order. The answer was HTTP 202 in 0.022776s; 10 seconds later the order was still Pending and one message was waiting in inventory.commands. In the first polls after we started Inventory (14:39:16) the order was Confirmed. We reached the goal we set in the Communication Styles post of the Microservices series: an Inventory outage is no longer a lost sale but a delayed confirmation. The cost is in the user experience: the screen has to say "order received, waiting for confirmation". We solve that in Angular and with SignalR in 11.

The Failure Paths: No Stock, a Declined Payment, a Timeout

In this post Payments is a placeholder: it declines payments above a set amount (5,000 TRY). In 08 it makes way for a fake payment provider and a webhook. We tried the three failure paths in turn.

An order for Don Quixote, whose stock is zero: Cancelled, reason Out of stock: 9780060934347. Nothing was reserved, so there is no compensation. A 5,668 TRY order: Payments declined it, and the saga released the stock and cancelled the order:

[payments]       Declined 5668.00 TRY for order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7
[inventory]       Reservation 01a1da7d-9c23-777e-aa14-e26c99b1cfce for order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7 Released
[notifications]       Order 01a1da7d-9bdb-7bad-aea8-a78ecf0f0fa7 cancelled for linus@example.com: Payment failed: card-declined

For the timeout we lowered Ordering's Checkout:PaymentTimeout setting to 20 seconds, stopped Payments and sent an order. It became StockReserved at 14:37:45 and was Cancelled in the poll at 14:38:07. The scheduled messages are visible in Wolverine's incoming table; the third row is this order's timeout, the first two are the 15-minute timeouts of earlier successful orders:

message_type                                 execution_time                     status
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:51:31.0886160 +00:00 Scheduled
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:52:07.5313680 +00:00 Scheduled
Bookstore.Ordering.Checkout.CheckoutTimedOut 2026-11-14 14:38:02.7646180 +00:00 Handled

The sagas of the first two had long completed; when their time came, Wolverine ignored them and no error reached the log. We also changed the reservation's lifetime: Inventory's TTL went from 15 to 20 minutes. Normally the saga releases the stock; the TTL is the last resort for a saga that never ends. If the reservation expired on its own before the saga's 15 minutes were up, a paid order could be left with stock that was believed to be sold.

Compensation Is Not an Undo: A Late Payment

The rest of the timeout experiment is the second question from Guess First. After the order was cancelled we started Payments. The AuthorizePayment waiting in payments.commands was handled and the payment succeeded. But the saga is gone. The saga has a NotFound method for this case:

// The saga is gone (completed or timed out). A payment that succeeds now belongs to a cancelled order:
// the money has to go back. That is a new operation (a refund), not an undo.
public static async Task<RefundPayment?> NotFound(
    [SagaIdentityFrom(nameof(PaymentSucceeded.OrderId))] PaymentSucceeded message, IOrderRepository orders, CancellationToken ct)
{
    Order order = await orders.GetAsync(message.OrderId, ct);
    return order.Status == OrderStatus.Cancelled ? new RefundPayment(message.OrderId, "order-cancelled") : null;
}
[payments]       Authorized 389.00 TRY for order 01a1da7e-22ba-71d8-8ff1-1f06d1468e59: pay_01a1da7eb7e47a3ea381580d548a5ad1
[payments] warn: Bookstore.Contracts.Payments.RefundPayment[0]
[payments]       Refund requested for order 01a1da7e-22ba-71d8-8ff1-1f06d1468e59: order-cancelled

Checking the order's status matters. If the same PaymentSucceeded is delivered a second time after the saga completed normally, the order is Confirmed and there must be no refund. This example shows well why compensation is not an "undo". The stock reservation really can be undone: ReleaseStock puts things back as they were. The payment cannot. Money taken from the customer's card only comes back through a separate operation, a refund, which has its own fee, its own duration and its own chance of failing. In 08 we connect the refund to a real provider flow.

Testing the Saga

Every step of the saga is a method: its input is a message and the state, its output the new state and messages. Thanks to IOrderRepository the tests need no database:

[Fact]
public async Task A_declined_payment_releases_the_stock_and_cancels_the_order()
{
    await saga.Handle(new StockReserved(OrderId, Guid.CreateVersion7(), Clock.GetUtcNow().AddMinutes(20)), orders, Options, Ct);

    (ReleaseStock release, OrderCancelled cancelled) = await saga.Handle(new PaymentFailed(OrderId, "card-declined"), orders, Clock, Ct);

    Assert.Equal("payment-failed", release.Reason);
    Assert.Equal("Payment failed: card-declined", cancelled.Reason);
    Assert.Equal(OrderStatus.Cancelled, order.Status);
    Assert.True(saga.IsCompleted());
}
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_payment_for_a_cancelled_order_is_refunded (13ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_timeout_after_the_payment_does_nothing (2ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.No_stock_cancels_without_compensation (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.Happy_path_ends_with_a_confirmed_order (2ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_declined_payment_releases_the_stock_and_cancels_the_order (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.Start_reserves_the_stock (0ms)
passed Bookstore.Ordering.Tests.CheckoutSagaTests.A_timeout_while_waiting_for_payment_releases_the_stock (0ms)
  total: 15
  failed: 0

The happy path, a declined payment, no stock, a timeout while waiting for the payment and after it, a late payment for a cancelled order: seven tests finish in milliseconds. They show that the messages are built correctly and the state moves correctly; they do not show RabbitMQ routing, the outbox or real timing. For those we need an automated version of the manual runs above; we build end-to-end tests with Aspire in 13.

In Production

Count stuck sagas. The rows in the CheckoutSagas table are checkouts in progress. A row older than an hour means a message landed in a DLQ or a service is not answering. The simplest metric: row count per step and the age of the oldest row.

If the receiver's queue does not exist, messages can be lost. With ExternallyOwned() Ordering does not create the queue. In RabbitMQ, a message sent to the default exchange with the name of a queue that does not exist is silently dropped (unless the mandatory flag is set). Locally Inventory and Payments create their queues at startup; in production, infrastructure code should create the queues before the applications.

Think about the time settings together. Payment timeout (15 minutes) < reservation TTL (20 minutes) < how long the payment provider keeps a payment pending. When one changes, look at the others.

On first start Wolverine checks and creates its tables in each service's database. With four services starting at once on a busy SQL Server, this failed once with Execution Timeout Expired; restarting the resources fixed it. In production this schema should also be created before the deployment, together with the migrations.

gRPC stays. Inventory still has the ReserveStock RPC, but checkout no longer uses it. In 09 the BFF will call GetStock for the book page; gRPC's place is the immediate query.

Common Pitfalls

Treating compensation as an undo. A refund, a cancellation, a fee: every compensation is a workflow of its own, and it can fail too.

Building a synchronous call chain inside the saga. Calling Inventory over gRPC in a saga step brings back the dependency from 05 and removes the saga's most important gain: accepting orders while Inventory is down.

Forgetting the late message. Without a NotFound for a message that arrives after the saga has completed, Wolverine treats it as an error and the message ends up in the DLQ after its retries. You have to think step by step about which message can arrive late and what to do when it does.

Leaving the saga identity out of the message. If a reply carries no order id, Wolverine can find its saga only from a header; any layer that does not keep the header (another library, a hand-written consumer) breaks the chain.

Building the timeout as a timer separate from the saga. A separate background job that cancels orders without seeing the saga state can cancel an order whose payment was just taken. The timeout should reach the saga as a message, and the saga should decide based on the step it is in.

A Small Experiment

★☆☆ Stop Payments and send an order below 5,000 TRY. Which Step is its row in the CheckoutSagas table at? What happens when you start Payments before the 15 minutes are up?

★★☆ The limit is 10 copies per book. What happens if an order has enough stock for one book and not for another? Change Inventory to "reserve what exists, report what is missing" and add a step to the saga that asks the customer to accept a partial order. Write its tests first.

★★★ Build a scenario that breaks the timing: set Checkout:PaymentTimeout to 5 seconds and add a 5-second delay to Payments, so that PaymentSucceeded and CheckoutTimedOut arrive together. Find the saga's Version conflict and the retry in Ordering's log. Is the result the same on every run?

Checkout is no longer affected by Inventory outages, and every failure path has an outcome. But Payments is still just an if. In the next post we build the problems of a real payment flow: a fake payment provider, charges with an Idempotency-Key, an unknown result after a timeout and a reconciliation job, a signed webhook callback, and keeping the provider's model out of the domain.