Skip to main content

System Design Drill Day 12: The Saga Pattern for a Multi-Step Checkout

How sagas replace distributed transactions with local steps and compensations, using a multi-step checkout as the example.

Updated 7 min readMarkdown
Part of seriesSystem Design Drills
System Design Drill Day 12: The Saga Pattern for a Multi-Step Checkout

The customer's card was charged, and then the database write for the order failed. You now hold their money with no order to show for it, and the next move (retry the order write or refund the charge) has to be designed in advance.

A single checkout can touch inventory, payment, the order row and a merchant notification, and no single database transaction covers all of them. This drill models an online marketplace's checkout as a saga, with compensations for when a step fails partway through.

Core concepts

Think of booking a trip. You book the flight, then the hotel, and if the hotel falls through you cancel the flight. Nothing is booked atomically; each step is done on its own, and each has a known way to undo it.

A saga applies that to software: a sequence of local transactions, where each step has a compensating action that undoes it if a later step fails. It comes in two styles. In orchestration, one coordinator tells each step what to do and when to compensate. In choreography, each step emits an event, the next step reacts, and there is no central coordinator.

The alternative is two-phase commit (2PC), where every participant prepares and then commits together. 2PC blocks while it waits and scales poorly. Sagas give up all-or-nothing atomicity in exchange for availability, and they rely on careful compensations and idempotent steps, the material of Days 9 to 11: idempotency keys, retries and the outbox.

Pause and think: Which is the better fit for a single Laravel app with one team: orchestration or choreography?

Answer: Orchestration. The steps are explicit, compensations live in one place, and you debug by reading one saga row. Choreography makes more sense when separate teams own separate services and the flow rarely changes.

The design problem

Design the marketplace's checkout as a saga with this happy path: reserve stock, charge the payment gateway, mark the order paid and create the fulfillment row, then queue the merchant webhook and the buyer email.

Decide what compensates if the charge fails after stock was reserved, or if the order write fails after the charge went through. For a Laravel and Redis-queue stack, choose between orchestration and choreography, and decide where the saga's state lives (a DB table or Redis). Then handle the case where payment succeeds and the DB write for the order fails, without leaving a charge with no order and without a double charge on retry.

Try the problem before reading the solution.

Questions to think about

  1. When is a compensation not a perfect undo? Think of an email that's already sent, or stock that gets sold to someone else after you release it.
  2. Orchestration or choreography: which is easier to debug and change when you add a step, like a coupon?
  3. How do you make sure a step's transition is never lost, and that retries and duplicate events are harmless?

Model solution

Steps and compensations

The saga starts by reserving stock under the Day 8 lock. If that fails, the saga aborts, since nothing needs undoing yet.

Next it charges the payment gateway with the idempotency key set to saga_id (or order_id). A failed charge is compensated by releasing the stock.

Then it confirms the order, writing orders plus the fulfillment row in one DB transaction. If this fails after the payment succeeded, compensations run in reverse order: refund or void the payment, then release the stock.

The last step notifies the buyer and merchant through an outbox OrderPlaced event. Delivery is at-least-once, so consumers must be idempotent. A failure here is retried, never rolled back, because a sent email can't be unsent.

Saga flow: reserve stock, charge payment, confirm order, notify, with compensations running backwards: release stock if payment fails, refund then release if the order write fails, and retry if notify fails

Figure 1: Forward steps on top, compensations running in reverse when something fails.

Why orchestration here

With orchestration the order of steps is explicit, compensations sit in one place, and debugging means reading one saga_id row instead of tracing a chain of events through the logs. Adding a coupon step is one new state and one new job. Choreography fits better when separate teams own separate services and the flow rarely changes. For a single Laravel app like this marketplace, I'd choose orchestration.

State diagram of the orchestrated checkout saga stored in MySQL: started, stock_reserved, paid, confirmed, notified, with failure branches to compensating and cancelled

Figure 2: The saga's state machine, stored in a MySQL row next to the order.

Payment succeeded but the order write failed

The saga stays at paid, because the saga row's move to paid (with the charge id) is committed in its own local transaction before the order write is attempted, so the failed order transaction can't roll it back. A recovery job, or the orchestrator's next pass, either retries the confirm step or runs refund plus release.

On recovery, look up the existing charge first, using the charge id stored on the saga row or a search by saga_id or order_id at the provider. Re-sending the charge with the same idempotency key only avoids a double charge while the provider still remembers that key, and providers keep keys for a limited window (Stripe keeps them for at least 24 hours), so a recovery that runs later must not depend on it.

Saga state belongs in MySQL next to the orders. Redis is fine for locks and queues, but it shouldn't be the only record of where a saga stands.

Answers to the questions

  1. A sent email can't be unsent, so you send a correction email instead. A released stock reservation might already be sold to someone else by the time a retry wants it back. A refund isn't instant either, and the customer sees it on their statement. Compensations restore the business state, not the exact past.
  2. Orchestration. You add one state and one job in the coordinator. With choreography you'd be rewiring which service listens to which event, and the flow wouldn't live in any single place.
  3. Each step writes its state change and an outbox row in one transaction, so moving to the next step never gets lost. Each step and each compensation runs with an idempotency key, such as saga_id plus the step name, so retries and duplicate events are harmless.

Interview one-liner

"An orchestrated saga with its state in MySQL: each step is a local, idempotent transaction handed off through the outbox, failures run compensations in reverse order, and notifications retry instead of rolling back."

Conclusion

A saga only works if every step and every compensation is idempotent and its state survives a crash, so keep that state in MySQL and key each step on the saga id. Design the compensations as business actions the customer will see, like a refund or a correction email, because they can't make the failed step disappear.

Try it yourself

Model the checkout saga as a checkout_sagas table with a state column and one job per step. Make the payment step a fake that fails on purpose for some orders, and check that the stock comes back.

Then make the "confirm order" step fail after a fake charge and verify that your recovery job refunds once, even if you run it twice.

This is part of my daily System Design Drills series.

No comments yet. Be the first to comment.