SYSTEM DESIGN · DESIGN PROBLEMS · COMMERCE AND MONEY · MEDIUM · 27 OF 41

Design an E-commerce Store Like Amazon: Catalog, Cart, Inventory and Checkout

Design an online store where millions browse and thousands check out every minute. Requirements and scale, the shopping API and data model, then the hard parts: a read-optimized catalog with search and caching, a cart in a key-value store, inventory reservations that never oversell, a checkout saga with idempotent payments and an outbox, the order state machine, and where you can be eventually consistent.

Reported atAmazonOraclePhonePe

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Scope: shoppers browse and search the catalog, see a product page with price and availability, keep a cart that follows them across devices, check out and pay, and track their orders. Non-functional needs split in two. Browsing must be fast, under three hundred milliseconds. Checkout must be correct: never oversell, never charge twice, and stay available. Out of scope: sellers onboarding, recommendations, reviews and delivery routing.

Requirements
  • Functional
  • browse and search products
  • product page: price, stock
  • cart across devices
  • checkout and pay
  • order status and history
  • Non-functional
  • page loads under 300 ms
  • never oversell, never double
  • charge; 99.99% for checkout

Ask scale

Fifty million daily shoppers viewing twenty pages each is a billion page views a day, ten thousand requests a second on average and thirty thousand at peak. On a big sale day, multiply by ten. Five million orders a day is only sixty a second, maybe a thousand at the sale peak. The catalog is about two and a half terabytes of product data, and orders add under four terabytes a year. Reads outnumber orders two hundred to one, which shapes everything.

Back of the envelope
50M DAU x 20 page views = 1B/day
  = 10,000 rps avg, 30,000 peak
  sale day: x10 = 300,000 rps

5M orders/day = 60 orders/s avg
  sale day peak: about 1,000/s

catalog: 500M SKUs x 5 KB = 2.5 TB
orders: 5M x 2 KB x 365 = 3.6 TB/yr
images: object storage + CDN

Define API

A handful of endpoints cover it. Search and list products with a cursor, get one product, and get its availability separately, because stock changes far more often than a description. Put items in the cart. And checkout, which carries an idempotency key from the client, so a double tap or a network retry returns the same order instead of creating a second one. Checkout answers with a pending order, because payment finishes asynchronously.

API
GET  /v1/products?q=kettle&cat=home
       &cursor=...  -> list
GET  /v1/products/{id} -> details, price
GET  /v1/products/{id}/availability
PUT  /v1/cart/items { sku, qty }
POST /v1/checkout
  Idempotency-Key: 7f3a...
  { cart_version, address_id, pay_id }
  -> 202 { order_id, status: PENDING }
GET  /v1/orders/{id}

Data model

Products go in a document store, because a television and a T-shirt have completely different attributes. Search runs on Elasticsearch, fed by product change events. The cart is a single document per user in a key-value store, with a version number and an expiry. Inventory and orders go in a relational database, because these are exactly where you want transactions and constraints. Orders shard by user id, since most queries are a user reading their own history.

Stores
products: document store
  { sku, title, attrs{...}, price }
  attributes vary by category
search: Elasticsearch, from events
cart: key-value, key = user_id
  { items[], version }, TTL 30 days
inventory: SQL
  (sku, warehouse, on_hand, reserved)
orders: SQL, shard by user_id
  orders, order_items, status

High-level design

The browsing side. Images and static pages come from the CDN. Search and product pages go through the gateway to the catalog service, which reads from the search index and a cache in front of the product store. Adding to the cart is a single key-value write of the user's cart document. None of these paths touch inventory or orders, so a busy browsing day cannot slow down checkout.

The checkout side. The order service records a pending order, asks inventory to reserve the items, asks the payment service to authorize the amount, then confirms the order and publishes an order placed event. Email, warehouse and analytics all subscribe to that event. Each step is a separate service with its own database, so we coordinate them with a saga rather than one big transaction.

Deep dive: catalog

The catalog is a classic read-scaling problem. Keep one source of truth, and every change emits an event that updates read-optimized copies: the search index for facets and sorting, cached product pages, and CDN images. A few seconds of lag is fine. The trick is to split volatile fields off: the description can be cached for hours, while price and availability are fetched separately with a short lifetime. See the Scaling reads pattern.

Read-optimized catalog
  • source of truth: product store
  • change -> event -> projections:
  • search index (facets, sort)
  • cache of product pages
  • CDN for images
  • lag of seconds is acceptable
  • price and stock: separate,
  • short-TTL calls (30 s), so
  • a page can cache for hours

Deep dive: inventory

Overselling happens when two shoppers read one remaining unit and both buy it. Avoid the read-then-write race by letting the database decide: one conditional update increments reserved only if on hand minus reserved is at least one. One row updated means you got it; zero means sold out. Record the reservation with a fifteen minute expiry. When payment succeeds, convert it to a sale; when it expires, a sweeper releases it. The Flash sale problem covers the extreme hot-item version.

Real stock sits in many warehouses, so keep one inventory row per SKU per warehouse. The product page shows a cached sum across warehouses that can reach the shopper. At checkout, try to reserve from the nearest warehouse that can ship everything in one box, fall back to the next, or split the shipment. A nice side effect: spreading one popular SKU across warehouse rows also spreads the write contention on it.

Reserve, never oversell
UPDATE inventory
  SET reserved = reserved + 1
  WHERE sku = 42 AND warehouse = 7
    AND on_hand - reserved >= 1;
-- 1 row: reserved; 0 rows: sold out

reservation row: (order_id, sku,
  qty, expires_at = now + 15 min)
paid -> on_hand - 1, reserved - 1
expired -> reserved - 1 (sweeper)
Many warehouses
rows per (sku, warehouse)
  sku 42: Delhi 3, Mumbai 0, Pune 12

product page: sum across nearby
  warehouses, cached 30 s
checkout: try nearest warehouse
  that can ship all items first
  else split shipment or next one

hot SKU: rows per warehouse also
  spread the write contention

Deep dive: checkout saga

Checkout is a saga: a sequence of local steps, each with a compensating undo. Create the order as pending, reserve inventory, authorize payment, confirm. If the card is declined at step three, run the undos in reverse: release the reservation and cancel the order. Persist the saga state after each step so a crashed worker resumes rather than restarts, and make every call idempotent on the order id so retries are safe. Concepts episode 33 covers sagas in depth.

Saga steps
1 create order PENDING  | undo: cancel
2 reserve inventory     | undo: release
3 authorize payment     | undo: void
4 confirm order PLACED

declined at 3 -> release, cancel
state saved after every step
  so a crash resumes, not restarts
every call idempotent on order_id

Deep dive: outbox

A quiet but classic bug: you commit the order, then crash before publishing the event, and the warehouse never ships it. The fix is the transactional outbox. In the same database transaction that updates the order, insert the event into an outbox table. A relay process reads the outbox and publishes to Kafka, retrying until it succeeds. That gives at-least-once delivery, so every consumer deduplicates on the event id.

Transactional outbox
problem: commit order, then crash
  before publishing OrderPlaced
  -> warehouse never ships

BEGIN;
  UPDATE orders SET status = PLACED;
  INSERT INTO outbox (event, payload);
COMMIT;
relay reads outbox -> Kafka, marks sent
consumers dedupe on event_id

Deep dive: order states

Model the order as an explicit state machine: pending, placed, packed, shipped, delivered, with cancelled and returned branches. Enforce transitions in the update itself, for example set shipped where status is packed, so two workers cannot race the order into a bad state. A common real-world choice is to authorize the card at checkout and capture the money only when the item ships, which makes cancellations before shipping cheap.

Order lifecycle
PENDING -> PLACED -> PACKED
  -> SHIPPED -> DELIVERED
PENDING -> CANCELLED (payment failed)
PLACED  -> CANCELLED (user, refund)
DELIVERED -> RETURNED (refund)

authorize at checkout,
  capture when shipped
UPDATE ... WHERE status = PACKED
  (no illegal jumps, no races)

Deep dive: cart

The cart is simpler than it looks. A guest cart lives under a session id and merges into the user's cart at login. A version number rejects a stale write from another device. Store only SKU and quantity, never price, because prices change. Checkout re-reads price, stock and promotions and shows the shopper any changes. The cart is a hint, and checkout is the moment of truth.

Cart rules
  • guest cart keyed by session id,
  • merged into user cart at login
  • version number: reject stale
  • writes from another device
  • store sku + qty, not price
  • checkout re-reads price, stock,
  • promos: the cart is a hint
  • TTL 30 days; save for later
  • is a separate list

Trade-offs

The main trade-off is a consistency map. Inventory, orders and payments need strong consistency, so they live in relational databases with transactions. Search, product pages, cart sync, order history views and recommendations can lag by seconds. The in-stock badge on a product page may occasionally be wrong; the reservation at checkout never is. Saying that sentence clearly earns real points.

Failure modes. A payment call that times out has an unknown result, so never guess: poll the provider or wait for its webhook, then reconcile. If search lags, shoppers see slightly old data but checkout stays correct. If the cache fails, the catalog database needs enough replicas to survive. Monitor checkout success rate, oversell count, which should be zero, expired reservations and page latency. At ten times the scale, shard inventory by SKU and orders by user.

Consistency map
  • strong (SQL, transactions):
  • inventory, orders, payments
  • eventual (seconds of lag OK):
  • search, product pages, cart
  • sync, order history views,
  • recommendations
  • stock badge on page may lie;
  • reservation at checkout cannot
Failures and growth
  • payment timeout: status unknown
  • -> poll provider, reconcile
  • search lag: shopper sees old
  • data, checkout still correct
  • cache down: catalog DB must
  • survive with replicas
  • watch: checkout success rate,
  • oversell count (target 0),
  • expired reservations, p99
  • 10x: shard inventory by SKU

Interview summary

In the interview, lead with the split: browsing is a read-scaling problem and checkout is a correctness problem. The catalog has one source of truth projected into search, caches and the CDN. The cart is a key-value document that checkout re-prices. Inventory uses a conditional update and a timed reservation. Checkout is an idempotent saga with an outbox for its events. Strong consistency for money and stock, eventual everywhere else.

Say it like this
  • two systems: browse vs buy
  • 300k rps sale peak, 1k orders/s
  • catalog: one source of truth,
  • events to search, cache, CDN
  • cart: KV doc, checkout re-prices
  • inventory: conditional update
  • + 15 min reservation
  • checkout saga + idempotency key
  • outbox for reliable events
  • strong for money, eventual else

Interview questions

How do you prevent overselling the last item?

Reserve stock with a single conditional update that only succeeds when on-hand minus reserved is enough, so the database serializes competing buyers. Reservations carry an expiry and are released by a sweeper if payment never completes.

Why use a saga for checkout instead of a distributed transaction?

Inventory, orders and payments are separate services with their own stores, and a two-phase commit across them is slow and fragile, especially with an external payment provider. A saga runs local steps with compensating actions, persisting progress so it can resume after a crash.

How do you avoid creating two orders when the user clicks twice?

The client sends an idempotency key with checkout, and the server stores the key with the resulting order id, returning the same order for any repeat request with that key.

Which data needs strong consistency and which can be eventual?

Inventory reservations, orders and payments need strong consistency and live in transactional stores. Search results, product pages, recommendations and order history views can safely lag by seconds.

Why store the cart in a key-value store?

A cart is read and written as one document by a single key, the user or session id, and needs high availability more than relational features. Price and stock are re-validated at checkout, so the cart does not need to be authoritative.

What is the transactional outbox and why does an order service need it?

It writes outgoing events to an outbox table in the same transaction as the order change, and a relay publishes them to the message bus. This prevents losing an event when the service crashes between committing the order and publishing.

How do you keep search results in sync with the product database?

Publish change events from the product store, or use change data capture, and have an indexer update the search index asynchronously, accepting a few seconds of lag.

What do you do if the payment call times out?

Treat the result as unknown, keep the order pending, and query the provider or wait for its webhook using the same idempotency key, rather than assuming failure and retrying with a new charge.