Design Amazon Online Shopping System
An online shopping system works by treating a cart as a draft of intent and the order as the committed, immutable fact: the dangerous moment is checkout, where the system must atomically convert N cart lines into a paid order while guaranteeing the same physical unit is never sold twice — so the whole design hinges on how you reserve inventory and how the order moves through a lifecycle of legal states.
The original Grokking version of this problem lists a dozen classes (Catalog, Product, Order, Payment…) with one-line "this class encapsulates X" notes and method signatures that have no bodies. That tells you the nouns but hides every decision that actually makes the system correct. This rewrite keeps the noun map but fills in the three mechanisms an interviewer (and production) actually cares about: inventory reservation under concurrency, the order state machine, and pluggable payment.
The three mechanisms that matter
Strip away the CRUD classes and an e-commerce LLD reduces to three load-bearing decisions:
| Concern | Naive answer (Grokking) | What it actually needs |
|---|---|---|
| Don't oversell stock | availableItemCount int on Product; decrement at checkout | An atomic reserve → confirm/release protocol; the decrement must be conditional and concurrency-safe |
| Order lifecycle | OrderStatus enum + setter | A State machine that rejects illegal transitions (you cannot cancel a SHIPPED order) |
| Pay many ways | makePayment(Payment) bodyless | A Strategy interface so card / bank-transfer / wallet are swappable without touching Order |
Everything else — catalog search, reviews, the address value object — is supporting cast.
The core hazard: two buyers, one unit
This is the bug the naive "count-- at checkout" code hides. Inventory is shared mutable state read by everyone and written during checkout. Without a reservation step you get a classic lost-update / oversell race.
Why the naive version is wrong
The Grokking checkout() body — were it written — would be if (product.availableItemCount > 0) product.availableItemCount--;. Read-test-write is three separate operations; nothing makes them atomic. Two threads interleave between the read and the write, both see 1, both decrement, and you've sold a unit you don't have. The fix is to make the decrement a single conditional, atomic step: "subtract 1 only if at least 1 remains, and tell me whether you succeeded." In a single process that's a guarded critical section; across a fleet of app servers it's a conditional UPDATE in the database:
UPDATE product SET available = available - :qty
WHERE id = :pid AND available >= :qty; -- rows affected: 1 = reserved, 0 = out of stockThe database row lock serializes the two checkouts; the loser sees 0 rows affected and is rejected cleanly. This is the difference between a toy and a system.
The reservation protocol
Decrementing at the final confirm is still too late: a buyer fills card details for 90 seconds while the unit sits "available" and someone else grabs it. Real systems reserve at checkout-start, hold with a TTL, then confirm or release:
- Reserve — on "Proceed to checkout", atomically move
qtyfromavailabletoreservedwith an expiry (e.g. 10 min). If the atomic step fails, surface "out of stock" now, not after payment. - Confirm — on payment success, the reservation becomes a permanent sale;
reservedis drawn down. - Release — if payment fails, the cart is abandoned, or the TTL lapses, return the held units to
availablevia a sweeper.
This is exactly why concert tickets show a countdown timer: that's a reservation TTL made visible.
Worked example: Alice and Bob race for the last iPhone
Inventory: iPhone 15, available = 1, reserved = 0, list price $999, TTL 10 min. Both hit checkout within the same second.
| Step | Actor | Action | available | reserved | Result |
|---|---|---|---|---|---|
| 1 | Alice | checkout: reserve 1 (atomic, available≥1?) | 1 → 0 | 0 → 1 | ✅ held until 10:10 |
| 2 | Bob | checkout: reserve 1 (atomic, available≥1?) | 0 | 1 | ❌ 0 rows — "Out of stock" |
| 3 | Alice | Order CREATED → state machine | 0 | 1 | order #A-7732, status PENDING_PAYMENT |
| 4 | Alice | pay $999 via CreditCardStrategy | 0 | 1 | payment COMPLETED |
| 5 | Alice | confirm reservation; order PAID | 0 | 1 → 0 | sale final; Observer fires "order confirmed" |
| 6 | Bob | (retries later — Alice's TTL never lapsed) | 0 | 0 | still out of stock — correct, only 1 existed |
Contrast step 2 with the naive code: there Bob's if(available > 0) would have passed on a stale read and oversold. Here the atomic conditional decrement makes Bob lose deterministically. If Alice had abandoned at step 4, the TTL sweeper at 10:10 runs available += 1, reserved -= 1 and Bob's retry would then succeed.
The order state machine
OrderStatus is not just an enum you assign freely — it's a State machine whose whole job is to make illegal transitions impossible to call. Requirement #8 ("cancel an order if it has not shipped") is a transition rule, not a comment.
The State pattern puts the cancel() / ship() logic inside each state object, so ShippedState.cancel() simply throws — there is no if-ladder in Order to forget a case. The naive enum-plus-setter lets any code do order.setStatus(CANCELLED) on a shipped order, and the rule lives only in a comment.
Corrected, compiling code
Java. Inventory uses a guarded critical section (single-process); the comment notes the distributed equivalent. Methods have bodies.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
enum OrderStatus { PENDING_PAYMENT, PAID, SHIPPED, DELIVERED, CANCELLED, REFUNDED }
// ---- Inventory: the atomic reserve/confirm/release core ----
final class Inventory {
private int available;
private int reserved;
private final ReentrantLock lock = new ReentrantLock();
Inventory(int initial) { this.available = initial; }
/** Atomic conditional reserve. Returns false if not enough stock.
* Distributed equivalent: UPDATE ... SET available=available-:q
* WHERE id=:id AND available>=:q; -- check rows-affected==1. */
boolean reserve(int qty) {
lock.lock();
try {
if (available < qty) return false; // loser of the race lands here
available -= qty;
reserved += qty;
return true;
} finally { lock.unlock(); }
}
void confirm(int qty) { // payment succeeded -> permanent sale
lock.lock();
try { reserved -= qty; } finally { lock.unlock(); }
}
void release(int qty) { // payment failed / TTL lapsed
lock.lock();
try { reserved -= qty; available += qty; } finally { lock.unlock(); }
}
int getAvailable() { lock.lock(); try { return available; } finally { lock.unlock(); } }
}// ---- Payment: Strategy, so Order never names a concrete method ----
interface PaymentStrategy { boolean pay(double amount); }
final class CreditCardStrategy implements PaymentStrategy {
private final String last4;
CreditCardStrategy(String last4) { this.last4 = last4; }
public boolean pay(double amount) {
System.out.printf("Charged $%.2f to card ****%s%n", amount, last4);
return true; // real impl calls a gateway; returns its result
}
}
final class BankTransferStrategy implements PaymentStrategy {
public boolean pay(double amount) {
System.out.printf("Initiated bank transfer of $%.2f%n", amount);
return true;
}
}
// ---- Order: orchestrates reserve -> pay -> confirm, enforces the state machine ----
final class Order {
private final String orderNumber;
private OrderStatus status = OrderStatus.PENDING_PAYMENT;
private final Inventory inventory;
private final int qty;
private final double total;
Order(String num, Inventory inv, int qty, double unitPrice) {
this.orderNumber = num; this.inventory = inv;
this.qty = qty; this.total = qty * unitPrice;
}
/** Caller must have already reserved stock for this order. */
boolean checkout(PaymentStrategy payment) {
if (status != OrderStatus.PENDING_PAYMENT)
throw new IllegalStateException("Cannot pay an order in state " + status);
if (payment.pay(total)) {
inventory.confirm(qty); // sale becomes permanent
status = OrderStatus.PAID;
return true;
}
inventory.release(qty); // give the held units back
status = OrderStatus.CANCELLED;
return false;
}
void ship() {
if (status != OrderStatus.PAID)
throw new IllegalStateException("Only PAID orders ship; was " + status);
status = OrderStatus.SHIPPED;
}
/** Requirement #8: cancel only before shipment. */
void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED)
throw new IllegalStateException("Cannot cancel a shipped order");
inventory.release(qty);
status = OrderStatus.CANCELLED;
}
OrderStatus getStatus() { return status; }
}// ---- Demo: Alice wins, Bob is rejected ----
public class Demo {
public static void main(String[] args) {
Inventory iphone = new Inventory(1); // last unit
boolean aliceGot = iphone.reserve(1); // true
boolean bobGot = iphone.reserve(1); // false -- loses the race
System.out.println("alice reserved=" + aliceGot + ", bob reserved=" + bobGot);
Order alice = new Order("A-7732", iphone, 1, 999.00);
alice.checkout(new CreditCardStrategy("4242")); // PAID, reserved drawn down
alice.ship(); // SHIPPED
System.out.println("alice order: " + alice.getStatus()
+ ", stock now " + iphone.getAvailable());
try { alice.cancel(); } // rejected: already shipped
catch (IllegalStateException e) { System.out.println("cancel blocked: " + e.getMessage()); }
}
}Output: alice reserved=true, bob reserved=false / Charged $999.00 to card ****4242 / alice order: SHIPPED, stock now 0 / cancel blocked: Cannot cancel a shipped order. The race is decided in reserve; the lifecycle is enforced in ship/cancel; payment is swappable.
Making checkout idempotent
The client will resend checkout after a network blip, and without a guard the gateway charges twice. The fix is an idempotency key owned by the order/attempt (not the HTTP request): store the first outcome under the key, and a retry returns it instead of paying again — the retried checkout becomes a no-op.
// A retried checkout is a no-op, never a second charge.
// The key belongs to the ORDER/attempt, not the network request.
final class IdempotentCheckout {
private final Map<String, Boolean> seen = new ConcurrentHashMap<>();
boolean checkout(String key, Order order, PaymentStrategy payment) {
Boolean prior = seen.get(key); // key = order's orderNumber
if (prior != null) return prior; // seen(key) -> return priorResult
boolean outcome = order.checkout(payment); // the guarded work runs once
seen.putIfAbsent(key, outcome); // remember it for any retry
return outcome;
}
}In production the key is a UNIQUE column on the order row, so a duplicate submit fails the insert — the same conditional-write trick that serializes the oversell race, applied to the payment attempt. Confirm the sale only after a definitive gateway success; treat a timeout as “unknown” and reconcile rather than blindly re-charging.
Pitfalls
- Decrementing stock at "add to cart" instead of checkout. A cart is intent, not a sale. Holding inventory the moment someone adds an item lets one shopper with 50 tabs starve everyone. Reserve at checkout-start, not at add-to-cart.
- No reservation TTL. Reserve-then-never-release leaks stock forever when carts are abandoned mid-payment. You need a sweeper (or a DB column
reserved_until) that returns expired holds. Forgetting this is how "sold out" pages persist with full warehouses. - Double-charge on retry. Network blips make the client resend
checkout. Without an idempotency key per checkout attempt, the gateway charges twice. The order, not the request, must own the key. - Confirm/release not paired with payment outcome. If
confirmruns before the gateway actually settles, a later decline leaves stock sold and money missing. Confirm only after a definitive success; release on any failure path — and treat "unknown" (timeout) as needing reconciliation, not silent confirm. - Treating
ItemandProductas the same thing.Productis the catalog SKU (price, description, seller); a cart/orderline itemis a snapshot of price × quantity at purchase time. If orders point at liveProduct.price, a seller's price change rewrites historical orders.
When to use these patterns — and when not
Order lifecycle: State vs a status enum + if-ladder
Choose State when transitions are governed by rules (cancel-only-before-ship), when behavior differs per state, and when you want illegal calls to be structurally impossible. The cost: one class per state and indirection — overkill for 2 states with no rules.
Prefer a plain enum + guard when the "machine" is one or two transitions with trivial rules; a single switch is clearer than five tiny classes. Choose State when the transition table is non-trivial and rule-enforcing; prefer an enum guard when it's two states and a setter.
Payment: Strategy vs if/else on a payment-type enum
Choose Strategy when payment methods are an open set that grows (card, bank, wallet, BNPL, gift card) and each has independent logic — new methods become new classes, Order never changes (Open/Closed). The cost: an interface + a class per method, and you must wire up selection (often a small factory).
Prefer if/else when there are exactly two methods that will never grow and the branches are one line each — the abstraction earns nothing. Choose Strategy when the method set is open and logic is non-trivial; prefer if/else when it's a fixed pair of trivial branches.
Notifications: Observer vs Order calling the notifier directly
Choose Observer (Order publishes "status changed"; email/SMS/push subscribers react) when notification channels are many and shouldn't couple to order logic — you add a channel without touching Order. The cost: indirection (the flow is harder to trace) and you must manage subscription lifecycles. Choose Observer when channels are plural and volatile; prefer a direct call when there's exactly one notification and it's synchronous.
Concurrency control: pessimistic lock vs optimistic version vs DB conditional UPDATE
The single-process demo uses a pessimistic lock (ReentrantLock). In a multi-server deployment that lock is useless — there are N JVMs. There the equivalent is the conditional UPDATE (the row lock is your mutex) or an optimistic version column (read version, write WHERE version = :v, retry on conflict). Optimistic wins under low contention (no lock held during think-time); pessimistic/conditional-UPDATE wins on a hot SKU where retries would thrash. Choose conditional UPDATE for hot single-row stock; choose optimistic versioning when conflicts are rare and you want no held locks.
Takeaways
- The hard part of e-commerce LLD is not the class list — it's the atomic reserve → confirm/release protocol that prevents overselling. Read-test-write is a race; make the decrement a single conditional step.
- Reserve at checkout-start with a TTL, confirm on payment success, release on failure or expiry. The countdown timer you see on ticket sites is this TTL.
- Model the order as a State machine so illegal transitions (cancel-after-ship) can't be called, not as a free-to-set enum.
- Use Strategy for payment so methods are swappable, Observer for notifications, and an idempotency key so retries don't double-charge.
Re-authored and deepened for this guide. Problem framing and class inventory adapted from Grokking the Object Oriented Design Interview (DesignGurus / Educative). Inventory reservation, idempotency, and concurrency patterns draw on Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns; Martin Fowler, Patterns of Enterprise Application Architecture (optimistic/pessimistic offline lock); and the Gang of Four Design Patterns for State, Strategy, and Observer. The original page's bodyless methods and oversell-prone naive decrement were corrected here.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design Amazon Online Shopping System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design Amazon Online Shopping System** (OO & Low-Level Design) and want to truly understand it. Explain Design Amazon Online Shopping System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design Amazon Online Shopping System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design Amazon Online Shopping System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design Amazon Online Shopping System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.