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.
- 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.
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.
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.
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.
- 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.
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) 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.
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.
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.
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.
- 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.
- 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
- 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.
- 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