AHH Ali Hajj Hassan
Contact

Engineering notes Deep note

Thera Architecture · Backend · Reliability

Designing multi-store checkout as a saga

How Thera turns one purchase across several independent stores into per-store orders — a preflight, idempotency at two levels, compensation and careful locking instead of one giant database transaction.

6 min read

On this page · 10 sections
  1. 01 The problem
  2. 02 Constraints
  3. 03 Why not one transaction?
  4. 04 Preflight
  5. 05 Idempotency
  6. 06 Compensation
  7. 07 Concurrency
  8. 08 Tradeoffs
  9. 09 Lessons
  10. 10 What I’d change next

A marketplace cart can hold products from several stores. In Thera, every store is its own business: it owns its stock, its orders and its invoices, and the platform never issues a merged sales invoice. So “pay for this cart” is really several independent purchases that the customer experiences as one action.

This note is about how that checkout is put together, and why it isn’t one transaction.

The problem

Each store in the cart needs its own:

  • order, with its own fulfilment lifecycle;
  • stock update, which must never oversell;
  • invoice, issued by the store.

The customer still presses one button and expects one outcome: everything went through, or nothing did.

Constraints

On top of that:

  • Late failures happen. A store can fail after an earlier store in the same cart has already succeeded — it gets suspended, or its last unit sells a moment earlier.
  • Retries are normal. Lost responses and double taps mean the same request can arrive twice, and it must never create duplicate orders.
  • Stock beats speed. Two customers buying the last unit at the same moment must not both succeed.

Why not one transaction?

Wrapping every store’s order in a single database transaction looks simpler, but it works against the domain. It couples every store’s stock, order and invoice into one unit, holds locks across all of them for the whole purchase, and needs a second, bigger checkout that would slowly drift from the tested single-store one.

  1. Customer checks out a cart with three stores
  2. Preflight passes — no writes yet
  3. Store A: order, stock and invoice created
  4. Store B: order, stock and invoice created
  5. Store C fails — its last unit just sold
  6. Reverse store B, then store A
  7. Return an explicit "cancelled" result
A late failure: completed stores are reversed newest-first, and the customer gets a clear outcome.

Preflight

Most failures are predictable: a suspended store, an empty cart, a coupon that doesn’t apply, a cart that mixes currencies. A preflight pass checks every store, cart, coupon and the currency before anything is written.

Predictable problems are rejected cleanly, so the saga never creates orders only to undo them. Compensation is left for the failures nobody could have predicted.

Idempotency

Idempotency works at two levels.

  • The whole purchase is keyed by the customer and an idempotency key, stored with a hash of the request. Repeating the same request returns the existing result without re-running anything. Reusing the key for a different request is rejected as a conflict — never answered with the old purchase.
  • Each store’s order uses a key derived from the purchase key and the store, so a retried purchase can’t create a second order in any one store.

When two identical requests race, a unique constraint decides: one creates the purchase, the other is answered as a replay or a conflict.

Compensation

Stores are checked out one at a time. If a store fails after the preflight, the orders already created are reversed in reverse order: refunded if paid, cancelled if not. Coupon use is given back too, because from the customer’s point of view no purchase happened.

Simplified, the orchestration looks like this — illustrative TypeScript, not the production source:

async function checkoutCart(customer, request, key) {
  const previous = await findPurchase(customer, key);
  if (previous) return replayOrReject(previous, hash(request)); // same → result, different → conflict

  await preflight(request); // stores, carts, coupons, currency — no writes
  const purchase = await createPurchase(customer, key, hash(request));

  const created = [];
  for (const store of request.stores) {
    try {
      created.push(await checkoutOneStore(store, `${key}:${store.id}`, purchase.id));
    } catch (error) {
      const stillActive = await reverse([...created].reverse()); // refund paid, cancel pending
      if (stillActive.length) throw partialFailure(purchase, stillActive);
      throw cancelled(purchase, error);
    }
  }
  return confirm(purchase); // one confirmation; each store issued its own invoice
}

Concurrency

Inside each store’s checkout, two rules keep concurrent purchases correct.

Claim the cart once. The cart is claimed with a compare-and-set, so the check and the update are a single statement (simplified):

UPDATE cart SET status = 'CONVERTED'
WHERE id = $1 AND status = 'ACTIVE';
-- 0 rows updated → another checkout already claimed this cart

Lock stock in a fixed order. The order is written in a transaction that locks the product rows being bought FOR UPDATE, in ascending id order. Locking prevents overselling; the fixed order stops two multi-item orders from deadlocking on each other’s rows.

Request ARequest B
Claims the cart — 1 row updatedClaims the cart — 0 rows updated
Locks products in id orderStops: cart already checked out
Writes the order, decrements stock—
Only one checkout can win the claim; the loser stops before touching stock.

Tradeoffs

Deliberate limits keep the rest manageable: one currency per purchase, and one coupon per store, with no stacking. The design fits Thera’s closed-beta scale while keeping the boundaries clear enough to evolve.

Lessons

The most instructive bug wasn’t in the happy path.

Two smaller habits came out of the same work:

  • Record before you link. Each store’s order goes onto the compensation list before it is linked to the purchase, so a failure between those two writes can’t leave an order that nothing knows to reverse.
  • Test the failure paths, not just the purchase. The end-to-end suite covers a two-store purchase (one order per store, stock reduced in each), a failure in the second store that leaves no active order or stock change behind in the first, and a mixed-currency cart rejected before any order exists.

What I’d change next

  • Recover partial failures automatically. Thera already runs a database-backed job queue with retries and dead-lettering. Moving stuck reversals onto it would let them retry safely and escalate only when they truly can’t complete.
  • Parallelise only with evidence. If large multi-store carts become common, the per-store steps could run in parallel or move to a reserve-then-confirm flow — once real latency numbers justify the complexity.

See the full Thera architecture →