Designing an E-Commerce Checkout System
Design a checkout that reserves stock, prices carts, takes payment and creates orders exactly once, even during flash sales and partial failures.
Checkout is the narrowest and most valuable path in a store. Every request ends in money moving, so correctness matters more than raw throughput, yet the path must still survive a flash sale.
Requirements
Functional
- Build a cart, apply promotions, compute tax and shipping.
- Reserve inventory, authorize payment, create exactly one order.
- Send confirmation and trigger fulfillment.
Non-functional
- 99.99% availability for the checkout API; p99 latency under 800 ms excluding the payment provider.
- Zero duplicate orders and zero oversold items for tracked stock.
- Handle 10x traffic spikes during sales.
- Orders durable with RPO of zero.
Capacity Estimation
Assume 20M daily active users, 2% convert, giving 400K orders per day, about 5 per second on average. A flash sale can hit 100x, so plan for 500 orders per second and 5,000 cart mutations per second. An order row is about 2 KB plus 5 line items at 300 bytes, so roughly 3.5 KB. At 400K orders per day that is 1.4 GB per day, around 500 GB per year before indexes. Carts are ephemeral and fit in a key-value store: 5M active carts at 2 KB is 10 GB.
High-Level Design
Client -> API Gateway -> Cart Service (Redis + DB)
-> Checkout Orchestrator (saga)
|-> Pricing Service (tax, promos)
|-> Inventory Service (reserve)
|-> Payment Service (authorize)
|-> Order Service (create)
'-> Outbox -> Kafka -> Fulfillment, Email, Analytics
The orchestrator runs a saga: price, reserve, authorize, create order, capture. Each step has a compensating action: release the reservation, void the authorization, cancel the order.
Data Model
carts(cart_id, user_id, items_json, version, updated_at)reservations(reservation_id, sku, qty, checkout_id, expires_at, state)checkouts(checkout_id, cart_id, idempotency_key, state, price_snapshot, payment_ref)orders(order_id, checkout_id UNIQUE, user_id, total, state)order_items(order_id, sku, qty, unit_price, tax)outbox(id, aggregate_id, event_type, payload, published_at)
The checkout stores a price snapshot so the amount the customer approved is the amount charged, even if the catalog changes mid-flow.
Deep Dives
Idempotency
The client generates a checkout key when the page loads and sends it on every submit. The checkouts table has a unique index on it. A retry after a timeout returns the stored state rather than starting a second saga. The same key is forwarded to the payment provider so a retried authorization cannot double charge. The order table has a unique constraint on checkout_id as a last line of defense.
Inventory consistency
Reserve stock with a conditional atomic update: decrement available where available is at least the requested quantity. One statement, no read-modify-write race. For hot SKUs, split the counter into 16 shards and draw from random shards, falling back to others when one is empty. Reservations expire after 10 minutes via a sweeper, returning stock to the pool. Convert the reservation into a permanent decrement only after payment succeeds.
Concurrency on carts
Carts use optimistic concurrency with a version column. Two tabs editing the same cart produce a conflict that the client resolves by refetching. Checkout locks the cart by moving it to a frozen state, so edits cannot change totals mid-payment.
Payment and order atomicity
Authorize first, capture after the order is committed. The order row and its outbox event are written in one local transaction, and a relay publishes to Kafka at least once. Consumers deduplicate by event id.
Failure Modes and Trade-offs
- Payment times out: the state is unknown. Mark the checkout as pending, query the provider by idempotency key, and reconcile. Never release stock until the outcome is known.
- Orchestrator crash mid-saga: state is persisted per step, so a recovery worker resumes or compensates from the last checkpoint.
- Inventory service down: fail closed for tracked stock, or fail open with backorder for items where overselling is cheap. This is a business decision, not a technical one.
- Flash sale stampede: put a virtual waiting room in front, admitting users at the rate the inventory and payment stages sustain.
- Orchestration versus choreography: orchestration gives clear state and easier debugging but creates a central component to scale; choreography reduces coupling but hides the flow.
- Reserve early versus late: reserving at cart add hurts availability for others; reserving at payment risks failed purchases. Reserving at checkout start is the usual compromise.
Key takeaways
- Treat checkout as a saga with explicit compensations and persisted state.
- Use one idempotency key from client to payment provider to order table.
- Reserve stock with a single atomic conditional update, expire reservations, and shard hot SKUs.
- Snapshot prices at checkout start so the charged amount matches what was shown.
- Write order and outbox event in one transaction; deliver downstream at least once with deduplication.
- Protect the pipeline with a waiting room rather than letting spikes overload payment and inventory.