Marketplace & Booking
Design E-Commerce Platform
01
The problem
The problem
You are building the shopping side of a general marketplace: the part a buyer touches between typing "running shoes" into a search box and getting an email that says the parcel is on its way. Think of a store with 300 million listed SKUs, tens of millions of shoppers a day, and a calendar that includes a few sale days where traffic jumps by an order of magnitude before lunch.
The tension is simple to state. Almost every request is someone looking, and looking can tolerate data that is a few seconds old. A tiny fraction of requests are someone paying, and paying cannot tolerate any ambiguity at all: the unit they bought must exist, must be theirs alone, and the money must move exactly once. A good design admits that these are two different systems sharing a catalogue, and builds each one for its own job.
How do you let millions of people browse a fast, cached storefront while making sure the last unit of a product goes to exactly one buyer, and that nobody is charged for an order that does not exist?
Keyword search with filters, category pages, the product page with live stock, a cart that follows the user across devices, checkout with stock holds and payment, and order status from placed to delivered.
Seller tools, recommendations, reviews, coupons and the pricing engine, returns, and the warehouse and courier systems. Name them so the interviewer knows you see them, then park them behind an interface.
02
What the platform must do
What the platform must do
- Search the catalogue by text, then narrow by brand, price band, rating and category, with result counts per filter value.
- Show a product page with images, specs, seller, price, a delivery promise for the user's pincode and a stock signal such as "4 left".
- Keep a cart per user that survives logout and device changes. Putting something in the cart does not set stock aside.
- At checkout, hold the stock, take payment and create the order so that either all three happen or none of them stick.
- Let buyers follow an order through placed, packed, shipped, out for delivery and delivered, and cancel before it ships.
- Browse and search pages: p95 under 250 ms, available 99.99%, a few seconds of staleness is fine.
- Checkout: completes in under 3 seconds including the payment provider, available 99.95%.
- Stock and money are strongly consistent: zero oversells on normal days, and any oversell on a sale day is detected and resolved automatically.
- A placed order is never lost. A retried checkout never charges twice.
- A lost cart is an annoyance, not an incident, which frees the cart store from the durability bar orders have.
Ask whether the marketplace holds stock itself or sells for third-party sellers, because that decides who owns inventory truth. Ask how many units a popular sale item has: 500 phones and 2 million t-shirts need different machinery. And ask whether "4 left" on the product page must be exact. The honest answer is no, and saying so early removes a lot of pressure from the read side.
03
Back-of-envelope numbers
Back-of-envelope numbers
Pick round assumptions and say them out loud: 30 million daily active shoppers, about 18 browse requests each (searches, category pages, product pages), 1.2 million orders a day on an ordinary day, and a catalogue of 300 million SKUs. Sale days multiply traffic by about twenty for the first few hours.
| Quantity | Working | Result |
|---|---|---|
| Browse requests per day | 30M users x 18 | 540M |
| Average browse rate | 540M / 86,400 s | about 6,250 / s |
| Evening peak | 5x average | about 31,000 / s |
| Sale-day opening peak | 20x average | about 125,000 / s |
| Orders per second | 1.2M / 86,400 s, peak hour 5x | 14 / s average, 70 / s peak |
| Sale checkout burst | observed queue drain rate on a headline deal | about 4,000 / s for a few minutes |
| Browse to buy ratio | 540M / 1.2M | roughly 450 : 1 |
| Catalogue documents | 300M SKUs x 6 KB | 1.8 TB, about 5.4 TB with index replicas |
| Product images | 300M x 8 images x 250 KB | about 600 TB in object storage, behind a CDN |
| Stock rows | 450M SKU and warehouse pairs x 100 B | about 45 GB, small enough to keep hot |
| Active carts | 8M carts x 2 KB | about 16 GB |
| Order history growth | 1.2M x 365 x 3 KB with line items | about 1.3 TB a year |
The order path averages fewer than a hundred writes a second, which any relational database handles. The two hard numbers are 125,000 reads a second on sale morning and thousands of buyers per second aimed at the same few rows of stock. The first is a caching problem. The second is a contention problem, and it is the one interviewers want you to solve.
600 TB of images sounds scary but is solved by object storage and a CDN. They never touch your application servers. Spend your time on the 45 GB of stock rows instead, because that is where correctness lives.
04
API design
API design
Payment providers sometimes take several seconds, and 3-D Secure can bounce the buyer to a bank page. Treat checkout as a resource with a state, not a single blocking call. The client polls or listens on a socket, and a dropped connection never leaves the buyer guessing whether they paid, because they can always ask for the checkout by id.
05
Architecture overview
Architecture overview
flowchart LR
B["Shopper app"] --> CDN["CDN and edge cache"]
CDN --> GW["API gateway"]
GW --> SR["Search service"]
GW --> PD["Product page service"]
GW --> CT["Cart service"]
GW --> CO["Checkout orchestrator"]
SR --> ES[("Search index")]
PD --> RC[("Redis read cache")]
CT --> CK[("Cart KV store")]
CO --> INV[("Inventory DB, sharded by SKU")]
CO --> PAY["Payment gateway adapter"]
CO --> ORD[("Orders DB with outbox")]
ORD -. "CDC" .-> K[["Kafka: order and stock events"]]
K -. "reindex, notify, fulfil" .-> ESDraw a line down the middle of the diagram. To the left of checkout is the browse plane: search, category pages, product pages and the cart. Everything there is a read or a cheap, forgiving write, and it is built from caches, a search index and stateless services that scale by adding pods. To the right is the buy plane: the checkout orchestrator, inventory, payments and orders. It handles a few hundred requests a second on a normal day and is built from transactional stores that are slower to scale but never lie.
The two planes talk through events, not shared tables. When stock changes or an order is placed, the orders database writes the change and an outbox row in one transaction. A change-data-capture connector streams those rows into Kafka, and consumers update the search index's in-stock flag, invalidate cached product pages, tell fulfilment to pick and pack, and trigger emails. If any consumer is down, events wait in Kafka and no buyer notices.
An Elasticsearch or OpenSearch cluster with the catalogue split across shards by SKU hash. It serves relevance ranking and facet counts, and is fed asynchronously from the catalogue database, so it lags by a few seconds by design.
Assembles the page from the catalogue, price and stock. The heavy static part is cached at the CDN; the small dynamic part (price, stock band, delivery date) is a separate call cached in Redis for a few seconds.
A KV store such as DynamoDB keyed by user id, with a version number on each cart. Guest carts live under a device id and merge into the user's cart at login.
Runs each checkout as a durable state machine: hold stock, authorise payment, write the order, confirm the hold. Every step is idempotent and every step has an undo.
The only writer of stock. Postgres sharded by SKU, one row per SKU and warehouse, with conditional updates that refuse to go below zero.
Orders sit in a relational store partitioned by buyer, because "my orders" is the main query. The outbox plus CDC gives at-least-once events with no dual writes.
06
Deep dive: holding stock without overselling
Deep dive: holding stock without overselling
Two buyers see "1 left" and press Buy within the same 20 ms. Both requests read available = 1, both decide there is enough, and both write 0. You have sold one phone twice. Every technique below is a way to make the check and the decrement a single indivisible step, and then to keep that step fast when thousands of buyers aim at one row.
Step one: let the database do the check
Never read stock into the application, compare, and write back. Push the comparison into the write itself, so the database's row lock serialises competing buyers for you:
Both statements run in one transaction on the SKU's shard. The update either moves units from available to held or touches nothing, and the hold row records who owns those units and until when. This is correct with no explicit locks, no SELECT ... FOR UPDATE, and no retries in the happy path. For the long tail of 300 million SKUs, where two people rarely buy the same item in the same second, this is the whole answer.
Step two: holds, not decrements
Stock moves through three states. Available units can be held. Held units belong to a checkout that has not finished. Committed units belong to a placed order and leave the count when the warehouse ships them. A hold has a ten-minute expiry so that a buyer who closes the tab on the payment page does not keep the item forever. A sweeper scans for expired holds every few seconds and moves their units back to available, using the hold id so that a late payment confirmation and the sweeper cannot both act on the same hold.
flowchart LR A["Available"] -->|"checkout holds"| H["Held, expires in 10 min"] H -->|"payment authorised, order written"| C["Committed to order"] H -->|"payment declined or hold expired"| A C -->|"order cancelled before ship"| A C -->|"warehouse ships"| S["Shipped, leaves the count"]
Step three: hot SKUs need split counters
The conditional update serialises on one row. A Postgres row can take somewhere between a few hundred and roughly two thousand such updates a second before lock waits dominate, and on sale morning the headline phone gets 4,000 buy attempts a second. The row becomes a queue, latency climbs past the checkout budget, and connection pools fill up for every other SKU on that shard.
The fix is to split the stock of a hot SKU into buckets. Before the sale, an operator (or a rule that watches add-to-cart velocity) marks the SKU hot and the inventory service spreads its 5,000 units over 32 sub-rows of about 156 units each. A checkout picks a random bucket and runs the same conditional update against it. If that bucket is empty it tries two more at random, then falls back to a scan of the rest. Contention drops by roughly the bucket count, and correctness is unchanged because each bucket is still guarded by its own available >= qty. The cost is that "how many are left" now needs a sum over 32 rows, which is fine for a number you were going to show approximately anyway.
Some teams use a Redis counter with a Lua script for this instead: one DECRBY guarded by a check, at well over 50,000 operations a second on one shard. It is faster, but Redis is now a second source of truth for stock, and you need a reconciliation job and a plan for what happens when a failover loses the last second of writes. Split rows keep a single transactional store and are usually enough. Both are reasonable; pick one and explain the failure you accept.
Step four: the checkout saga
Checkout spans the inventory shard, an external payment provider and the orders database. There is no transaction that covers all three, and two-phase commit is not available because the payment provider will not take part in it. So the orchestrator runs a saga: a sequence of local steps, each with a compensating step that undoes it.
| Step | Action | If a later step fails |
|---|---|---|
| 1. Hold | conditional update plus hold row, per line item | release the hold |
| 2. Authorise | payment authorisation with the checkout id as idempotency key | void the authorisation |
| 3. Order | insert order, line items and outbox event in one transaction | mark order cancelled, emit event |
| 4. Commit hold | move units from held to committed | none needed, the order owns them |
| 5. Capture | capture the authorised amount when the parcel ships | refund if a later cancellation happens |
The orchestrator persists its state after each step, so a crash in the middle resumes from the last recorded step rather than starting over. Every external call carries a key derived from the checkout id, which makes retries safe: a second authorise with the same key returns the first result instead of charging again. Authorise first and capture on shipment is worth calling out, because it means a cancelled or unfulfillable order costs a void, not a refund that takes the buyer a week to see.
Keeping the browse plane honest enough
The product page shows a stock band ("in stock", "only a few left", "sold out"), not a live count. Inventory emits an event when a SKU crosses a band boundary, and a consumer updates the Redis entry and the search index flag. Between those events the page can be a few seconds behind, which is acceptable because the hold at checkout is the real check. If the page says "in stock" and the hold fails, the buyer sees a clear "just sold out" message with alternatives. That is a better experience than a slow page that tries to be exact.
07
Decisions and their costs
Decisions and their costs
One transactional store owns stock, with no second system to reconcile. Bucketing only the few hundred hot SKUs keeps the long tail simple. You pay with an extra operational step before big sales and a summed read for the stock band.
Correct and fast enoughAn order of magnitude more throughput per key, and easy to reason about. But stock now lives in two places, asynchronous replication can drop recent decrements on failover, and a reconciliation job becomes part of the correctness story.
Good for extreme dropsEach step commits locally and a central orchestrator knows exactly where every checkout stands, which makes support questions and stuck-checkout alerts easy. It needs a durable workflow store and careful idempotency on every step.
Fits external payment providersGives real atomicity on paper, but the payment provider cannot be a participant, the coordinator becomes a single point of failure, and locks stay held while the slowest participant votes. Availability drops exactly when traffic peaks.
Not practical hereCarts are wish lists: most never convert. Holding at cart time would lock up popular stock for people who wandered off. Holding for ten minutes at checkout keeps stock liquid, at the cost of the occasional "sold out while you were deciding".
Matches buyer behaviourFeels friendlier, and some ticketing sites do it with a short timer. For a general store with low conversion it hides real stock from real buyers and invites bots to fill carts to block competitors.
Only with short timersA dedicated engine gives typo tolerance, ranking and facet counts in tens of milliseconds over hundreds of millions of documents. The price is a few seconds of lag and an indexing pipeline to operate and backfill.
Needed at this catalogue sizeNo sync problem and one less system. It struggles with facets and fuzzy matching at 300 million rows, and putting search load on the database that also serves checkout couples the two planes you worked to separate.
Fine for an early storeThe order row and its event commit together, so there is no window where one exists without the other. Downstream consumers can be slow, broken or redeployed without failing a single checkout, and Kafka lets you replay events into a new consumer.
No dual writesSimple to write, but the slowest downstream service now sets checkout latency, and a crash between "order saved" and "fulfilment told" leaves an order that nobody packs.
Fragile under load08
Failure modes
Failure modes
The provider approves the charge, then the orchestrator pod dies before the order insert. The buyer sees money held on their card and no order in the app. This is the failure that generates the angriest support tickets.
The orchestrator records "authorising" with the idempotency key before calling out, so on restart it knows a charge may exist. It asks the provider for the result by key and either finishes the order or voids the authorisation. A nightly job matches provider settlements against orders and flags anything unmatched.A SKU that nobody marked hot goes viral. Its row serialises thousands of updates, the shard's connection pool fills, and checkouts for unrelated products on the same shard start timing out.
Detect hotness automatically from hold rate per SKU and split it into buckets within a minute. Give each SKU a small concurrency cap at the inventory service so a single item cannot take the whole pool, and shed excess with a fast "try again" rather than a slow timeout.A deploy breaks the sweeper. Expired holds pile up, products show "sold out" while the warehouse is full, and revenue quietly drops with no error rate to alert on.
Alert on the count of holds past their expiry, not on sweeper errors. Run the sweeper on several nodes with a lease so one bad host cannot stop it, and make the hold check itself treat an expired hold as free.A slow network makes the buyer tap twice, or the app retries after a timeout. Without protection you get two holds, two charges and two parcels.
The client sends one idempotency key per checkout attempt. The orchestrator stores the key with a unique constraint and returns the existing checkout for any repeat, and the same key flows down to the payment provider.A node loss during a mapping change leaves several shards red. Search returns partial results or errors, and search is how most buyers start.
Run at least one replica per shard across zones, return partial results with a flag rather than failing, and keep a cached set of top category pages that render without search. Never let search failure touch the buy plane.A TV advert shows a product and its cache entry expires a second later. Ten thousand requests miss together and hit the catalogue database for the same row.
Coalesce misses per key so one request rebuilds the entry while others wait, refresh popular entries ahead of expiry, and serve the stale copy for a few seconds if the rebuild is slow.A warehouse count was wrong, a damaged unit was found at packing, or a manual stock edit raced a checkout. The system sold a unit that does not physically exist.
Treat this as a business process, not a bug: fulfilment emits "cannot fulfil", the order is cancelled, the authorisation is voided before capture, and the buyer gets an apology and a voucher. Track the rate; it should stay well under one in ten thousand orders.09
How to run the interview
How to run the interview
Split reads from writes in the first five minutes. Show the browse to buy ratio from your own numbers and say plainly that the two sides get different consistency rules. Everything later hangs on that split.
Describe the oversell race concretely. Two buyers, one unit, read then write. Then fix it with a conditional update and explain why it is atomic. Interviewers listen for whether you understand the race, not whether you name a tool.
Have a plan for the hot row. Expect "what if 5,000 people buy the same phone at once". Bucketed stock or a Redis counter are both fine answers, as long as you state what you give up.
Walk the saga including the unhappy paths. Saying "saga" is not enough. Say what happens if payment fails, if the order write fails, and if the orchestrator crashes between the two. Compensations and idempotency keys are what get you credit.
Mention authorise versus capture. It is a small detail that shows you have seen how card payments really work, and it makes cancellations cheap.
Do not reserve on add to cart. If you do, be ready to defend it against bots and low conversion. Most interviewers expect holds at checkout with a timer.
Use events for side effects. Draw the outbox. It answers "what if the email service is down" and "how does search learn about stock" in one move.
Leave room for a sale-day discussion. Waiting rooms, pre-warming caches and per-SKU concurrency caps are where senior candidates separate themselves. If time is short, mention them and offer to go deeper.
10
How the design grows
How the design grows
Start with a monolith that is correct, then pull pieces out when a measured limit forces you to. The order in which you split things is a design decision in itself.
Catalogue, carts, stock and orders in one database, with the conditional stock update already in place. Search is the database's own full-text index. Correctness is easy when everything shares a transaction.
Product pages move behind a CDN, dynamic fragments into Redis, and browse queries go to replicas. Images move to object storage. The primary now handles only writes and checkout reads.
Add a search cluster fed by an outbox and CDC. Emails and fulfilment hooks move onto Kafka consumers, which takes them off the checkout path.
Inventory becomes its own service on Postgres sharded by SKU. Orders shard by buyer. Carts move to a KV store. Checkout becomes an orchestrated saga because the pieces no longer share a database.
Bucketed stock for flagged SKUs, per-SKU concurrency caps, pre-warmed caches and a virtual waiting room in front of the headline deals. The single hot row stops being the thing that pages you.
Each region runs its own buy plane with local stock and payment providers, while the catalogue and search replicate globally. Stock is regional anyway, so cross-region transactions are rarely needed.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
