Scale Interview
System DesignHard

Design Amazon

E-Commerce Platform System Design

Overview

Open Amazon and three things happen in sequence: you search or browse a giant product catalog, you add items to a cart, and you place an order that charges your card. Each looks ordinary, but together they hide two opposite hard problems. The catalog side is overwhelmingly read-heavy — millions of shoppers browse and search far more than they buy, so it lives or dies on caching, read replicas, and a fast search index. The buying side is write-heavy and correctness-critical — limited stock must never be sold twice, and placing an order must charge exactly once even when the network retries.

It is labeled "Hard" because the naive answers collapse. Reading current stock and then writing a decrement in two steps lets two buyers both take the last unit — an oversell. A checkout that isn't idempotent turns one click plus a timeout-retry into two orders and two charges. And stuffing catalog, cart, orders, inventory, and payments into one giant database couples a read storm to a write-correctness path that have nothing in common.

To scope it tightly: we cover browse/search the catalog → add to cart → place an order, with inventory that never oversells. Out of scope (say so): fulfillment/logistics/shipping, reviews and ratings, recommendations, and the seller marketplace. These are brief extensions at the end; the correctness core is inventory and checkout.

In this walkthrough you'll scope it, size it, model the data, design the API, draw the read and write paths, then go deep on the catalog + search, inventory + cart without overselling, and the order-placement checkout pipeline.

Functional requirements

  • Browse and search the catalog. Find products by keyword, filter/facet by category, brand, price, and see product detail pages with price and availability.
  • Add to cart. Put one or more products (with quantity) into a cart that survives across sessions.
  • Place an order. Check out the cart — reserve stock, charge payment, and create a confirmed order — without ever overselling or double-charging.

Out of scope (state it): fulfillment/shipping/logistics, reviews/ratings, recommendations, and the third-party seller marketplace. We keep inventory correctness and checkout firmly in scope — they are the whole problem.

Non-functional requirements

  • Catalog is read-heavy and highly available. Browsing and search must stay fast and up even under load; they tolerate slightly stale data and are heavily cached and served from read replicas and a search index. For the catalog, availability wins over strong consistency (the AP choice in CAP).
  • Strong consistency on inventory. A unit is sold at most once; the system prefers rejecting an order over risking an oversell. For inventory, consistency wins over availability (the CP choice).
  • Order integrity and idempotency. Placing an order spans inventory, payment, and order services. It must be idempotent (a retry never creates a second order or charge) and it must not half-complete (no charge without an order, no order without stock).
  • Handle spikes. A flash sale or Prime Day concentrates 10–100× normal write load on a few hot items in minutes; the system must degrade gracefully, not oversell or collapse.
  • Money is exact. Prices and totals are stored as integer minor units (cents), never floats, so totals never drift by rounding.

Estimations

State assumptions; the aim is to justify the read-heavy-catalog vs write-heavy-order split.

Rounded assumptions

  • Catalog size: 500M products (across categories and variants).
  • Users: 300M registered; ~50M daily active.
  • Browse/search reads: ~500K reads/sec at peak — the dominant traffic.
  • Orders: ~5K orders/sec normal; a flash sale can push one hot item to thousands of attempts/sec where only a few hundred units exist.
  • Read:order ratio: roughly 100:1 — browsing dwarfs buying (and each order fans out to several writes — reserve, charge, confirm — so the read:write ratio is somewhat lower, but reads still dominate).

How the numbers affect the design

Signal from the numbers Design decision
~500K reads/sec, read:order ~100:1 Serve catalog from cache + read replicas + a search index, never the write primary.
500M products, keyword + faceted search A dedicated search index (inverted index / Elasticsearch), kept in sync via CDC.
~5K orders/sec, correctness-critical Inventory in a strongly consistent store; atomic conditional decrement.
Flash sale: attempts ≫ units on one item Hot-key contention — atomic decrement or a Redis counter, never read-then-write.
Retries are inevitable on checkout Idempotency key + a saga so pay + order can't half-complete.

Storage

  • Catalog: 500M products × ~2 KB of metadata ≈ ~1 TB — modest, but the read rate (not the size) is what forces caching and replicas.
  • Inventory: one small row per SKU (sku_id, available, reserved, version) ≈ tens of bytes × millions of SKUs — tiny, but the most contended data in the system.
  • Orders grow forever and are the financial record of truth — append-heavy, must be durable and consistent. At ~5K orders/sec × ~500 bytes/order ≈ ~2.5 MB/sec, a few hundred GB/day — large but well within a sharded relational store partitioned by order_id.

The takeaway from the numbers is the split itself: the ~500K reads/sec catalog and the ~5K orders/sec checkout have nothing in common — one wants cheap available reads, the other wants correct durable writes — so they get different stores, different consistency models, and scale independently.

Caution

Do not fixate on catalog storage size. The hard problem is not storing 500M products; it is the read-heavy catalog and the write-heavy, correctness-critical inventory and checkout living in the same system.

Core entities / data model

Entity Key fields
Product product_id (PK), title, description, brand, category, attributes, variants[]
Variant (SKU) sku_id (PK), product_id, options (size/color), price_cents
Inventory sku_id (PK), available, reserved, version — the contended row
Cart cart_id / user_id, items[] (sku_id, qty), updated_at
Reservation (hold) reservation_id, sku_id, order_id, qty, expires_at, status
Order order_id, user_id, items[], total_cents, status, idempotency_key
Payment payment_id, order_id, idempotency_key, amount_cents, status

A product is the catalog page a shopper sees; a variant / SKU (stock keeping unit) is the specific buyable unit that carries price and stock. Inventory is modeled per SKU with two numbers: available (free to sell) and reserved (held for in-flight checkouts). A reservation is a short-lived hold with an expires_at so an abandoned checkout returns stock automatically.

Money is an integer. Every price and total (price_cents, total_cents, amount_cents) is stored as an integer count of minor units (cents), never a floating-point dollar amount.

Bad — store prices and totals as floats

0.1 + 0.2 is not 0.3 in binary floating point — it's 0.30000000000000004. Sum a cart of float prices and totals drift by fractions of a cent; multiply by quantity and round, and the customer's charge can disagree with the displayed total. Across millions of orders these rounding errors become real money and reconciliation nightmares. Money is never a float.

Good — integer minor units (cents) with explicit rounding

Store 1299 for $12.99 and do all arithmetic in integer cents, formatting to dollars only for display. Addition and multiplication are exact; any rounding (tax, discounts) is applied once, deliberately, at a defined step. Totals always match to the penny, and the order record is an exact financial fact.

Where each lives. Catalog (Product, Variant) is read-heavy and can tolerate staleness, so it lives in a store optimized for scale and reads — a document/NoSQL store or a sharded relational DB with read replicas — denormalized into a cache and a search index. Inventory, Order, and Payment are correctness-critical and live in a strongly consistent relational store (Postgres/MySQL), partitioned by sku_id / order_id. The version column on inventory enables optimistic locking. Carts are semi-durable (Redis backed by a DB) — losing a cart is annoying, not a correctness failure.

API design

A RESTful API covers the three steps that map onto the two halves of the design — read the catalog, mutate the cart, then place the order. Add-to-cart and place-order are deliberately separate so stock isn't held during slow human browsing.

Search / browse the catalog

GET /api/products?q=headphones&category=audio&brand=sony&sort=price
200 OK
Returns: { "products": [ { "product_id": "...", "title": "...", "price_cents": 12999, "in_stock": true }, ... ] }   (cached, from search index)

Keyword + faceted search served from the search index and cache, never the write primary. in_stock here is a hint, not a guarantee — the authoritative check happens at checkout.

Add to cart

POST /api/cart/items
Body: { "sku_id": "sku_123", "qty": 2 }
200 OK
Returns: { "cart_id": "...", "items": [ ... ] }

Adding to the cart does not reserve stock (see the reserve-at-cart vs reserve-at-checkout tradeoff). It just records intent. The cart survives across sessions and devices, so a shopper can add on a phone and check out on a laptop.

Place an order (checkout)

POST /api/orders
Header: Idempotency-Key: <client-generated-uuid>
Body: { "cart_id": "...", "payment_token": "..." }
201 Created
Returns: { "order_id": "...", "status": "CONFIRMED" }   (or 409 if an item is out of stock)

The Idempotency-Key header is mandatory: a network retry of the same checkout must not create a second order or a second charge — the server returns the original result for a repeated key. The key is persisted server-side under a UNIQUE constraint (claimed before any work; see deep dive 3), which is what makes it safe against both sequential retries and concurrent double-clicks. This is the step that reserves stock, charges payment, and confirms the order via the saga in deep dive 3. Returns 409 Conflict if any item can't be reserved.

Check order status

GET /api/orders/{order_id}
200 OK
Returns: { "order_id": "...", "status": "CONFIRMED", "items": [ ... ], "total_cents": 25998 }

Lets the client confirm the outcome after a checkout whose response was lost — the same order id returned by the idempotent POST. Statuses walk PENDING → CONFIRMED (or FAILED when a step's compensation ran).

High-level architecture

Two paths matter and pull in opposite directions: a read path (browse/search the catalog) that is cached and eventually consistent, and a write path (cart → reserve → pay → confirm) that is strongly consistent. We'll walk each, then combine.

Reading the catalog is the dominant traffic and tolerates slight staleness, so it never touches the write primary.

flowchart LR
    Client[Client] -->|"browse product"| Cache[(Catalog Cache)]
    Cache -->|"hit"| Client
    Cache -.->|"miss"| Replica[(Catalog Read Replica)]
    Client -->|"keyword or facet search"| Search[Search Index]
    Search -->|"results"| Client
  1. Product-detail reads hit the cache first; a miss falls back to a read replica, never the primary.
  2. Keyword and faceted queries go to the search index, not the database.

2. Write path — cart to confirmed order

Checkout is the strongly consistent path: reserve stock, charge, confirm — coordinated so it can't half-complete.

flowchart LR
    Client[Client] -->|"add to cart"| Cart[Cart Service]
    Client -->|"place order"| Order[Order Service]
    Order -->|"reserve stock atomically"| Inv[(Inventory DB)]
    Order -->|"charge idempotently"| Pay[Payment Service]
    Pay --> PSP[(Payment Provider)]
    Order -->|"on success confirm order"| OrderDB[(Order DB)]
  1. Add-to-cart records intent in the Cart Service without touching inventory.
  2. On checkout the Order Service atomically reserves stock in the Inventory DB (deep dive 2).
  3. It charges payment idempotently (deep dive 3).
  4. On success it writes a CONFIRMED order and converts the reservation into a committed decrement.

The Order Service is the coordinator of this saga: it owns the idempotency key, drives the three steps in order, and triggers the compensating actions if any step fails. Inventory, payment, and order storage stay independent — each a local transaction the coordinator sequences.

3. Combined architecture

Putting it together, the two paths share only the API gateway and remain otherwise independent — the read path (cache, replicas, search index) and the write path (cart, order, inventory, payment) scale and fail separately. Note the background worker that releases expired reservations and the CDC stream that keeps the search index in sync:

flowchart LR
    Client[Client] --> GW[API Gateway]
    GW -->|"browse"| Cache[(Catalog Cache)]
    Cache -.->|"miss"| Replica[(Catalog Read Replica)]
    GW -->|"search"| Search[Search Index]
    GW -->|"cart"| Cart[Cart Service]
    GW -->|"checkout"| Order[Order Service]
    Cart -.->|"read cart at checkout"| Order
    Order -->|"reserve"| Inv[(Inventory DB)]
    Order -->|"charge"| Pay[Payment Service]
    Pay --> PSP[(Payment Provider)]
    Order --> OrderDB[(Order DB)]
    Catalog[(Catalog Primary)] -.->|"CDC"| Search
    Catalog -.-> Replica
    Expiry[Reservation-Expiry Worker] -->|"release expired holds"| Inv

The dotted Catalog Primary → Search edge is change data capture (CDC): every catalog write is streamed to the search index and replicas so reads reflect the truth within seconds. The reservation-expiry worker returns abandoned holds to available for a tidy stock count — though, as deep dive 2 shows, correctness never depends on it running on time.

Tip

Separate the catalog from checkout. The catalog can be cached, replicated, and eventually consistent; inventory and orders must use the strongly consistent write path.

Deep dives

1. Product catalog + search and browse

The catalog is the front door and the highest-volume surface, so it is engineered entirely for cheap, fast, available reads — the opposite of the checkout path. Three ideas carry it: a dedicated catalog service, aggressive caching + read replicas, and a search index kept in sync with the catalog.

Catalog service and data model. A product is a denormalized document: title, description, brand, category, attributes, and a list of variants (SKUs) each with its own price. Storing it as one document means a product page is a single key lookup with no joins — exactly what a read-heavy surface wants. The catalog is the source of truth for product data, but not for stock: available lives on the inventory row (deep dive 2), and the catalog only carries a cached in_stock hint. That hint stays roughly current via a lightweight event / CDC stream: after an inventory commit flips a SKU's availability, the change is streamed to the catalog document. It's eventually consistent and correctness never depends on it — the reservation step (deep dive 2) is the authoritative check, so a stale hint only ever costs a shopper a late "out of stock," never an oversell.

Caching + read replicas. At ~500K reads/sec you cannot serve product pages from the write primary. Product detail is cached (long TTL — product data is near-static), and cache misses fall to read replicas, never the primary. The primary handles only writes (a seller or admin editing a product), which are rare relative to reads. The layering matters: the cache absorbs the bulk of the read storm, replicas absorb the misses, and the primary is reserved for the trickle of writes — so a viral product page cannot overload the store that checkout-adjacent catalog writes depend on. Popular products can also be pushed to a CDN edge for static fields (title, images, description) with volatile fields (price, in_stock hint) fetched separately on a shorter TTL.

Search index. Keyword and faceted search ("headphones", filter by brand, sort by price) can't be a LIKE '%...%' scan over 500M rows. Index products in a search engine built on an inverted index (Elasticsearch/OpenSearch) for full-text, fuzzy matching, and facets. The index is a derived store, kept in sync from the catalog via CDC — a catalog write streams to the index within seconds.

The full read path — a write to the catalog primary fans out to the replicas, the cache, and the search index, and every shopper read is served by one of those derived stores, never the primary:

flowchart LR
    Admin[Seller or admin write] --> Primary[(Catalog Primary)]
    Primary -.->|"replicate"| Replica[(Read Replicas)]
    Primary -.->|"CDC"| Index[Search Index]
    Primary -.->|"invalidate or write-through"| Cache[(Product Cache)]
    Shopper[Shopper] -->|"product page"| Cache
    Cache -.->|"miss"| Replica
    Shopper -->|"keyword or facet query"| Index

Because all three derived stores lag the primary by seconds, the catalog is explicitly eventually consistent — acceptable, because product data is near-static and stock is not read from here.

Bad — search straight off the catalog database with LIKE

Serving search with SELECT * FROM products WHERE title LIKE '%headphones%' over the catalog database seems to save a moving part. But LIKE '%term%' can't use a normal index, so every query is a full table scan over 500M rows; there is no relevance ranking, typo tolerance, or efficient faceting; and the read storm hammers the same database checkout-adjacent writes depend on. It falls over at the first traffic spike.

Good — a dedicated search index kept in sync via CDC

Index products in a search engine (inverted index) built for full-text, fuzzy matching, ranking, and facets. Keep it in sync from the catalog with change data capture so a product edit propagates in seconds. Search traffic hits the index; the catalog primary is untouched by reads. The index is derived and rebuildable — if it's lost, replay from the catalog. This is the standard split: strongly consistent source of truth for writes, denormalized derived index for reads.

Note

Catalog in_stock is a hint, not a promise. Even with perfectly fresh data, stock can sell out between the search result rendering and the buyer's click, so freshness can never close that race. The unit becomes truly the buyer's only at the reservation step (deep dive 2), which runs the authoritative strongly consistent check. A stale "in stock" simply becomes "out of stock, sorry" there — never an oversell.

2. Inventory + cart without overselling

This is the correctness core: many buyers, limited stock, and the invariant that a unit is sold at most once. The failure to avoid is the read-then-write race, where two buyers both read "1 left" and both write a decrement.

First, a word on the cart, because where you reserve stock is a design decision. The cart itself holds no stock — it is a list of (sku_id, qty) intents, stored in Redis (fast, session-friendly) and backed by a DB so it survives across devices and sessions. Losing a cart is an annoyance, not a correctness failure, which is why it lives on the availability side of the split. Adding to the cart deliberately does not reserve inventory — otherwise every idle cart would lock stock and starve real buyers (see the reserve-at-cart vs reserve-at-checkout tradeoff). Stock is only touched at checkout.

Bad — read stock, check in application code, then write the decrement
Two buyers race for the last unit of sku_123 (available = 1):
  T1: SELECT available FROM inventory WHERE sku_id='sku_123'  -> 1
  T2: SELECT available FROM inventory WHERE sku_id='sku_123'  -> 1   (T1 hasn't written yet)
  T1: if 1 >= 1 -> UPDATE ... SET available = 0                -> OK
  T2: if 1 >= 1 -> UPDATE ... SET available = 0                -> OK
  -> BOTH orders confirmed; available is now 0 but TWO units were sold -> OVERSELL

The check and the write are two separate steps with a gap in between; two transactions interleave inside that gap and both pass the check. Reading a value and deciding in application code is never safe under concurrency.

Good — a single atomic conditional decrement (guarded UPDATE)

Make the check and the decrement one atomic statement guarded by a WHERE predicate, so the database — not your code — enforces the invariant:

Two buyers race for the last unit of sku_123 (available = 1):
  T1: UPDATE inventory SET available = available - 1
      WHERE sku_id='sku_123' AND available >= 1              -> 1 row
  T2: UPDATE inventory SET available = available - 1
      WHERE sku_id='sku_123' AND available >= 1              -> 0 rows -> out of stock
  -> exactly one UPDATE matches; the other affects 0 rows and is rejected. No oversell.

The available >= 1 predicate makes the read-and-write a single serialized operation. Exactly one buyer's UPDATE matches; the loser affects 0 rows and gets a clean "out of stock." No locks are held across the user, and no oversell is possible.

The atomic decrement is the mechanism, but a checkout takes seconds (payment), so you don't want to hard-decrement and then refund on failure. Instead reserve: move stock from available to reserved with a TTL, pay, then commit the reservation into a permanent decrement (or release it on failure/expiry). A single unit's stock moves through three transitions:

flowchart LR
    Avail[available] -->|"reserve at checkout"| Reserved[reserved]
    Reserved -->|"payment ok, commit"| Sold[sold and gone]
    Reserved -->|"failure or TTL expiry, release"| Avail
  • Reserve. Atomically available = available - qty, reserved = reserved + qty WHERE available >= qty. This is the same guarded-UPDATE pattern — one buyer wins the last unit, the rest get "out of stock." Note the available decrement happens here, at reserve time — not at commit.
  • Commit. On successful payment, reserved = reserved - qty only (the unit is now sold and gone). Commit does not touch available — that decrement already happened at reserve. The commit is itself a conditional UPDATE ... WHERE status='HELD' AND reservation_id=?; if it matches 0 rows the hold was already released (expired and swept), so the commit must fail rather than blindly decrement.
  • Release. On failure or expiry, available = available + qty, reserved = reserved - qty — the unit returns to the pool.

The version column supports the optimistic-locking (CAS) variant when you must recompute in app code, but for a simple decrement the guarded WHERE available >= qty is enough and needs no version at all.

There's a third option worth naming for contrast — pessimistic locking — which sits between the read-then-write race (bad) and the guarded atomic UPDATE (good):

Warn — pessimistic lock (`SELECT ... FOR UPDATE`): correct, but it holds a lock

Lock the inventory row as you read it, so the read and the decrement are one serialized unit inside a transaction. It's correct by construction and simple to reason about — but it holds a lock for the duration of the transaction, so under a flash-sale herd contenders pile up waiting on the same hot row and latency spikes. Crucially, never hold that lock across the user's payment — that would block every other buyer for the seconds a card charge takes. The guarded atomic UPDATE holds no lock at all, so it's the better default; pessimistic locking is a fallback for the very hottest rows where you'd rather serialize than thrash on retries.

Approach How it prevents oversell Best for
Atomic conditional decrement (default) UPDATE ... WHERE available >= qty — the DB serializes the check + write; loser matches 0 rows The common case; no locks held
Optimistic CAS Guard the update with a version check; retry on 0 rows When you must compute in app code between read and write
Pessimistic lock SELECT ... FOR UPDATE locks the row until commit The very hottest rows, where serializing beats retry thrash

Releasing expired reservations. Make expiry lazy: an expired-but-uncommitted reservation does tie up stock — available was decremented at reserve time — so that stock stays unavailable until it's released, either by the sweeper or by the commit-time re-check that finds the hold gone. A background worker sweeps Reservation rows where expires_at < now() and status = 'HELD', moving stock back to available. Crucially, correctness is never violated while it waits: the stock is unavailable-but-unsold, so the worker's timeliness affects sell-through (how fast abandoned stock returns to the pool), not correctness (it can never cause an oversell).

Flash-sale hot key. When thousands of buyers hit one SKU — say ~5K attempts/sec against ~200 units — every attempt contends on one row. The guarded UPDATE is still correct (only the winners' rows match), but the row becomes a hotspot — every transaction serializes on it and latency climbs. Once the guarded decrement has drained the 200 units, ~96% of attempts return 0 rows and fail immediately — correct, but the whole herd still pounded one row to learn it. Options:

  • Redis counter as an admission gate. Seed a Redis counter from the DB stock and gate the herd with an atomic DECR: a losing DECR (counter already ≤ 0) is rejected in memory, so most of the herd never reaches the DB. But Redis is not the authoritative decrement — it isn't durable, and a failover could lose recent DECRs. So a winning DECR is only an admission ticket: the order is CONFIRMED only after the durable guarded DB UPDATE ... WHERE available >= qty also succeeds. This makes the failure mode safe — a Redis failover can cause false rejections (under-sell), never an oversell, because the DB decrement remains the single source of truth.
  • Sharded counter. Split the SKU's stock into N sub-counters (available_0 … available_{N-1}), route each buyer to one at random, and sum them for display. Each sub-counter uses the same guarded decrement, so per-shard no-oversell holds and the only failure mode is a premature "sold out" — a near-empty item can look sold out on one shard while stock sits on another (safe under-sell). Rebalancing must move stock between shards atomically (or route the move through the DB) so the shard sum never exceeds real stock; done sloppily, rebalancing is where an oversell would sneak in. Contention drops by roughly N×.

Either way, the atomic-decrement invariant against the durable store is preserved — the point is only to spread or gate the contention so one hot row doesn't become the bottleneck for the whole sale.

Warning

Do not read stock, decide in application code, then write. That read-then-write gap is exactly where two buyers both take the last unit. The check and the decrement must be one atomic conditional statement.

3. Order placement — the checkout pipeline

Placing an order spans three services that don't share a database — inventory, payment, and order. Two failure modes to defend: double orders on retry, and half-complete checkouts (charged but no order, or order but no stock).

Idempotency. The client generates one Idempotency-Key per checkout attempt. The checkout's first step, before any reserve or charge, is to insert that key into an idempotency table under a UNIQUE constraint in a PENDING state. This closes both races at once: a sequential retry finds a completed key and returns the original order; a concurrent duplicate (a double-click firing two requests at once) collides on the UNIQUE constraint and either blocks on the in-flight row or returns its result — it can never proceed past the insert to reserve and charge a second time. Only by claiming the key up front is checkout safe under true concurrency, not just sequential retries. Network timeouts make clients retry constantly — without this, one click becomes two orders and two charges. The payment provider's own idempotency key adds a second layer at the charge step.

Bad — no idempotency key, so a retry re-runs the whole checkout

The client posts the order, the server reserves stock and charges the card, then the response times out on the way back. The client, seeing no response, retries. The server treats it as a brand-new checkout: it reserves again, charges again, and writes a second order. The customer is charged twice and receives two shipments for one intent. This is the single most common e-commerce correctness bug.

Good — idempotency key makes the retry a no-op that returns the original order

The client sends the same Idempotency-Key on the retry. Because the first attempt claimed the key under a UNIQUE constraint before doing any work, the Order Service looks it up, finds the completed first attempt, and returns that original order_id and CONFIRMED status without touching inventory or payment — and a concurrent duplicate collides on that same constraint rather than racing ahead. One key, one order, one charge — no matter how many times or how simultaneously the client retries. Persist the key with the result for long enough to outlast any client retry window (hours), then expire it.

Consistency via a saga. You can't wrap inventory, an external card charge, and the order write in one distributed transaction. Model checkout as a saga — a sequence of local steps, each with a compensating action if a later step fails:

sequenceDiagram
    participant C as Client
    participant O as Order Service
    participant I as Inventory
    participant P as Payment
    C->>O: place order with Idempotency-Key
    O->>I: reserve stock
    I-->>O: reserved OK
    O->>P: charge payment
    P-->>O: charged OK
    O->>I: commit reservation
    O-->>C: order CONFIRMED

The order pipeline runs reserve → charge → confirm. Walk the failure modes:

  • Reserve fails (item out of stock): return 409 immediately; nothing was charged, nothing to undo.
  • Charge fails after a successful reserve: compensate by releasing the reservation (stock returns to available) and marking the order FAILED. No harm done.
  • Confirm write fails after a successful charge: the dangerous case. A reconciliation job repairs it by matching charges against orders on the payment provider's payment id, then taking one of two actions: if the charge succeeded and the order row exists, it finishes the CONFIRMED write; if the charge exists but the order was never created, it refunds the orphaned charge. The reservation's TTL is the backstop that returns stock if the crash left it held.

This is effectively-once, not exactly-once (true exactly-once delivery is impossible) — the idempotency key plus reconciliation make retries and crashes safe. Each step is a local transaction in one service; the saga sequences them and defines the compensation for each, which is what lets you stay consistent across three separate databases without a distributed transaction.

Note

The order of the steps matters: reserve stock before charging, so a stock failure costs nothing and only a paying customer's card is touched. Charge before the final confirm write, so the confirm is a cheap local write that rarely fails — and when it does, reconciliation (not the customer) fixes it. Reversing the order (charge first, then discover no stock) forces a refund on every out-of-stock buyer.

Reservation-expiry vs payment race. If a reservation's TTL expires while payment is in flight, you risk charging for stock that was already released to someone else. Guard the commit step by re-checking, inside the same transaction that commits the decrement, that this order's reservation is still valid and owned by this order; if it expired and the stock is gone, fail and refund. Be precise about what this guard does: it detects-and-refunds, it does not prevent the external charge — the charge happened before the commit transaction and can't join it, so if the stock was reclaimed after the charge the guard catches it and refunds rather than stopping it. That gap is exactly why reconciliation and refunds exist. In practice, size the TTL comfortably longer than the payment step so this race is rare, and let reconciliation mop up the tail.

Caution

Do not make checkout non-idempotent. Network timeouts cause retries, and a checkout without an idempotency key turns one retry into a second order and a second charge on the customer's card.

Tradeoffs & bottlenecks

  • Reserve at cart vs reserve at checkout. Reserving at add-to-cart guarantees the buyer gets the item but locks stock for every idle cart, starving real buyers and making popular items look sold out — a cart abandoned for hours would sit on stock nobody can buy. Reserving at checkout (the default) maximizes sell-through but risks a late "out of stock" when the buyer finally checks out. Amazon-style systems reserve at checkout and treat the cart as intent only; a middle ground reserves at checkout start with a short TTL, giving the buyer a few minutes of exclusivity to pay without holding stock for idle carts.
  • Strong vs eventual consistency for inventory. Inventory is strongly consistent (CP) — better to reject an order than oversell. The catalog is eventually consistent (AP) — a slightly stale in_stock hint is fine. Naming this split is the central tradeoff.
  • Monolith vs microservices. Splitting catalog / cart / order / inventory / payment lets the read-heavy catalog scale independently from the write-heavy order path and isolates failure domains, at the cost of a distributed transaction problem (solved by the saga). One giant DB couples a read storm to a correctness path and can't scale either well.
  • SQL order integrity vs NoSQL catalog scale. Orders, payments, and inventory want relational ACID guarantees; the catalog wants document-store scale and read throughput. Use both — a strongly consistent relational store for money and stock, a denormalized document store + search index for the catalog.
  • Hot-item hotspot. A flash sale concentrates all contention on one inventory row. Atomic decrement stays correct, but the row is a bottleneck; a Redis counter or sharded counters move the contention off the DB.
  • Cart durability vs cost. A cart in Redis alone is fast but volatile; backing it with a DB adds durability across devices at the cost of extra writes. Since a lost cart is an annoyance, not a correctness failure, this can lean toward cheaper/looser storage — unlike inventory and orders, which cannot.
  • Reservation TTL length. Too short and buyers lose stock mid-checkout; too long and abandoned checkouts hold inventory and depress sell-through. Tune to real checkout times (a few minutes), and rely on the lazy release so correctness never hinges on the TTL firing exactly on time.

Extensions if asked

Add only the extension that changes the design discussion:

  • Recommendations. "Customers also bought" — a separate offline/near-line pipeline over order history; doesn't touch the correctness core.
  • Reviews and ratings. Write-moderate, read-heavy, aggregated into a rating on the product document; another derived, eventually consistent surface.
  • Fulfillment and shipping. After an order is confirmed, a separate logistics subsystem picks, packs, and ships — an event-driven pipeline triggered by the CONFIRMED order.
  • Seller marketplace. Multiple sellers per product, each with their own inventory and pricing — turns one SKU into many offers and complicates the buy-box selection.
  • Flash-sale hot-item handling. Redis-backed counters, admission control / a queue in front of the hottest SKUs, and per-user purchase limits to blunt the herd.

What interviewers look for & common mistakes

The strongest signal in this question is recognizing that Amazon is really two systems glued together — a read-heavy catalog and a write-heavy, correctness-critical order path — and designing each on its own terms instead of forcing one architecture on both. Everything below flows from that split.

What interviewers usually reward:

  • Splitting read-heavy catalog from write-heavy orders — cache + replicas + search index for browsing; a strongly consistent store for inventory and orders.
  • A correct anti-oversell mechanism — a single atomic conditional decrement (WHERE available >= qty) or optimistic CAS, never read-then-write.
  • Reservations with a TTL that decouple slow human payment from stock, released lazily on expiry.
  • Idempotent checkout via an idempotency key, and a saga with compensations plus reconciliation for pay + order confirmation.
  • Explicitly choosing consistency over availability for inventory while keeping the catalog cached and available.

The mistakes that sink this question are almost always on the write side: an oversell from a read-then-write race, a double order from a non-idempotent checkout, or money stored as floats. Get the read/write split, the atomic decrement, and idempotent-saga checkout right and the rest is detail.

Before you finish, do a quick mistake check:

  • Did you prevent overselling with an atomic conditional decrement, not a read-then-write check?
  • Did you make checkout idempotent so a retry can't create a second order or charge?
  • Did you split the catalog and order stores instead of one giant DB?
  • Did you separate the read-heavy catalog from the write-heavy order path?
  • Did you store money as integer cents, never floats?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →