Skip to content
QSWEQB
hardDesignMidSeniorStaff

Design the checkout flow for a marketplace with multiple sellers per order.

Persist the order in a pending state before you touch the payment gateway, quote tax and shipping per seller shipment, authorise once behind an idempotency key, and commit the remaining steps through a transactional outbox and a saga whose compensations void the authorisation and release reserved stock.

7 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate persist an order intent before calling the payment gateway, or authorise first and leave money attached to nothing
  • Whether authorisation and capture are treated as separate events tied to fulfilment rather than one atomic payment
  • That tax and shipping are computed per seller shipment, because both depend on where the goods ship from
  • Whether the multi-step commit uses an outbox and compensations instead of an implied distributed transaction
  • Does the candidate name a reconciliation path for a gateway success whose local write was lost, with a deadline attached

Answer

A cart is a draft and an order is a promise

The most useful boundary in this design is that a cart holds no commitments and an order holds all of them. A cart is a mutable list of SKU references with quantities, and it does not store prices as facts - it recomputes them on every read, because a cart can sit for three weeks while the seller reprices, the promotion ends, and one line goes out of stock.

Checkout is the transition into something you can be held to, and it is worth modelling as an explicit sequence of states rather than a single POST: the buyer supplies a destination, you produce a priced quote, the buyer accepts it, you take the money, and only then do sellers ship. Each is a state on the order, because each can fail in a way you must recover from without a human reading logs. Prices, taxes, shipping, and promotion eligibility are resolved once at quote time and frozen onto the order with the version of every input that produced them. If the seller reprices between quote and payment, the buyer pays what they were quoted and you either absorb the difference or void the quote - silently repricing at capture time is how you get a charge that does not match the confirmation email.

Quoting: address first, then shipping, then tax

Address handling has two jobs that people collapse into one. Normalisation converts what a human typed into a canonical form your carriers accept; verification asks whether that address is deliverable. Both should suggest rather than reject, because a hard block on an address your validator does not recognise loses real orders in every country with informal addressing. Keep it separate from the billing-address check the card networks perform during authorisation, which is a fraud signal about the cardholder and says nothing about whether a parcel can arrive.

Shipping is where the single-order abstraction breaks. Goods from three sellers ship from three origins, so one order becomes several shipments, each with its own rates, estimate, and carrier. The shipment - sometimes called a fulfilment group - is therefore the real unit, and an order is a collection of them held together by one payment and one buyer.

Tax then depends on the shipment rather than the order, because in most regimes the rate is a function of where goods ship from, where they ship to, and sometimes what the item is - a tax code, not a price. So tax is calculated per shipment line and summed, never applied as one rate to an order total. The marketplace wrinkle is liability: in many jurisdictions the platform rather than the seller must collect and remit, which makes the calculation yours to defend under audit, and that is the argument for storing the full per-line breakdown rather than only the total.

Where authorisation sits relative to order creation

This is the decision the rest of the design hangs from, and there is a right answer. Persist the order first, in a PENDING_PAYMENT state, generate an idempotency key for the payment attempt, store that key on the order, and only then call the gateway.

That ordering means money can never exist without a local record to attach it to. If the gateway call succeeds and your process dies before recording the result, you still hold the order and the exact idempotency key, so you can query the gateway or replay the call and learn what happened. Do it the other way and a crash in between leaves a hold on a customer's card that no row in your database knows about - not a retryable error but an accounting discrepancy that surfaces as a chargeback.

Keep authorisation and capture separate for the same reason. Authorisation reserves funds and proves the card is good; capture moves them. Authorise at checkout so the buyer gets an immediate yes or no, and capture when you can fulfil - per shipment, where sellers dispatch at different times. Authorisations do not last indefinitely and the window varies by network and issuer, so a seller who takes three weeks to dispatch needs either an early capture with a clear refund policy or a re-authorisation. Assume too that none of this is synchronous: strong customer authentication in Europe and equivalent step-up flows elsewhere mean the buyer may be redirected to an issuer and return minutes later, or never.

Splitting money across sellers

Two shapes exist and they differ in who is the merchant of record. The platform can take one authorisation for the whole basket and move funds onward to each seller afterwards, deducting commission; or it can authorise separately against each seller's account, making each seller the merchant for their own lines. Providers with marketplace support expose both.

The single-charge model gives the buyer one statement line and one refund story, and makes the platform responsible for chargebacks and for holding funds it does not yet own. The per-seller model puts disputes with the seller where they arguably belong, at the cost of multiple statement lines and a hard failure mode: one authorisation declines and you hold a partial basket. Decide in advance whether that is a partial order, a full failure, or a retry on another method.

Either way, capture and refund are per seller. Refunding one line means clawing back that seller's share and deciding what happens to the commission already recognised on it, so model commission as a ledger entry against the shipment rather than a percentage recomputed at refund time - otherwise the numbers stop reconciling once discounts and partial refunds interact.

The multi-step commit, without a distributed transaction

Checkout writes to inventory, payments, orders, ledger, and notifications, which are not in one database and cannot be in one transaction. Two-phase commit across a gateway you do not own is not available, so the honest design is a saga: a sequence of local transactions, each with a defined compensation, driven by the order state machine. What makes it reliable is the transactional outbox - every state change writes both the row and the event announcing it in the same local transaction, and a separate relay publishes from the outbox table. That removes the dual-write failure where the order is marked paid but the "order placed" event never reaches fulfilment.

BEGIN;
UPDATE orders SET state = 'AUTHORISED' WHERE id = :order_id AND state = 'PENDING_PAYMENT';
-- The event is committed atomically with the state it describes,
-- so a crash before publishing loses neither.
INSERT INTO outbox (aggregate_id, type, payload)
VALUES (:order_id, 'OrderAuthorised', :payload);
COMMIT;

Compensations are the part candidates skip. Failing after authorisation means voiding it rather than refunding it, because a void leaves nothing on the buyer's statement while a refund leaves a debit, a credit, and a confused customer. Failing after reservation means releasing reserved stock. And because the relay delivers at least once, every consumer must be idempotent, keyed on the event identifier or on the state it transitions from - which is what the AND state = 'PENDING_PAYMENT' guard above is doing.

When the money moved and the order did not

This is the failure that separates a design from a diagram, and the useful insight is that the ordering above nearly eliminates it. If the order row exists before the gateway is called, there is no such thing as a payment with no order; there is only an order whose payment result you have not yet recorded, which is a resolvable state rather than a mystery.

So handle it as reconciliation. The idempotency key was persisted before the call, so retrying cannot double-charge - the gateway returns the original result. The webhook is a second, independent path to the same fact, and must be idempotent and treated as authoritative because it may arrive before your synchronous response. Behind both, a periodic job compares gateway authorisations against local orders in both directions, and the direction people forget is gateway records with no local order. For anything it cannot resolve there is one acceptable outcome and it carries a deadline: void or refund automatically within a stated window, and alert. An authorisation left to expire quietly is still a hold the customer can see, and "it will drop off in a few days" is not a design.

Ordering the steps so money is never authorised without a persisted order turns the worst failure in checkout from a data-loss incident into a reconciliation job with a deadline. The unrecoverable case - captured funds against an order that can never exist - should be impossible to reach from your own state machine, and if you cannot argue that, you have found the thing to fix.

Likely follow-ups

  • The buyer's card authorises for two of three sellers. What does the customer see, and what do you do with the money?
  • Where do you capture, and what changes if one seller ships in two days and another in three weeks?
  • How do you handle a partial refund when the platform fee has already been taken?
  • The tax service times out at quote time. Do you block checkout, and what do you tell the customer?

Related questions

Further reading

checkoutmarketplacepaymentssagaoutboxsplit-payments