Source: https://www.hack2hire.com/question-bank/questions/system-design/69e1249f95e80ae213e5cf9f
Company: Amazon
Difficulty: Easy
Problem statement
Browse and search a catalog, keep a cart, check out and track orders, without overselling limited stock.
Requirements
We are building an online bookstore: shoppers browse and search a catalog of books, keep a cart, check out with a card, and track orders, and the store must never sell more copies than it has in its warehouses. Clarify early: physical books only (no ebooks), one currency, a single seller (not a marketplace), and stock that is genuinely scarce for some titles (signed editions, new releases).
Functional requirements
- Search the catalog by title, author, ISBN, keyword; filter by genre, format, price, in-stock; view a book detail page with price and availability.
- Add, update, and remove cart items; carts survive logout and merge when a guest signs in.
- Check out: reserve stock, take payment, create an order, send a confirmation.
- Track orders: status history (placed, paid, shipped, delivered, cancelled, refunded) and cancel before shipment.
- Out of scope: reviews, recommendations, seller onboarding, returns logistics.
Non-functional requirements
- No oversell. Units sold per SKU never exceed units on hand. This is the one hard correctness rule.
- Browse and search: p99 under 200 ms, 99.9% available; a few seconds of staleness on price or stock badge is fine.
- Checkout: p99 under 1.5 s excluding the payment provider, 99.95% available; strongly consistent on stock and order state.
- No double charges and no lost orders, even with client retries, timeouts, and provider webhooks arriving late or twice.
- Read-heavy: roughly 100 views per order.
Back-of-the-envelope estimation
- 5M titles, 20M SKUs (format × condition). ~2 KB per catalog record → 40 GB primary; search index with text ~150 GB.
- 20M DAU, 10 page views each → 200M views/day ≈ 2.3K rps average, 25K rps peak (holiday, launch day).
- 1M orders/day ≈ 12 orders/s average, 200/s peak; a hot release can concentrate 5K checkout attempts/s on one SKU for a minute.
- Carts: 10M active × 1 KB = 10 GB. Orders: 1M/day × 3 KB × 365 × 5 years ≈ 5.5 TB.
API design
Search the catalog:
GET /v1/books?q=dune&genre=scifi&in_stock=true&sort=relevance&cursor=abc&limit=20
→ 200 {items: [{book_id, title, authors, cover_url, price_cents, availability: "in_stock"|"low"|"out"}], next_cursor}
GET /v1/books/{book_id} → {book_id, isbn, title, authors, description, skus: [{sku_id, format, price_cents, availability}]}
Cart:
PUT /v1/cart/items/{sku_id} {quantity: 2} → 200 {cart_id, items, subtotal_cents, version}
DELETE /v1/cart/items/{sku_id} → 200 {cart}
GET /v1/cart → 200 {cart, warnings: [{sku_id, "price_changed"|"out_of_stock"}]}
Place an order (idempotent):
POST /v1/orders
Idempotency-Key: 7f3c...
{cart_id, cart_version, shipping_address_id, payment_method_token}
→ 201 {order_id, status: "pending_payment", total_cents, client_secret}
→ 409 {code: "insufficient_stock", sku_ids: [...]} | 409 {code: "cart_changed"}
Supporting: GET /v1/orders/{id}, GET /v1/orders?cursor=, POST /v1/orders/{id}/cancel.
Payment webhook (from the provider):
POST /internal/payments/webhook (signed)
{event_id, type: "payment.succeeded"|"payment.failed", payment_intent_id, order_id, amount_cents}
→ 200 once recorded; provider retries on non-2xx for up to 3 days
Recovery contract: the handler dedupes on event_id, applies only valid state transitions, and a reconciler polls the provider for any order stuck in pending_payment longer than 15 minutes, so a lost webhook never strands an order.
Data model
Orders, inventory, and payments live in PostgreSQL (sharded by order_id later; inventory sharded by sku_id): transactions and row-level conditional updates are what prevent oversell. The catalog's source of truth is also Postgres; reads go through Elasticsearch/OpenSearch for search and a Redis/CDN cache for detail pages. Carts live in Redis backed by a DynamoDB-style table keyed by user_id.
| Table | Key columns | Notes |
|---|---|---|
books |
book_id PK, isbn unique, title, authors (json), genre, updated_at |
Source of truth; CDC to search |
skus |
sku_id PK, book_id idx, format, price_cents, active |
|
inventory |
sku_id PK, on_hand, reserved, version
|
Invariant: reserved <= on_hand
|
reservations |
reservation_id PK, order_id idx, sku_id, qty, expires_at idx, status |
held / committed / released |
carts |
user_id PK (or guest_token), items json, version, updated_at |
TTL 30 days for guests |
orders |
order_id PK, user_id + created_at idx, status, total_cents, idempotency_key unique |
|
order_items |
order_id, sku_id, qty, unit_price_cents |
Price frozen at order time |
payments |
payment_intent_id PK, order_id idx, status, amount_cents |
|
processed_events |
event_id PK |
Webhook dedupe |
outbox |
id PK, aggregate_id, event_type, payload, published_at |
Transactional outbox to Kafka |
High-level design
flowchart LR
U[Browser or app] --> CDN[CDN]
CDN --> GW[API gateway]
GW --> CAT[Catalog service]
GW --> SR[Search service]
GW --> CART[Cart service]
GW --> ORD[Order service]
CAT --> CDB[(Catalog DB)]
SR --> ES[(Search index)]
CART --> RC[(Redis carts)]
ORD --> INV[Inventory service]
INV --> IDB[(Inventory DB)]
ORD --> ODB[(Orders DB)]
ORD --> PAY[Payment provider]
PAY -. webhook .-> ORD
ODB -. outbox .-> K[Kafka]
CDB -. CDC .-> K
IDB -. CDC .-> K
K --> SR
K --> NOTIF[Notifications]
- Catalog path (cheap, eventually consistent). Detail pages are served from CDN (60 s TTL) and Redis; search hits the index. Changes to books, prices, and stock levels flow by CDC through Kafka into the index within seconds. The availability badge here is a hint, never a promise.
-
Cart. Cart writes go to Redis with a
versioncounter and are persisted asynchronously. A cart holds no stock. On read, it re-prices against the catalog cache and attaches warnings. -
Order path (authoritative, strongly consistent).
POST /orderswith an idempotency key: the order service checks the key, loads the cart, revalidates prices from the catalog DB, and asks inventory to reserve each SKU (sorted bysku_id). If all succeed, it writesorders+order_items+ an outbox row in one transaction, statuspending_payment, and creates a payment intent. -
Payment. The client confirms the card with the provider. The webhook moves the order to
paid, commits the reservations (decrementingon_hand), and emitsOrderPaid. Failure or 15-minute timeout releases the reservations and marks the ordercancelled. - Where the paths meet. Inventory changes feed back into the catalog path via CDC, so the badge flips to "out" within seconds; the order path never trusts that badge and always checks the inventory row.
Deep dives
Preventing oversell under concurrent checkout. Reservation is a single conditional update on the inventory row:
UPDATE inventory SET reserved = reserved + :qty
WHERE sku_id = :sku AND on_hand - reserved >= :qty; -- 0 rows → insufficient_stock
The row lock serializes contenders on that SKU; no read-then-write race is possible. Multi-SKU orders reserve in sku_id order to avoid deadlocks and roll back all on any failure. Reservations carry expires_at = now + 15 min; a sweeper releases expired ones (reserved -= qty) in batches.
| Approach | Correct | Throughput on hot SKU | Notes |
|---|---|---|---|
| Check stock at add-to-cart only | No | High | Oversells; carts are abandoned 70% of the time |
| Conditional UPDATE on row | Yes | ~1-3K/s per row | Default; simple, transactional |
Optimistic version CAS + retry |
Yes | Degrades with contention | Retry storms on hot SKUs |
Redis DECRBY gate + DB commit |
Yes if reconciled | 50K+/s | For launch-day SKUs only |
| Split stock into N bucket rows | Yes | N × row rate | Pick a random bucket with stock; harder to show exact counts |
For a hot release (5K attempts/s on 2K copies), put a Redis counter in front: a Lua script decrements available units and rejects at zero, so only ~2K requests reach Postgres. Postgres remains the source of truth; a reconciler compares Redis vs DB every minute and corrects the gate.
The cart as the bridge between browsing and buying. The cart deliberately holds no stock: reserving at add-to-cart would lock up scarce copies in carts that are 70% abandoned. Instead it stores {sku_id, qty, price_seen}. Guest carts key on a signed cookie token; on login, merge by summing quantities (capped at a per-SKU max) and bump version. cart_version is sent with checkout so the order matches exactly what the user reviewed; a mismatch returns 409 cart_changed. Price changes since price_seen surface as a warning and require re-confirmation before payment.
Order and payment orchestration. The order is a state machine: pending_payment → paid → fulfilling → shipped → delivered, with pending_payment → cancelled and paid → refunded. Every transition is a conditional update (WHERE status = 'pending_payment') so duplicate or out-of-order events are no-ops.
- Idempotency:
orders.idempotency_keyunique; a retry with the same key returns the original response. The payment intent is created with theorder_idas the provider's idempotency key, so a retried call cannot create a second charge. - Webhook: insert
event_idintoprocessed_eventsin the same transaction as the state change; duplicates hit the PK and return 200. - Late success: if payment succeeds after the reservation expired and stock is gone, issue an automatic refund and notify the user rather than oversell.
- Reconciler: every 5 minutes, poll the provider for orders in
pending_payment> 15 min and drive them topaidorcancelled. - Side effects (email, fulfillment, search update) go through the transactional outbox → Kafka, so they happen once the DB commit is durable and are never lost if the process dies.
Catalog freshness and search index lag. CDC from the catalog and inventory DBs feeds an indexer that updates documents within ~5 s p99. Stock updates are coarsened into buckets (in stock / low / out) so a sale that moves 12 to 11 doesn't rewrite the document. Price is rechecked from the DB at checkout, so stale index prices cost a warning, never money. Alert when indexer lag exceeds 60 s; a nightly full reindex into a new index with an alias swap repairs drift.
Operational signals. Abandoned-cart events (cart idle 24 h with items) feed an email job; low stock (on_hand - reserved below a per-SKU reorder point) emits an event to purchasing. Checkout SLOs: success rate of POST /orders excluding user errors > 99.9%, p99 latency, count of orders stuck in pending_payment, reservation expiry rate, and a hard alert if reserved > on_hand on any row.
Trade-offs
- Reserving at checkout rather than at add-to-cart keeps scarce copies available, at the cost of a user occasionally learning at checkout that the last copy is gone.
- A 15-minute reservation hold protects a user mid-payment but takes stock off the shelf; shorter holds raise late-payment refunds, longer ones hurt sell-through on hot titles.
- Conditional row updates are simple and correct but cap one SKU at a few thousand writes/s; the Redis gate scales hot launches but adds a second counter to reconcile.
- Eventually consistent search and badges make browsing cheap and fast, but users sometimes see "in stock" on a book that just sold out.
- Asynchronous payment confirmation (webhooks plus a reconciler) is robust to provider outages, but order status is briefly
pending_paymentand the UI must show that. - Postgres for orders and inventory gives transactions and easy reasoning, at the cost of planned sharding work past a few thousand writes/s.
Interviewer expectations
Mid-level (L4)
- Clear functional scope, a sensible schema for books, carts, orders, and inventory, and REST endpoints for each.
- Separates search (index) from the transactional DB and caches detail pages.
- Prevents oversell with a transaction or conditional update, not a check-then-write.
Senior (L5)
- Splits the cheap catalog path from the authoritative order path and says what consistency each needs.
- Reservations with expiry, idempotency keys on order creation, and a webhook handler that dedupes and survives retries.
- Explains why the cart does not reserve stock and how guest carts merge.
- Quantifies read vs write load and the per-row contention limit.
Staff (L6+)
- Handles a hot-SKU launch (Redis gate or bucketed stock) and the reconciliation that keeps it honest.
- Designs failure recovery end to end: lost webhooks, late payment after expiry, crash between DB commit and side effects (outbox).
- Defines checkout SLOs and the invariant alerts (
reserved > on_hand, stuck orders, index lag) that would catch a regression. - Names what to defer (sharding, multi-region inventory) and the signal that would trigger it.