- A read-heavy catalog that has to feel instant.
- A write-heavy order path that has to be correct.
- An inventory count that absolutely cannot go negative.
Get those three straight and the rest is plumbing.
If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Split on the read:write seam. The catalog is a cache-and-CDN read machine (58K QPS); the order path is a small, strongly consistent write machine (175/sec). Spend your consistency budget only on the second one.
- One service owns oversell. Inventory is its own service with one invariant: available never goes negative. There is exactly one place the invariant can break, so exactly one place to get right.
- Conditional write, never read-then-write. Decrement stock with a single atomic statement guarded by
available >= qty. The row lock serializes the race for free. - Checkout is a saga. Reserve with a TTL, charge, commit, release or refund on failure, reconcile on crash. An idempotency key kills double submits.
- Price is frozen, not joined. Snapshot the price into the order line and re-read live price at checkout, so the browse cache can stay stale without ever deciding what you pay.
1Clarifying the requirements#
Before I draw a single box, I want to know what we are actually on the hook for. Skip this and I spend 40 minutes designing a search engine the interviewer never asked for. Here is roughly how I open with a candidate.
Candidate: Are we building the whole thing? Search, recommendations, payments, warehouse logistics, the lot?
Interviewer: No. Scope it to the shopping path: catalog browse and product pages, cart, inventory, checkout, and order creation. Treat payments and search as black boxes we call out to.
Candidate: One region or global? And do we care about the seller side?
Interviewer: Global, multi-region reads. Assume a single logical product catalog. Buyer side only. Sellers upload once, we ignore that flow.
Candidate: What is the one thing that must never break? If I had to pick a single invariant?
Interviewer: Never oversell. Charging a customer for a unit you cannot ship is the worst outcome. A product page being 30 seconds stale is fine. A double-sold PlayStation is not.
What those answers just told me:
- The interview is about the oversell invariant and the read:write asymmetry, not CRUD.
- "A product page 30 seconds stale is fine" is permission to cache the catalog hard.
- "Never oversell" is the interviewer handing me the thesis. I write it at the top of the whiteboard and keep pointing at it.
Functional requirements
- Browse a category and view a product detail page (price, images, stock status, reviews summary).
- Add, update, and remove items in a cart that survives across devices.
- Place an order: reserve stock, take payment, create an order record.
- View order history and order status.
- Reflect inventory changes (reserved, sold, restocked) accurately.
Non-functional requirements
- Catalog reads: very high volume, low latency (p99 under 200 ms), can tolerate slight staleness.
- Order writes: strongly consistent on inventory. No overselling, ever.
- High availability for browse and cart. Checkout can be slightly slower but must be correct.
- Durable orders and payments. We can replay and audit after the fact.
- Horizontally scalable. Survive a single region or AZ failure without losing orders.
This opening is scored before you draw a single box. I am listening for whether you name the oversell invariant and the read:write gap on your own. Start sketching a product CRUD or a login flow and I have quietly downgraded you. Name the hard part first, then go spend your time there.
2Back-of-the-envelope estimation#
Let me put numbers on it so the design has something concrete to push against. The point is to find the one number that dictates the architecture, not to show off arithmetic.
You are not here to compute storage to three decimal places. You are here to surface one load-bearing number: reads dwarf order writes by hundreds to one. That asymmetry is what justifies caching the read path hard and spending the entire consistency budget on the tiny write path. Find it fast, then move on.
Users and traffic.
- 300M monthly active users, 50M daily active.
- Shopping is bursty (morning scroll, lunch, late night), so I plan for a peak concentration of about 5x over the naive average.
Read QPS (catalog and product pages).
- Say each DAU does 20 product or catalog views a day: 50M x 20 = 1B reads/day.
- Average is 1e9 / 86400, about 11,600 QPS.
- Peak at 5x is roughly 58,000 read QPS. This is the firehose.
Cart writes.
- Maybe 40% of DAU touch a cart, at 5 cart ops each: 20M x 5 = 100M/day.
- About 1,160 average, so around 6,000 QPS peak.
Order writes.
- Say 3M orders/day (6% of DAU convert): 3e6 / 86400, about 35 orders/sec average.
- So around 175 orders/sec peak.
- Each order has a handful of line items, so call it about 500 inventory decrements/sec at peak.
The asymmetry (this is the one that matters).
- Reads outnumber order writes by roughly 300 to 1 (58,000 vs 175).
- That single ratio is the entire thesis: cache the read path aggressively, spend the whole consistency budget on the tiny, precious write path.
- Treat every read with the care an order needs and you buy 300x the strongly consistent machinery for no benefit.
Storage.
- Catalog: 500M distinct products at about 5 KB of structured data each = 2.5 TB. Images live in object storage and a CDN, never this budget.
- Orders: 3M/day x 365 x about 2 KB = roughly 2.2 TB/year, kept forever, so plan for tens of TB and growing.
- Cart data is small and ephemeral.
Bandwidth.
- 58K read QPS x 5 KB payload is about 290 MB/s out of the product service at peak, before the CDN soaks up most of it.
- The CDN should serve 90%+ of image bytes and a good chunk of cached product JSON, so the origin sees a fraction of that.
3Core APIs#
Keep these boring and REST-ish on purpose. The interesting design lives behind them, not in the URL shapes.
GET /v1/products/{productId}
GET /v1/categories/{categoryId}/products?cursor=&limit=40
POST /v1/cart/items { productId, qty }
PATCH /v1/cart/items/{id} { qty }
POST /v1/checkout { cartId, addressId, paymentMethodId, idempotencyKey }
GET /v1/orders/{orderId}
The one worth staring at is checkout. Note the idempotencyKey.
- A customer mashing the buy button on flaky hotel wifi must not create two orders and get charged twice.
- The client generates a UUID once per checkout attempt and reuses it across retries.
- The server dedupes on it via a unique index.
- This single field prevents the most common real-world failure in a payment flow.
{
"cartId": "c_8f21",
"addressId": "addr_1023",
"paymentMethodId": "pm_vault_55",
"idempotencyKey": "chk_2f9c1e7a-0b44-4e3a"
}
Category browse uses cursor pagination, not offset:
- Offset pagination on a large, shifting catalog gives duplicates or gaps when products are added or removed mid-scroll.
- The cursor is an opaque token that resumes a stable range scan, so the server never re-counts from zero.
4High-level design#
The box diagram is table stakes. What earns points is the single seam: the catalog is a cache-first read machine, the order path is a transactional write machine, and they meet at exactly one place, inventory. Draw the boxes fast, then defend that seam.
The split follows the read:write asymmetry I found in the estimates:
- The catalog path is a cache-first, eventually consistent read machine.
- The order path is a careful, transactional write machine.
- They meet at exactly one place: inventory.
A few deliberate choices in that picture:
- CDN and gateway up front. Product JSON and images are cacheable at the edge, keyed by product ID with a short TTL. Most reads never reach my own services, which is how 58K QPS stays affordable.
- Product Service reads from a cache, not the DB. The Catalog DB is the source of truth, but the hot path hits a read-through cache. On a miss it reads the DB and fills the cache for the next caller.
- Cart in Redis. Carts are small, high-churn, and do not need relational guarantees. Redis with a write-behind to a durable store so a node loss does not nuke carts. The key is the user or session ID.
- Inventory is its own service with its own database. This is the crown jewel. Nobody writes stock counts except the Inventory Service, and it alone guards "never oversell." One service means exactly one place the invariant can be violated, so exactly one place to get right.
- Order Service orchestrates checkout. It talks to Inventory (reserve), Payment (charge), and the Order DB (persist), and emits events on the bus.
- Event bus fans out side effects: invalidate the product cache on a price or stock change, feed the search index, trigger emails and warehouse pick-lists. These are async and allowed to lag by seconds.
Why split it this way.
- Reads and writes have opposite shapes: reads are enormous, latency-sensitive, and cacheable; order writes are tiny, precious, and need strict correctness.
- The split scales reads to 58K QPS on cheap cache and CDN while keeping the small order volume on strongly consistent, more expensive machinery.
- I am spending money where correctness lives and nowhere else.
Reveal the answer
5Data model#
Use a few stores, each picked for its access pattern. The catalog is read-heavy and tolerant, so I would genuinely be happy with a document store (each product as one blob keyed by ID). I will show it relationally since it reads more clearly and the relationships matter once we get to the order side.
products
| Field | Type | Notes |
|---|---|---|
| product_id | UUID (PK) | Stable, used as cache and CDN key |
| title | text | |
| description | text | |
| price_cents | bigint | Store money as integer cents, never float |
| currency | char(3) | |
| category_id | UUID | Indexed for category browse |
| version | bigint | Bumped on every edit; used for cache busting |
| updated_at | timestamptz |
- Keyed by product_id so it doubles as the cache and CDN key, which keeps invalidation simple.
inventory
| Field | Type | Notes |
|---|---|---|
| product_id | UUID (PK) | One row per SKU |
| available | int | Units free to sell |
| reserved | int | Units held for in-flight checkouts |
| sold | int | Lifetime sold counter |
| row_version | bigint | For optimistic concurrency |
- The invariant, enforced in both code and DB constraints:
available >= 0andreserved >= 0. availablecan only be decremented if the result stays non-negative. That non-negative check is the oversell guard, and I enforce it inside the write itself (section 6a), not in application logic that can race.
carts (Redis, shown as logical shape)
| Field | Type | Notes |
|---|---|---|
| cart_id | string (key) | Keyed by user_id or session_id |
| items | map productId to qty | Hash in Redis |
| updated_at | ts | TTL refreshed on every write |
orders
| Field | Type | Notes |
|---|---|---|
| order_id | UUID (PK) | |
| user_id | UUID | Indexed for order history |
| status | enum | PENDING, PAID, FAILED, CANCELLED, SHIPPED |
| total_cents | bigint | |
| idempotency_key | string | Unique index; dedupes double submits |
| created_at | timestamptz |
order_items
| Field | Type | Notes |
|---|---|---|
| order_id | UUID (FK) | |
| product_id | UUID | |
| qty | int | |
| unit_price_cents | bigint | Snapshot price at purchase time |
That unit_price_cents snapshot matters more than it looks.
- If the seller changes the price after you buy, your order must still reflect what you agreed to pay.
- I never join live to
products.price_centsfor a historical order. The price is frozen into the line item at purchase time.
6Deep dives#
6a. Never oversell: inventory reservation#
This is the heart of the problem, and it is where most candidates hand-wave.
- The naive version is "read stock, check it is positive, write stock minus one."
- That has a textbook race: two checkouts read
available = 1, both pass the check, both decrement, and now you have sold 2 of 1. - Under 175 orders/sec with a hot item during a launch, that race fires constantly, not occasionally.
My rule: decrement under a conditional write, never a read-then-write. In SQL that is a single atomic statement:
UPDATE inventory
SET available = available - :qty,
reserved = reserved + :qty
WHERE product_id = :pid AND available >= :qty;
- If zero rows are affected, there was not enough stock, and I fail the reservation.
- The database row lock serializes concurrent decrements on the same product, so the invariant holds without any application-level locking or distributed lock service.
- The
WHERE available >= :qtyclause is the entire oversell guard in one line.
But I do not take payment then decrement, nor decrement and hope payment works. I use a reservation with a TTL:
Step by step:
- Reserve moves units from
availabletoreservedwith a hold expiry. - Charge the card.
- On success, commit (move
reservedtosold). - On payment failure, or if the hold expires, a sweeper releases the reservation back to
available.
Two edge cases I call out before the interviewer does:
- The commit must be idempotent. If my commit call times out and I retry, I cannot double-move stock. I tag each reservation with a reservation ID and record its state (RESERVED, COMMITTED, RELEASED), so a repeat commit on an already-COMMITTED reservation is a no-op.
- Crash between charge and commit. I log the payment and reservation IDs to the Order DB before charging. A reconciliation job scans orders stuck in PENDING with a successful payment and finishes the commit. Money taken with stock not committed is a bug I can heal automatically; the reverse, stock gone with no money, is not. So I always order the steps to fail in the recoverable direction.
Reaching for a distributed lock or a Redis SETNX mutex to "protect" the stock count. That is slower, adds a dependency, and still races on the lock release. The row-level conditional write already serializes the decrement for free. Pull in a lock service for inventory and I read it as never having trusted the database to do its job.
6b. The hot product problem#
Big launch, one product, 20,000 people hammering the same product ID in the same second. Two things melt at once:
- The product cache key (read side).
- The inventory row (write side).
- Both the thing I cached hard and the thing I guarded carefully now become single points of contention.
On the read side, a single cache key for a viral product is a classic hot-key problem. One Redis shard takes all 58K QPS for that key and falls over. Fixes, cheapest first:
- Push that product's JSON to the CDN and to local in-process caches on each app node, so the Redis tier barely sees it.
- For the rare miss storm, use request coalescing (single-flight): only one request per node goes to the DB on a miss, and the rest wait for that fill.
- Replicate the hot key across multiple cache replicas and read from a random one.
On the write side, every order for the launch product contends on one inventory row. Row-lock serialization is correct but caps throughput at whatever one row can commit per second. If a single row becomes the bottleneck, I shard the inventory into N sub-buckets for that product:
How the bucketing behaves:
- Each bucket holds a slice of the stock, say 10,000 units split into 10 buckets of 1,000.
- A reservation hashes to a bucket and decrements there, spreading contention 10x.
- If a bucket is empty, I fall through to the next one so I do not reject while stock still exists elsewhere.
- Display stock is the sum across buckets, computed off the read replica.
- This trades a little complexity and a slightly fuzzy "units left" number for 10x reservation throughput. I would only reach for it on known-hot launches, not every product, because it is not free.
6c. Cache invalidation on price and stock changes#
A stale price is a real liability, not a cosmetic bug.
- If the cached page says 50 dollars and checkout charges 60, that is a trust problem and sometimes a legal one.
- So reads are allowed to be stale on description and images, but price and availability need a tight story.
The key move: the cache never decides what you pay.
- I version every product. The cache entry stores
version. - The authoritative order path re-reads live price from the Catalog DB at checkout rather than trusting the cached page.
- The browse page can lag, but the money is always computed from source of truth at the moment of purchase.
- That sidesteps the hardest invalidation case entirely: I do not need the browse cache to be correct about price, only roughly current.
For the browse page itself:
- When a price or stock state changes, the Catalog DB write emits an event on the bus.
- A consumer invalidates (or better, proactively refreshes) the CDN and cache entries for that product ID.
- TTLs stay short (say 30 to 60 seconds) as a backstop, so even a missed invalidation self-heals quickly.
- Availability shows as coarse states ("In stock", "Only a few left", "Out of stock") rather than exact counts, which makes small staleness invisible and lets me cache harder.
7Bottlenecks and trade-offs#
Inventory DB is the scaling ceiling on writes.
- Reservations are strongly consistent and contend on rows.
- Order volume is small (175/sec) relative to reads, and I shard inventory by product ID so unrelated products never contend with each other.
- Hot products get the bucket trick from 6b.
- To go further, I would move inventory to a horizontally sharded store with per-key transactions.
- I would not make inventory eventually consistent to chase throughput. Correctness is the product here, so that trades away the one thing I was told must never break.
Cache and source-of-truth skew.
- My whole read path leans on the catalog being cacheable and tolerant of staleness.
- The trade is that browse pages can show slightly old stock or price.
- I accept it because checkout re-validates against source of truth, so the user is never charged a stale price.
- The cost is a small rate of "added to cart, then it is gone at checkout" rejections, which I message clearly to the user.
Checkout is a distributed transaction across three systems (inventory, payment, orders), and there is no clean two-phase commit across a third-party payment gateway.
- I use the saga pattern: reserve, charge, commit, with compensating actions (release the hold, refund) on failure, plus a reconciliation sweeper for crashes mid-flow.
- The trade is eventual consistency on the order's final state for a few seconds, and the operational burden of running and monitoring the sweeper.
- The alternative, holding a DB transaction open across a network call to the payment gateway, is a non-starter: it pins a connection for seconds and falls apart the moment the gateway is slow.
Cart durability vs speed.
- Carts live in Redis for latency, which risks loss on node failure.
- I write-behind to a durable store and accept that a hard crash might lose the last few seconds of cart edits.
- Carts are low-stakes and users re-add. I would not pay the latency tax of a synchronous durable write on every cart tap.
Multi-region reads, single-region writes.
- I serve catalog and cart from the nearest region with read replicas.
- I pin inventory and order writes to a primary region (or shard-local primaries) to keep the oversell invariant simple.
- Cross-region strongly consistent inventory is possible but expensive in latency. I would only build it if a single region genuinely could not hold the write load, which at 175 orders/sec it comfortably can.
Observability and SLOs.
- SLOs as measured targets: product read p99 under 200 ms, checkout success rate, and reservation-to-commit latency.
- Watch: cache hit ratio per tier, hot-key count, inventory row contention, reservation sweeper backlog, payment gateway error rate.
- When a number moves, I already know which lever it maps to.
Spend your consistency budget only where the invariant lives, and cache everything else as hard as you dare.
8Summary#
The decisions that matter, tightest version:
- Split the system along read vs write. Catalog is cache-and-CDN-first, eventually consistent, tuned for 58K read QPS. The order path is strongly consistent and small, at 175 orders/sec. The 300-to-1 ratio drives the whole shape.
- Inventory is a dedicated service with one invariant: never oversell. Enforce it with conditional atomic decrements, not read-then-write, so one row lock serializes the race for free.
- Checkout is a saga: reserve with a TTL, charge, commit, release or refund on failure, reconcile on crash. Idempotency keys dedupe double submits, ordered to fail in the recoverable direction.
- Snapshot price into the order and re-read live price at checkout, so cached browse pages can stay stale safely and the cache never decides what you pay.
- Hot items get special handling: CDN plus single-flight on the read side, inventory bucketing on the write side, both reached for only on known-hot launches.
- Carts in Redis with write-behind durability, because they are low-stakes and latency-sensitive.
- Out of scope by choice: search, recommendations, payments internals, warehouse logistics, seller onboarding. Naming them keeps me honest about what 45 minutes covered.
"Two people check out the last unit at the same instant. Walk me through what happens."
available >= qty. The row lock serializes them: the first commits and drops available to 0, the second affects zero rows because the WHERE clause now fails, and I return it a clean out-of-stock. No application lock, no distributed mutex, no oversell. The database row lock is doing the entire serialization."The payment succeeds but the service crashes before it commits the reservation. Now what?"
"A product goes viral and 20,000 people load it per second. What melts, and how do you fix it?"
"The browse page showed 50 dollars but you just raised the price to 60. Which does the customer pay?"
"The customer double-taps buy on bad wifi. Two orders? Two charges?"
What you can now explain in the room
- Why a read-heavy storefront caches the catalog hard and spends its consistency budget only on inventory and orders.
- How to derive the load: 58K read QPS at peak, 175 orders/sec, and the 300-to-1 ratio that drives the design.
- Why a conditional atomic decrement beats any lock-based oversell guard, and how one row lock serializes the race.
- How the checkout saga (reserve, charge, commit, release) stays correct through payment failures and crashes.
- Why price is snapshotted into the order and re-read at checkout, so the browse cache can stay stale safely.
- How a hot product is defended differently on the read side (CDN, single-flight) and the write side (inventory buckets).
For related flows, see the walkthroughs on designing a payment system, a distributed cache, and a URL-to-content CDN.
Get a prep plan built around your Design Amazon (E-commerce) interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.