CMD Guide
HomeOO & Low-Level DesignOO Design Problems

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:

ConcernNaive answer (Grokking)What it actually needs
Don't oversell stockavailableItemCount int on Product; decrement at checkoutAn atomic reserve → confirm/release protocol; the decrement must be conditional and concurrency-safe
Order lifecycleOrderStatus enum + setterA State machine that rejects illegal transitions (you cannot cancel a SHIPPED order)
Pay many waysmakePayment(Payment) bodylessA 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.

diagram
diagram

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 stock

The 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:

  1. Reserve — on "Proceed to checkout", atomically move qty from available to reserved with an expiry (e.g. 10 min). If the atomic step fails, surface "out of stock" now, not after payment.
  2. Confirm — on payment success, the reservation becomes a permanent sale; reserved is drawn down.
  3. Release — if payment fails, the cart is abandoned, or the TTL lapses, return the held units to available via 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.

StepActorActionavailablereservedResult
1Alicecheckout: reserve 1 (atomic, available≥1?)1 → 00 → 1✅ held until 10:10
2Bobcheckout: reserve 1 (atomic, available≥1?)01❌ 0 rows — "Out of stock"
3AliceOrder CREATED → state machine01order #A-7732, status PENDING_PAYMENT
4Alicepay $999 via CreditCardStrategy01payment COMPLETED
5Aliceconfirm reservation; order PAID01 → 0sale final; Observer fires "order confirmed"
6Bob(retries later — Alice's TTL never lapsed)00still 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.

diagram
diagram

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

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


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes