System design interview
Design an e-commerce checkout
Order states, sagas with compensation, outbox events and stock reservations.
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- The shopper presses Place order. The app sends the cart ID and a unique key made when the checkout page opened.
- The API prices the cart again, then saves the order, its price snapshot and an outbox event in one transaction. A repeated key finds the same order.
- A relay reads new outbox rows and publishes 'order created' to the event queue, at least once. The shopper's page already says 'processing'.
- The orchestrator receives the event and starts the saga for this order.
- Client
- Checkout API: price snapshot, key
- Order DB: orders and outbox
- Event queue
- Orchestrator: runs the saga
- Inventory service: stock holds
- Payment provider: authorize, capture
- Shipping service
On this page
In 2 minutes
I would treat checkout as a workflow across three services, not a single transaction. The Checkout API prices the cart again on the server, snapshots the lines, prices and tax, and saves an order in state created with an outbox event in one database transaction. A unique idempotency key makes Place order safe to repeat.
A relay publishes the outbox to a queue, and an orchestrator runs a saga: hold stock, authorize the card, commit the stock holds so they stop expiring, confirm the order, ask shipping to pack, and capture the payment when the parcel ships. Each step is safe to repeat because it carries the order ID as a key. If a step fails, the orchestrator runs compensations in reverse: void the authorization, release the hold, mark the order failed.
Order state changes are conditional updates, so two workers cannot both advance the same order, and a sweeper finishes any order stuck for 15 minutes. Inventory keeps on hand and reserved counts per item and warehouse, reserves and commits holds with conditional updates, and releases expired uncommitted holds exactly once. The estimate is about 350 orders a second at peak, which ordinary databases handle, so the design is about partial failure, not throughput.
Step 1 of 6: Clarify the scope
Interviewer asks
Design the checkout of an online store. A shopper submits a cart and ends up with a paid order, reserved stock and a shipment on its way. The work spans separate inventory, payment and shipping services. No order may be lost, no stock sold twice, and pressing Place order twice must not charge twice.
Questions to ask first
Ask these before you draw anything. Under each one is why its answer changes the design.
- Does the store have one warehouse or many?
With many, checkout must choose where each item ships from, and it may have to split an order.
- Do we charge the card at checkout or when the order ships?
Authorizing at checkout and capturing at shipment means a failed order needs a void, not a refund.
- How long can we hold stock for a shopper who has not paid yet?
A hold needs an expiry. Its length trades fairness to shoppers against stock that sits idle.
- Can prices and promotions change while someone is shopping?
We must decide which price the shopper pays: the one on screen or the one at submit.
- Can an order be cancelled after it is placed?
Cancelling needs an undo for every step that is already done.
Requirements
Functional
- Turn a cart into an order with prices, tax, shipping and discounts fixed at the moment of submit.
- Reserve stock for every line, authorize payment and confirm the order, or fail cleanly with nothing left held.
- Make Place order safe to repeat: one click or ten creates one order and one charge.
- Show the order's status from placed to shipped, and let the shopper cancel before it ships.
- Capture the payment when the order ships, and void or refund it when the order is cancelled.
Non-functional
- Place order shows a final result, confirmed or failed, within 3 seconds at p99, including payment authorization.
- Stock is never oversold: units promised never exceed units on hand.
- A repeated Place order creates one order and one charge.
- Every order reaches confirmed, failed or cancelled within 15 minutes, with no stock left held.
- Handles 350 orders a second at peak.
Step 2 of 6: Size it
The volumes are small enough for ordinary databases. The risk is partial failure across three services, so design around durable workflow state and steps that are safe to repeat, not around raw throughput.
Assumed for the exercise, not facts about a real system:
- 3 million orders a day, with 3 lines each on average. Peak traffic is 10 times the average.
- A stock hold lasts 60 seconds on average, with a limit of 15 minutes.
- Each order produces 6 workflow events of about 1 KB each: created, stock reserved, payment authorized, confirmed, shipped and captured.
- An order record with its lines and history takes about 3 KB.
- Orders per second
- 3M a day ÷ 86,400 s ≈ 34.7
- ≈ 35/s on average, about 350/s at a 10x peak
- Stock reservations per second
- 350 orders/s × 3 lines
- ≈ 1,050 conditional decrements a second at peak
- Workflow events per second
- 350 orders/s × 6 events × 1 KB
- ≈ 2,100 events/s, about 2 MB/s at peak
- Order storage
- 3M × 3 KB = 9 GB a day; 9 GB × 365
- ≈ 3.3 TB a year
- Holds in flight
- 350 orders/s × 60 s
- ≈ 21,000 live holds at peak
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
The Client posts the cart and an idempotency key to the Checkout API. The API prices the cart again on the server, takes a snapshot of the lines, prices, tax and shipping, and writes one order row in state 'created' to the Order DB. In the same transaction it writes an outbox row, an event that says 'order created'. Then it answers 'processing'.
A relay reads outbox rows and publishes them to the Event queue. The Orchestrator consumes the event and runs the order as a saga: ask the Inventory service to hold stock, ask the Payment provider to authorize the card, tell Inventory to commit the hold so it stops expiring, then mark the order confirmed and ask the Shipping service to pack it. After each step it saves the new state in the Order DB.
If a step fails, the orchestrator runs the compensations for the steps that are done, in reverse order: release the stock hold, void the authorization, mark the order failed. Every call carries the order ID as an idempotency key, so a retry after a timeout never does the same thing twice.
Other paths through the system
Reserve, pay, confirm
- Step 1: the orchestrator asks Inventory to hold stock for each line for up to 15 minutes, with the order ID as the key.
- Inventory ran one conditional decrement per line, and every line is held.
- Step 2: the orchestrator asks the provider to authorize the card for the total, with the order ID as the key.
- The provider approves. The money is set aside on the card but not yet taken.
- Step 3: the orchestrator tells Inventory to commit the holds, so they stop expiring. Each update runs only if its hold is still active.
- Inventory commits every hold. If one has expired, it refuses, and the orchestrator voids the authorization and fails the order.
- Step 4: the orchestrator marks the order confirmed, and writes an 'order confirmed' outbox row in the same transaction.
- It asks Shipping to pick and pack the order. Payment is captured later, when the parcel ships.
Payment fails
- The orchestrator asks the provider to authorize the card.
- The provider declines the card. There is nothing to void.
- Compensation: the orchestrator tells Inventory to release the hold for this order.
- Inventory gives the units back once, and records the release so a repeat does nothing.
- The orchestrator marks the order failed and writes an 'order failed' outbox row, which tells the shopper why.
Step 4 of 6: Go deep on the hard part
From cart to order
Interviewer asks
What exactly happens when the shopper presses Place order, and what makes it safe to repeat?
The cart is a draft.
Read moreShow less: From cart to order
The order is a promise. The server builds the order from the cart at submit time and never trusts prices sent by the browser. It reads the current price of every item, applies promotions and tax, adds shipping, and stores all of it as a snapshot on the order: lines, unit prices, discounts, tax, total and address. Later price changes cannot alter an order that exists.
If the snapshot differs from what the shopper last saw, the API refuses with the new total and the shopper confirms again. This keeps the charge equal to what they agreed to.
Idempotency. When the checkout page opens, the app makes a random key. Place order sends it. A unique constraint on the customer and the key in the Order DB means a second request, from a double click or a retry after a timeout, finds the first order and returns it. The order ID goes on to inventory and payment as their key.
The state machine. An order has a few states and only these moves: created, stock held, payment authorized, confirmed, shipped and completed, with failed and cancelled as exits. A change of state is a conditional update: 'set state to confirmed where state is payment authorized'. If no row changes, another worker got there first and this one stops. That makes the orchestrator safe to run twice, and it means an order can never be confirmed without stock held and payment authorized.
Store each move in a history table with a time and a reason. Support staff and the shopper's status page read from it.
Keep the answer fast. The API returns once the order row is saved, with the status 'processing', and the page learns the result by polling or a push. Waiting for three services inside the request would tie the shopper's request to the slowest of them.
A saga with compensations, and the outbox
Interviewer asks
There is no single transaction across inventory, payment and shipping. How do you keep them consistent?
Each service owns its data, so one database transaction cannot cover them all.
Read moreShow less: A saga with compensations, and the outbox
A saga replaces it: a series of local transactions, each with a compensation that undoes it if a later step fails. With orchestration, one service, the orchestrator, holds the order's progress and tells each service what to do next.
- Reserve stock. Compensation: release the hold.
- Authorize payment, which sets money aside on the card without taking it. Compensation: void the authorization.
- Commit the hold, so it no longer expires. Compensation: release it.
- Confirm the order, then ask shipping to pack it. Compensation: cancel the shipment request, if the parcel has not left.
- On shipment, capture the payment. If the order is cancelled after that, the compensation is a refund.
Order the steps so the easiest to undo comes first and the hardest comes last. A stock hold is cheap and expires by itself. Taking money from a card is the hardest to take back. That is why we hold, then authorize, and capture only when the parcel ships.
Every step and every compensation must be safe to repeat, because messages arrive at least once and calls time out. Each call carries the order ID and the step name as its key. The receiver stores the result of the first call and replays it for repeats. A compensation must also cope with arriving before the thing it undoes. A release that arrives before the hold is recorded leaves a marker, so the late hold is ignored.
The outbox. The API and the orchestrator both change a row and publish an event. If they write the row and then publish, a crash between the two loses the event and the order stalls. If they publish first, they may announce a change that never committed. So they write the event into an outbox table in the same transaction as the state change. A relay reads the outbox and publishes, marking rows as sent. If the relay crashes it sends again, so consumers must be safe to repeat. A state change and its event now commit together or not at all.
Timeouts. A step that gets no answer is retried with the same key. A sweeper looks for orders stuck in a non-final state for 15 minutes, and drives each one to failed by running the compensations.
Stock holds across warehouses
Interviewer asks
How do holds work for an order that may ship from several warehouses, and when do they become permanent?
Inventory keeps a row per item and warehouse with an on hand and a reserved count.
Read moreShow less: Stock holds across warehouses
A hold is one conditional update, 'add to reserved where on hand minus reserved is at least the quantity', plus a hold row with an expiry. The hold mechanics, the expiry job and the flash-sale buckets are the same as in the ticketing problem. This section covers what is special to checkout.
Choosing a warehouse. Rank the warehouses by distance to the customer and pick the nearest one that holds every line. If none does, allow a split into several shipments and tell the shopper. Choose first, then reserve against the choice. If that reserve fails, try the next warehouse once.
A split order has one group of holds per warehouse, all under the same order ID. A release or commit by order ID then covers every group. If any group cannot be held, the others are released, so the order is all or nothing.
Committing the hold. A hold lasts up to 15 minutes, but the parcel may ship hours or days later, and payment is captured only then. So once the card is authorized, the orchestrator sends 'commit the holds for order X', keyed by the order ID. Inventory runs one conditional update per hold: mark it committed and clear its expiry, only where it is still active. A committed hold still counts as reserved, so nobody else can buy that stock, and the expiry job skips it.
If a hold has already expired, the update changes no row and the commit fails. The orchestrator voids the authorization and fails the order. Without this step, every confirmed order would see its stock return to the shelf after 15 minutes. Shipping later turns each committed hold into a decrement of on hand, and a cancellation releases it.
Unknown results. If a reserve or commit call times out, the orchestrator does not know whether it happened. It retries with the same key. Inventory finds the existing hold and returns it, or creates one. It never guesses and never releases blind.
Reconciling. A nightly job compares each item's reserved count with the sum of its active and committed holds, and each committed hold with a live order. A hold whose order is failed, cancelled or missing is released and reported. A wrong count is rebuilt from the holds, which are the source of truth.
Step 5 of 6: Name the trade-offs
Cross-service consistency
A saga with compensations over A distributed transaction across the services
No service holds locks while the others work, and each can scale on its own. The cost is that the order is briefly in between states, and every step needs an undo.
Orchestration style
One orchestrator that tells each service what to do over Services reacting to each other's events
The whole story of an order is in one place, so timeouts and debugging are simple. The cost is one more service to run, and a risk that it grows to know too much.
Payment timing
Authorize at checkout, capture at shipment over Charge in full at checkout
A failed or cancelled order needs only a void, with no refund to wait for. The cost is that authorizations expire, so a long wait before shipping needs a fresh one.
Stock
A soft hold with an expiry over Decrement stock at once
Stock that a shopper abandons returns by itself. The cost is a job to release holds, and two counts to keep right.
Step 6 of 6: Say what breaks next
Questions an interviewer asks once the design is on the board. Answer each one out loud first.
- What if the payment provider says 'pending' and answers later?
Treat it as a state of its own: payment pending. The saga waits for the provider's callback, which carries our key, and matches it to the order. A timeout of a few minutes moves the order to failed, and anything that is authorized afterwards is voided.
- How would you handle a flash sale with 100,000 shoppers wanting 1,000 units?
Use the same approach as the ticketing problem: a waiting room in front of checkout, so only as many shoppers enter as the backend can handle. Split the hot item's counter into buckets and show 'sold out' from a cache. The holds work as they do for any order, with a short expiry.
- How do you cancel an order after it is confirmed?
Move it to a cancelling state and run the compensations for the steps already done: cancel the shipment if it has not left, release the hold, and void or refund the payment. A parcel that has left cannot be undone, so the order becomes a return instead.
- How do you test the failure paths?
Inject faults at each step, such as a timeout after the call succeeded, a duplicate message, and a crash between a state change and its event. After each run, check the rules that must always hold: no charge without an order, and held stock equal to the stock of live orders. Run the sweeper in the tests too, since it is the safety net.