Engineering Atlas › System Design Interviews

Design an e-commerce marketplace

22 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: seller onboarding, warehouse robotics and pick-pack, the recommendation models themselves, ads auctions, returns. Payments are covered in depth in Design Payment System and single-SKU stampedes in Design Flash Sale.

Back-of-envelope

Round assumed numbers, not Amazon's published figures.

API

Public endpoints are REST behind an API gateway. Internal calls are RPCs with deadlines.

GET  /v1/products/{asin}?zip=98109
  -> { asin, title, images[], buy_box: { offer_id, seller, price_cents },
       availability: "in_stock" | "low" | "out", promise: "2026-10-22" }

GET  /v1/search?q=usb+c+cable&filters=brand:anker&cursor=...
  -> { results: [ { asin, title, price_cents, rating } ], facets: {...}, next_cursor }

PUT  /v1/cart/items          { asin, offer_id, qty }        -> { cart_version, items[] }
POST /v1/cart/merge          { guest_cart_id }               -> { cart_version, items[] }

POST /v1/checkout/preview    { address_id, payment_method_id }
  -> { checkout_id, shipments: [ { fc, items[], promise } ], total_cents, expires_at }

POST /v1/orders              { checkout_id, idempotency_key }
  -> 201 { order_id, status: "placed", shipments[] }
  -> 409 { reason: "item_unavailable", asin }  (preview again)

POST /v1/orders/{order_id}/cancel  -> { status: "cancelled" | "too_late" }

checkout/preview computes a plan and a total without reserving anything. POST /orders re-validates the plan, reserves, and is idempotent on idempotency_key, so a double-click or a retried mobile request returns the same order_id.

Data model

catalog_items is a document store keyed by asin. Attributes vary wildly by category (a TV has a panel type, a shirt has sizes), so a schemaless document fits. Partition by asin: every read is a point lookup.

offers is keyed by (asin, offer_id) and partitioned by asin, so the buy box can read every offer for a product from one partition. Price changes often, so it lives apart from the slow-changing catalogue item.

inventory is keyed by (sku, fc_id) with on_hand and reserved integers, partitioned by sku. Allocation needs all fulfilment centres for a SKU at once, so one partition holds them. A hot SKU gets its stock split into several buckets per centre (see Failure modes).

carts is a key-value item keyed by customer_id (or guest_cart_id) holding the item list and a version vector. Partitioned by owner; one read, one write.

orders is partitioned by customer_id with sort key created_at, because the dominant read is "my orders, newest first". The order_id embeds the customer's shard bits so a lookup by order ID routes directly. Rows: order header, lines, shipments and an append-only status history.

idempotency_keys maps (customer_id, key) to order_id with a TTL of a day, written in the same transaction as the order header.

outbox rows are written in the order transaction and relayed to the event bus (see Outbox Pattern), so "order placed" is published if and only if the order exists.

High-level design

flowchart LR
  U["Browser / app"] --> CDN["CDN (images, static)"]
  U --> GW["API gateway"]
  GW --> PG["Page aggregator"]
  PG --> CAT[("Catalogue store")]
  PG --> OFF["Offers + pricing"]
  PG --> AV["Availability cache"]
  PG --> PR["Delivery promise"]
  GW --> S["Search service"] --> IDX[("Search index")]
  GW --> C["Cart service"] --> CS[("Cart KV store")]
  GW --> CO["Checkout / order service"]
  CO --> INV[("Inventory by SKU")]
  CO --> PAY["Payment service"]
  CO --> ORD[("Orders + outbox")]
  ORD --> BUS["Event bus"]
  BUS --> FUL["Fulfilment"]
  BUS --> AV
  BUS --> S
  BUS --> NOT["Email / push"]

