Code an online shopping platform
Open in the interactive app: practice, quiz, mock interview →In one minute
- In a low-level interview, "Design Amazon" is not about data centres. It is about the object model behind one checkout: catalog, cart, pricing, order and its lifecycle. The distributed version (fan-out product pages, a cart that never refuses a write, the order saga across services) is covered in the system design lesson Design Amazon.
- The cart is a wish list, not a reservation. It holds SKUs and quantities, never prices. The price and the stock are checked at the moment of checkout, and that moment is where all the correctness lives.
- An order is an immutable snapshot of what the customer agreed to pay: unit prices, discounts and totals copied at placement time. Later price changes, promotion edits or catalog deletes must never change a placed order.
- Promotions churn weekly, so they sit behind a
Promotioninterface (Strategy) and are combined by an explicit, written rule: best single promotion per line, then one coupon on the order. The combination rule is a product decision you surface, not a detail you improvise. - The order lifecycle is a state machine with guarded transitions (placed, paid, shipped, delivered, cancelled, returned). The problem shapes are multi-step consistency (stock, payment and order must agree) and duplicates (a double-clicked "Place order" must not buy twice). The same shapes appear in a hospital pharmacy: the prescription is the cart, dispensing is the checkout, and a dispensed dose is an immutable record.
Requirements
Functional:
- Customers browse products (SKU, title, category, price, stock) and add them to a cart with quantities. Adding more of the same SKU increases the quantity.
- Checkout turns the whole cart into an order: it prices every line at the current catalog price, applies automatic promotions and at most one coupon, and reserves stock for every line or for none.
- An order moves through placed, paid, shipped and delivered. A customer can cancel before shipping (stock goes back; a paid order is refunded) and can return a delivered order within 30 days.
- Customers can view orders and their status.
Non-functional:
- No overselling: two customers racing for the last unit cannot both get an order for it.
- Order totals are reproducible from the order record alone, in integer cents.
- Placing an order is idempotent from the customer's point of view.
Out of scope: search ranking, recommendations, reviews, multiple sellers per SKU, shipping address validation, tax and the payment gateway itself. Name them, mention where they would plug in, and move on.
Entities and relationships
- Product is catalog data: SKU, title, category and current price in cents. It changes when merchandising changes it, not when customers act.
- Inventory holds units on hand per SKU. It is the contended resource, and the only thing checkout mutates besides the order book. Keep it apart from
Product: they have different writers. - Cart belongs to one customer and maps SKU to quantity. It holds no prices, because a price copied into a cart is stale the moment merchandising changes it.
- Promotion is an interface with one method,
discount(line), returning cents for one priced line. Implementations include percent off a category and buy X get Y free. - Coupon is a value: code, minimum spend and a flat amount off. It applies to the order, not to lines.
- Order is the snapshot: id, customer, a list of OrderLine values (SKU, category, unit price, quantity, line discount) and the totals. Everything on it is immutable except its status.
- Store (or
CheckoutService) is the façade that owns the cart map, the inventory and the order book and runs checkout atomically.
Relationships: a customer owns one cart. A cart references products by SKU only. An order is composed of its lines, and a line copies product facts instead of pointing at the live product.
Class diagram
classDiagram
class Store {
-carts: Map~UserId, Cart~
-inventory: Map~Sku, int~
-orders: Map~OrderId, Order~
+addToCart(user, sku, qty)
+placeOrder(user, couponCode) Order
+pay(orderId)
+ship(orderId)
+deliver(orderId, day)
+cancel(orderId) int
+returnOrder(orderId, day) int
}
class Product {
+sku: string
+category: string
+price: int
}
class Cart {
+items: Map~Sku, int~
}
class Promotion {
<<interface>>
+discount(line) int
}
class Coupon {
+code: string
+minSubtotal: int
+off: int
}
class Order {
+id: string
+lines: OrderLine[]
+subtotal: int
+discount: int
+total: int
}
class OrderLine {
+sku: string
+unitPrice: int
+qty: int
+discount: int
}
Store "1" o-- "many" Product
Store "1" *-- "many" Cart
Store "1" o-- "many" Order
Order "1" *-- "many" OrderLine
Store --> Promotion
Store --> Coupon
PercentOffCategory ..|> Promotion
BuyXGetY ..|> PromotionKey flows
Checkout is one critical section: price the cart, check every line's stock, decrement all of it, record the order, clear the cart. Pricing is pure and could run before the lock; the stock check and decrement cannot.
sequenceDiagram
participant C as Customer
participant S as Store
participant P as Promotions
participant I as Inventory
participant O as OrderBook
C->>S: placeOrder(user, "SAVE5")
S->>S: read cart, copy current prices
S->>P: discount(line) for each line
P-->>S: best discount per line
S->>S: apply coupon if minimum met
S->>I: lock, check all lines
I-->>S: enough stock
S->>I: decrement all lines
S->>O: record Order O7 (PLACED)
S->>S: clear cart, unlock
S-->>C: Order O7, total 1900
C->>S: pay(O7)
S->>O: PLACED to PAIDDesign patterns used and why
- Strategy for promotions. "10% off books", "buy two get one free on socks", "20% off for Prime members on Prime Day" are each a class with
discount(line). Checkout iterates the list and never learns the kinds. New promotions are new classes, which is the Open/Closed principle earning its keep. - Policy object for combination. How promotions combine (best one per line, never stacked, then one coupon) is itself a policy. Put it in one function with a name, because the business will ask to change it ("let the coupon stack with the category sale") and you want that to be a one-place change. A pipeline of Decorators over a price also works, but it hides the stacking order in wiring order.
- State for the order lifecycle, implemented as an enum plus a transition table rather than a class per state. A table (
from,event,to) is easy to print, review and test exhaustively; six classes for six states is ceremony. - Façade:
Storeis the only entry point for the API layer. Callers never touch the inventory map, so you can later swap it for a database or an inventory service. - Value objects for
OrderLine,Couponand money. Copying values into the order is what makes it a snapshot. - Repository at the edges, so checkout logic is tested in memory, as the practice does.
Concurrency and edge cases
- Last unit, two buyers. Two checkouts both read "stock 1" and both decrement. Check and decrement must be one atomic step: a mutex in memory, or a conditional update in SQL:
UPDATE inventory SET on_hand = on_hand - $2 WHERE sku = $1 AND on_hand >= $2, checking the affected row count (PostgreSQL UPDATE). - Multi-line atomicity. A cart of three SKUs where the third is short must decrement nothing. In memory, check all lines first, then decrement. In a database, do it in one transaction and lock rows in a fixed order (sorted by SKU) so two carts with overlapping SKUs cannot deadlock (PostgreSQL explicit locking).
- Double submit. A customer double-clicks, or the client retries after a timeout. Give checkout an idempotency key (the cart version or a client-generated id) and return the first result on a repeat (Stripe idempotent requests).
- Price changes mid-session. The cart showed 10 dollars, the price is now 12. Price at checkout and show the change before confirming; never charge a price the customer did not see. After placement, the order line carries its own unit price, so a later catalog change cannot move it.
- Promotion rounding. Percent discounts produce fractions of a cent. Pick a rule (floor per line in the practice), apply it per line, and never compute money in floating point (Stripe currencies).
- Cancel versus ship race. The warehouse marks an order shipped at the same moment the customer cancels. Both are transitions on one order, so they serialise on that order's lock or row version; whichever commits first wins and the other gets "invalid transition".
- Returns. Measure the return window from the delivery timestamp on the order, using an injected clock, and restock only after the item is received (the practice simplifies this to restock on return).
In the wild
- Alibaba, 2012: Double Eleven inventory row oversells. Every buyer of a popular item updated the same inventory row, so row-lock contention collapsed throughput, the inventory database ran out of capacity at midnight, and a large number of orders were oversold. The database team later added update queuing and a concurrency-control change to AliSQL, and a 2016 rework made deduction about ten times faster (Alibaba Cloud).
- Stripe, 2017: idempotency keys for safe retries. When a network failure leaves it unclear whether a charge happened, clients send a unique
Idempotency-Keywith each POST; the server replays the stored result instead of charging again, and clients retry with backoff plus jitter (Stripe). - Shopify, 2022: ULID idempotency keys. Switching keys from random UUIDv4 to time-ordered ULIDs cut INSERT duration by 50 percent, because random keys scatter writes across B-tree pages (Shopify Engineering).
Down the stack
Layer 1The contract
placeOrder either creates exactly one order whose lines match the cart at current prices and decrements stock for every line, or changes nothing (out of stock, empty cart, unknown coupon). total = subtotal - discount holds on every order, with all values in integer cents, and is recomputable from the lines. Status transitions outside the table fail and change nothing. Cancel and return each restock and refund at most once.
Layer 2The mechanism
In memory, the store is a few hash maps (SKU to product, SKU to on-hand count, user to cart, order id to order) behind one mutex. Checkout is O(L × P) for L lines and P promotions, which for real carts (L under 50, P under 20 applicable) is a few hundred function calls. In a relational database the same model becomes products, inventory(sku PK, on_hand CHECK (on_hand >= 0)), carts(user_id, sku, qty), orders(id, user_id, status, subtotal, discount, total, version) and order_lines(order_id, sku, unit_price, qty, discount). The CHECK constraint is a second line of defence against overselling. Status transitions use optimistic concurrency: UPDATE orders SET status = 'SHIPPED', version = version + 1 WHERE id = $1 AND status = 'PAID' AND version = $2. Amazon's own cart, as described in the Dynamo paper, goes further and accepts writes on any replica, merging divergent carts later; that is the distributed answer, and the Design Amazon system design lesson walks through it.
Layer 3The machine
An uncontended mutex costs tens of nanoseconds; pricing a 10-line cart against 10 promotions is around 100 virtual calls (a few nanoseconds each through a vtable or Go itab) plus small allocations for the order lines, well under 10 microseconds in total. The expensive step is durability: a Postgres commit waits for the WAL fsync, roughly 0.1 to 1 ms on NVMe, and a payment authorisation call takes 200 ms to 2 s over the network. That ratio is why payment authorisation never happens inside the inventory lock: holding a row lock for two seconds on a hot SKU during Prime Day would serialise every buyer of that item behind a remote call. The critical section stays local; payment follows as a separate transition.
Why it's built this way
- Prices and stock change while customers browse, so the cart stores intent (SKU, quantity), not facts.
- Customers dispute charges, so the order copies every fact that set its price, which makes it an immutable snapshot.
- Stock is scarce and contended, so the check and decrement of all lines happen in one atomic step.
- Remote calls are a thousand times slower than local work, so payment runs outside the lock as its own state transition.
- Merchandising invents new deals every week, so promotions are strategies combined by one named policy, and checkout does not change.
Extensibility
- Marketplace sellers. Split
Product(what it is) fromOffer(who sells it, at what price, with what stock). The cart then holds offer ids, and one order may produce several shipments, one per seller or warehouse. - Stacking rules change. Replace the combination policy, not the promotions. A
PromotionPolicyinterface withBestPerLineandStackAllimplementations keeps the decision explicit and testable. - Prime pricing or member-only deals. Pass a pricing context (customer tier, date, channel) to
discount. Adding it later is a breaking interface change, so consider a context object from the start if the interviewer hints at it. - Partial cancellation and partial returns. The order gets per-line status, and refunds are computed per line with the line's share of the order-level coupon prorated.
Variant: shopping cart with price snapshots and merge
The main design treats the cart as pure intent, so it is a plain map from SKU to quantity and all the hard work waits for checkout. A cart-centred variant keeps that rule for what the customer pays but makes the cart itself a richer object, because carts live for days and are edited from several places.
- Price snapshot per line. Each
CartLinestores the unit price in cents the customer saw when they last touched it. The snapshot is for display and for detecting change; it is never what gets charged. At checkout, reprice every line from the live catalog and compare. If anything moved, stop, show the old and new numbers, and ask for confirmation. Honouring old prices silently lets someone park items before a price rise; charging new ones silently is a surprise. - Versioned aggregate. Two devices edit one cart. Give the cart a version number and have each write carry the version it read, rejecting a mismatch (optimistic concurrency, or
ETagwithIf-Matchover HTTP, RFC 9110). A retried "add" then fails on the stale version instead of adding twice. Without this, the last write wins and one edit disappears. - Guest-to-account merge. A guest cart is keyed by a token; on explicit login it merges into the account cart. Pick the rule deliberately: sum quantities, take the larger, or keep the newer line. Afterwards re-apply the per-line maximum and current stock, drop delisted SKUs and tell the user. Never merge on a cookie alone, since the browser may be shared.
- No reservation on add. Carts are abandoned far more often than checked out, so holding stock would starve real buyers. Reserve at checkout, ideally with a short expiry.
- Cheap reads. The cart badge renders on every page, so reads must stay fast: keep totals computed on demand from lines, and cache only the item count.
Compared with the main design, the order, stock and state machine code are unchanged. What grows is the cart: snapshot field, version column, merge function and a checkout step that returns "prices changed" as a normal outcome rather than an error. Test the merge with the limit and stock cases, and the version check with two interleaved writers.
Leadership lens
- Build first: atomic checkout with multi-line all-or-nothing stock and the order snapshot, with a test that runs many concurrent checkouts for the last units. Promotion bugs cost money; overselling costs trust and operations time.
- Make the promotion combination rule a written product decision with an owner in merchandising. Engineers improvising stacking rules is how a site ends up giving away items.
- Ask in review: where is the idempotency key for checkout; what happens to a cart whose SKU was delisted; can any path change a placed order's totals; how are refunds tied to the original order lines.
- Ownership splits naturally: catalog, pricing and promotions, inventory, and orders. The interfaces between them (
price(cart),reserve(lines),transition(order, event)) are what each team commits to.
Check yourself
Why does the cart store SKUs and quantities but no prices?
Show answer
Prices change while items sit in carts, so any price stored there is stale. Pricing at checkout and copying the result into the order gives one moment where price is decided and recorded.
A cart has three lines and the third is out of stock. What must happen to the first two, and how do you guarantee it in a database?
Show answer
Nothing must be decremented. Run all conditional decrements in one transaction, locking rows in sorted SKU order, and roll back if any update affects zero rows.
Why keep payment authorisation outside the inventory critical section?
Show answer
A remote payment call takes hundreds of milliseconds, so holding stock locks across it would serialise every buyer of a hot SKU. Reserve stock quickly, then authorise as a separate transition with cancel as the compensation.
A merchandiser wants a coupon to stack with a 50% category sale. Where does the code change?
Show answer
Only in the combination policy. The promotions and coupon classes stay as they are, which is the payoff of keeping the rule in one named place.
Sources and further study
Primary
- RFC 9110: HTTP semantics, If-Match: the conditional-write header behind the versioned cart.
Systems docs
- PostgreSQL: UPDATE: the conditional decrement and affected row count.
- PostgreSQL: explicit locking: row locks and why lock order matters.
- Stripe: idempotent requests: safe retry of a timed-out charge.
- Stripe: currencies and minor units: integer minor units plus a currency code.
Books
- Design Patterns, Gamma, Helm, Johnson and Vlissides: chapter 5 (Behavioral, including Strategy and State).
- Designing Data-Intensive Applications, Martin Kleppmann: chapter 7 "Transactions".
- Release It!, Michael Nygard, 2nd edition: the stability patterns chapters on timeouts and retries.
Other curricula
- donnemartin/system-design-primer: object-oriented design: models entities and classes, with no concurrency or idempotency.
Watch
- Amazon Shopping System LLD (Reactify Labs): a class-level walkthrough of a shopping system including checkout.
- State Pattern, Design Patterns ep 17 (Christopher Okhravi): the pattern behind order lifecycles, with a clear discussion of when it is worth the classes.
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): the pattern behind pluggable promotions.
Free resources
- Dynamo: Amazon's Highly Available Key-value Store (DeCandia et al., SOSP 2007): the paper built around a shopping cart that must always accept writes.
- Money (Martin Fowler, P of EAA): why money is a value type with integer amounts and explicit rounding.
- DDD Aggregate (Martin Fowler): why an order and its lines change together and are loaded as one unit.
- Strategy pattern (Refactoring.Guru): promotions as interchangeable algorithms.
- Saga pattern (microservices.io): how the same checkout becomes a sequence of local transactions with compensations once services split.