Read path (product page).

  1. The CDN serves images and static assets. The request for the page reaches the page aggregator.
  2. The aggregator issues parallel calls with a shared deadline of about 150 ms: catalogue item, offers and price, availability for the nearest centres, delivery promise for the postcode, review summary, recommendations.
  3. Required parts (title, price, buy button) block the render. Optional parts (recommendations, reviews) render as placeholders filled by the client if they miss the deadline.
  4. Availability comes from a cache fed by inventory change events, a few seconds stale, which is fine for display.

Write path (place order).

  1. The order service checks idempotency_keys; a known key returns the stored order.
  2. It reloads the cart and recomputes prices; a changed price sends the customer back to preview.
  3. It runs the allocator to choose fulfilment centres, then issues conditional reservations: reserved += q only if on_hand - reserved >= q. Any failure releases the reservations already taken.
  4. It asks the payment service to authorise (not capture) the total.
  5. In one local transaction it writes the order header, lines, shipments, the idempotency key and an outbox row. The customer sees "Order placed".
  6. The relay publishes OrderPlaced. Fulfilment, availability, email and search ranking consume it. Payment is captured when each shipment leaves the building.

Deep dives

1. The product page under fan-out

Options: wait for every dependency (the page is as slow as the slowest), precompute whole pages (impossible, since price and promise depend on the customer and the moment), or aggregate with deadlines and degrade. I choose the last, with a precomputed layer underneath: the catalogue item and its images are rendered into a cacheable fragment, while price, availability and promise are fetched live.

The arithmetic decides it. If each of 40 dependencies independently exceeds its own p99 1% of the time, the chance that at least one is slow on a given page is 1 - 0.99^40 ≈ 33%. So a page that waits for everything has a p67 equal to its dependencies' p99. This is not hypothetical: the Dynamo paper notes that one Amazon page typically called over 150 services, which is why Amazon wrote its SLAs at the 99.9th percentile rather than the mean (Dynamo, SOSP 2007). The fixes are a shared deadline, hedged requests for the few required calls (send a second copy after the p95 latency, take whichever returns first), and a strict list of what the page may render without. Hedging is cheap: in a Google benchmark, sending the backup request after a 10 ms delay cut the 99.9th-percentile latency of a 1,000-key read from 1,800 ms to 74 ms for only 2% more requests (Dean and Barroso, "The Tail at Scale"). Hedge only idempotent reads; never hedge a reservation or a payment call.

2. A cart that never says no

Options: a strongly consistent cart in a relational database (simple reads, but writes fail during a primary failover or partition), or an always-writable replicated cart that may diverge and is merged on read. The Dynamo paper chose the second for the shopping cart: writes go to any healthy replicas (sloppy quorum, hinted handoff), versions are tracked with vector clocks, and when a read finds siblings the application merges them by taking the union of items. The documented cost is that a deleted item can reappear, which customers tolerate far better than a failed "Add to cart". Divergence was rare in practice: over 24 hours, 99.94% of reads by the cart service saw exactly one version (Dynamo, SOSP 2007).

I would keep the semantics but implement them on a managed key-value store with conditional writes in a single region, and use version-vector merging only across regions. Don't assume the managed store gives you Dynamo's merge for free. DynamoDB, despite the name, replicates each partition through a Multi-Paxos group with a single leader rather than sloppy quorums (DynamoDB, USENIX ATC 2022), and its default multi-Region global tables resolve concurrent writes to one item by last writer wins (DynamoDB docs). Two Regions writing the same cart item would silently drop one add. So either store each cart line as its own item, so concurrent adds touch different keys, or keep a version vector in the item and merge in the application. Guest-to-signed-in merge is the same operation: union of lines, quantity = max of the two, then cap at the per-item purchase limit. The cart never reserves stock; holding inventory for 150M idle carts would starve real buyers.

3. Inventory, allocation and the order saga

Three questions get tangled here. What do we show? A cached, approximate count. What do we promise? A delivery date computed by an allocator that knows which centres hold the items, their distance and their carrier cut-off times. What do we commit? A conditional reservation per (sku, fc) row.

The allocator prefers one shipment from the nearest centre that can fill the whole order, because every extra box costs shipping and packaging. If no single centre can, it greedily picks the centre that covers the most remaining units, and repeats.

The order itself is a saga (Saga Pattern) rather than a distributed transaction: reserve, then authorise, then persist. If authorisation fails, release the reservations. If the order write fails after authorisation, void the authorisation. Reservations carry the order ID, so a sweeper can find and release any reservation older than ten minutes that has no order, which covers a crash between steps.

Variant: a multi-tenant store builder

Now suppose we are not Amazon, the one retailer, but the platform that lets millions of independent merchants run their own shops. The requirements shift from one catalogue and one inventory to millions of small ones, with the hard problem being isolation: one merchant's flash sale must not slow the other shops sharing the hardware.

The data model gains a tenant key. Every row carries a shop_id, and every request belongs to exactly one shop, resolved from the domain at the edge. A single shared database is one failure domain, and a database per shop is impossible to migrate, back up and connect to at millions of shops. The middle path is sharding by shop_id and grouping shards into pods, where a pod is a shard plus its own cache and job queues, and any unit of work touches exactly one pod. This is the cell pattern applied to tenants: a pod failure affects only its shops, and the rule "no cross-pod work" is what keeps it true. Shopify describes this design and its pod failover (Shopify Engineering).

Tenants grow unevenly, so pods drift out of balance, and Shopify reported its busiest and quietest shards differing by almost four times (Shopify Engineering). Balance by measured weight, not shop count, and make shops movable while live. The move has four steps: batch-copy the shop's rows in primary-key order, tail the row-based binlog filtered to that shop_id, take a brief exclusive lock when replay lag is seconds and replay up to the recorded position, then flip the routing entry and delete the old copy. Buyers see a few seconds of retried writes, not downtime.

The hot-spot also looks different. In our Amazon design, popular items are spread over many fulfilment rows. Here a single drop can send tens of thousands of requests per second to one shop, one pod and one inventory row. Scaling the database up is too slow, so shape the traffic instead: serve storefront pages from the edge cache so browsing never reaches the database, and give checkout a per-shop throttle sized from the commit rate of the hottest row, with a queue page and a retry-after hint for the rest. Per-shop limits on connections and job queues protect neighbours on the same pod.

Finally, the platform has a third audience: third-party apps. They call an Admin API under per-shop rate limits and receive webhooks at least once with a short response deadline, so consumers must verify signatures and dedupe on the delivery identifier. Cart and order semantics stay as designed above; what changes is who is allowed to hurt whom.

Down the stack

Layer 1The contract

  • Place order. A 201 means the order header, lines and outbox row are durable on at least two replicas, the reservations exist, and payment is authorised. The same idempotency_key within 24 hours returns the same order_id and never reserves or authorises twice.
  • Reservation. For every (sku, fc) row, 0 <= reserved <= on_hand always holds. A reservation is released exactly once, by shipment, cancellation or the sweeper.
  • Display. Availability and promise on the product page may be up to about 30 seconds stale and are not a commitment.
  • Cart. A write acknowledged to the client is never lost, but a concurrent removal may be undone by merge.

Layer 2The mechanism

The reservation is one conditional update. In SQL it is UPDATE inventory SET reserved = reserved + :q WHERE sku = :s AND fc = :f AND on_hand - reserved >= :q, and success is "1 row affected". In DynamoDB it is an UpdateItem with ConditionExpression: on_hand - reserved >= :q, which fails with ConditionalCheckFailedException. Multi-row orders either use a store-level transaction for the handful of rows or reserve row by row and compensate on the first failure. DynamoDB TransactWriteItems takes up to 100 actions and 4 MB, no two actions may touch the same item, and the whole request is cancelled if any condition fails. Its ClientRequestToken makes a retry idempotent for only 10 minutes, and reusing it with different parameters returns IdempotentParameterMismatch (DynamoDB API reference). That window is shorter than the order's 24-hour idempotency key, so the order service still needs its own key table.

The order write is one transaction on one partition (the customer's), containing the header, lines and outbox row. A relay tails the outbox, or the table's change stream, and publishes to the bus with the order ID as the message key, so consumers see each order's events in order and deduplicate by (order_id, event_seq).

Layer 3The machine

  • A conditional write in a replicated store costs one leader round trip plus a synchronous copy to another availability zone. Inter-zone round trips are typically 0.5 to 2 ms, and an NVMe fsync of the write-ahead log about 0.1 ms, so a reservation lands in 3 to 10 ms end to end.
  • Row contention. While one update holds the row lock (or the leader serialises the item), others wait. At 2 ms per update, one row tops out near 500 reservations per second. A lightning deal on one SKU in one centre will exceed that, which is why hot stock is split into buckets.
  • The page. 40 parallel RPCs, each a few KB of protobuf, cost little bandwidth but many connections; aggregators keep pooled HTTP/2 connections to each dependency so a call is one stream, not a TCP and TLS handshake (a full TLS 1.3 handshake adds one round trip, plus CPU for the key exchange).

Why it's built this way

  1. Reads outnumber purchases by about 200 to 1, so browsing is optimised for latency with caches and degradation, while buying is optimised for correctness.
  2. Pages fan out to many services, so tail latency compounds, so deadlines and optional sections are mandatory.
  3. A failed "Add to cart" loses revenue directly, so the cart favours availability and accepts merge anomalies.
  4. Stock is physical and scarce, so commitment happens only at checkout, as a conditional write on a narrow row.
  5. Inventory, payments and orders are owned by different teams on different stores, so the order is a saga with compensations instead of a cross-system lock.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Aggregate page with deadlinesFast, resilient to slow servicesPartial pages, more client logicMany independent page sections
Fully server-rendered, wait for allSimple, complete pagesTail latency of the slowest callFew dependencies
Always-writable cart, merge on readNever rejects addsDeleted items can returnCart and wishlist
Strongly consistent cartExact semanticsWrites fail during failoverSmall scale, single region
Reserve at checkoutStock not locked by idle cartsCustomer can lose the item lateGeneral retail
Reserve at add to cartCustomer keeps the itemStarves buyers, needs expiryTickets, limited drops
Saga for order placementIndependent failures, clear repairCompensations to write and testServices owned by different teams
Distributed transaction (2PC)Atomic across storesCouples availability, blockingSingle database or tightly coupled stores

Leadership lens

Check yourself

  1. A product page calls 40 dependencies, each slow 1% of the time. Why does waiting for all of them hurt, and what does the design do instead?

    Show answer

    The chance at least one is slow is 1 - 0.99^40, about 33%, so the page's p67 equals its dependencies' p99. The aggregator uses a shared deadline of about 150 ms, hedges the few required calls, and renders optional sections as placeholders if they miss it.

  2. Why does the cart use an always-writable, merge-on-read store instead of a strongly consistent relational cart, and what anomaly does that accept?

    Show answer

    A failed "Add to cart" loses revenue directly, and a strongly consistent cart rejects writes during failover or partition. Merging siblings by union means a deleted item can reappear, which customers tolerate far better than a rejected add.

  3. The order service crashes after reserving stock but before persisting the order. How is the stuck stock recovered?

    Show answer

    Reservations carry the order ID, so a sweeper releases any reservation older than ten minutes that has no order. Placing an order is a saga with compensations, not a distributed transaction.

  4. Why must a lightning deal on one SKU split its stock into buckets? Use the row-contention numbers.

    Show answer

    At about 2 ms per conditional update, one (sku, fc) row tops out near 500 reservations per second, while a deal can send 50K attempts per second. Splitting stock into N buckets per centre, picking one at random and falling back on failure spreads that load across rows.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →