Amazon Online Shopping System

Catalog, cart, orders, payments, shipping. The biggest single OOD prompt; the discipline is *what you cut*.

System Advanced
15 min read
ood case-study e-commerce state-pattern command-pattern

Context#

Amazon-scale online shopping is the largest OOD prompt by surface area: catalog, search, cart, checkout, payments, fulfilment, shipping, returns, reviews, recommendations, seller onboarding, multi-warehouse inventory, fraud, customer support. Every one of those is its own round. The LLD discipline on this prompt is not how much you can cover — it is how decisively you cut. An interviewer scoring this round watches you fight the urge to draw the whole map.

The HLD overlap is real: search-at-scale, recommendations, feed-fan-out for product pages, cross-region inventory consistency are system-design problems with system-design answers (sharding strategy, async pipelines, eventual consistency). An LLD round is not the place to design them. The right move is to name the seam — “Catalog is the read API; the implementation is out of scope here” — and move on.

The interviewer’s hidden objectives, in order:

  • Can you declare scope first and stick to it? Bonus points for naming what you cut.
  • Can you identify the five core aggregates — Product/Catalog, Cart, Order, Payment, Shipment — and keep their state machines independent?
  • Can you draw the Order lifecycle crisply (it is the biggest single artefact in this round)?
  • Can you apply the Command pattern for checkout (so each step is replayable / cancellable) and the State pattern for Order?
  • Can you handle payment failure mid-checkout and out-of-stock at fulfilment without hand-waving?
  • Can you defend a list of explicit cuts at the end of the round?

Requirements (functional and non-functional)#

Scope is the most points-bearing decision in this round. Below is a defensible cut that fits 45 minutes.

Functional — in scope.

  • A catalog of products. Each product has an id, title, price, and a stock count maintained at the SKU level. Browsing returns products; the search implementation is out-of-scope (a CatalogService interface marks the seam).
  • A customer can add to cart, update quantity, and remove. The cart belongs to a customer and persists across sessions.
  • Checkout turns a cart into an order: it locks stock, captures payment, creates a shipment, clears the cart. Each step is independently logged.
  • An Order walks the lifecycle Placed → Paid → Picked → Packed → Shipped → Delivered, with Cancelled reachable from Placed/Paid and Returned reachable from Delivered.
  • A customer has multiple Addresses; one is selected per order.
  • Payment is via CreditCard / UPI / GiftCard — a strategy interface.

Functional — out of scope (called out explicitly, and decisively).

  • Search and ranking. A separate HLD round. CatalogService.search(query) is a seam.
  • Recommendations and personalisation. HLD round. Same seam shape.
  • Reviews and ratings. Their own aggregate; named but not designed.
  • Seller onboarding, listings, payouts. A whole second platform.
  • Multi-warehouse inventory routing. Inventory is one number per SKU here; cross-DC routing is a fulfilment service round.
  • Returns policy mechanics. The Returned state exists; the refund window / RMA flow is a separate problem.
  • Promotions, coupons, dynamic pricing. Strategy seam on PricingPolicy; concrete implementations skipped.
  • Fraud detection. A FraudCheck seam in checkout; implementation out of scope.
  • Notifications. Observer hook on Order state changes; the channel implementations skipped.

Stating these cuts on the whiteboard first is the move that separates a strong round from a meandering one.

Non-functional.

  • The model should support 10⁷ products and 10⁶ daily orders in principle, but the OOD round optimises only the in-process shape — repositories are seams, not implementations.
  • Checkout must be atomic across stock lock + payment + shipment creation, with compensation on any failure.
  • Order state changes are observable (audit + notifications hook).
  • Concurrency: two customers buying the last unit must not both succeed.

Use case diagram#

┌────────────────┐
│ Customer │
└────────┬───────┘
┌────────────────┼────────────────┬────────────────┐
▼ ▼ ▼ ▼
[browse catalog] [manage cart] [checkout] [track / return]
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Online Shopping System │
└─────┬───────────────┬─────────────────┬─────────────────┘
▲ ▲ ▲
│ │ │
┌───────────┐ ┌──────────────┐ ┌────────────┐
│ Seller │ │ Fulfilment │ │ Support │
├───────────┤ ├──────────────┤ ├────────────┤
│ (cut) │ │ pick / pack │ │ refund │
│ │ │ ship │ │ cancel │
└───────────┘ └──────────────┘ └────────────┘

Four actors. Seller flows are explicitly cut. Fulfilment is internal (the warehouse worker driving an Order from Paid to Shipped). Support owns cancellation and refund.

Class diagram#

┌────────────────────────┐ ┌────────────────────────┐
│ Catalog │ │ Customer │
├────────────────────────┤ ├────────────────────────┤
│ findById(id) │ │ id, name, email │
│ search(query) // seam │ │ addresses : List │
└──────────┬─────────────┘ │ paymentMethods : List │
│ 1..* │ cart : Cart │
▼ └────────────────────────┘
┌────────────────────────┐
│ Product │
├────────────────────────┤ ┌────────────────────────┐
│ id, title, price │ │ Cart │
│ stock │ ├────────────────────────┤
│ reserve(qty) │ │ items : Map<Product,Qty>│
│ release(qty) │ │ add / remove / update │
│ ship(qty) │ │ subtotal() │
└────────────────────────┘ └──────────┬─────────────┘
│ checkout()
┌────────────────────────┐ ┌────────────────────────┐
│ Order │ │ CheckoutService │
├────────────────────────┤ ├────────────────────────┤
│ id, customer, items │◀────────│ place(cart, address, │
│ status : OrderStatus │ │ payment) │
│ payment, shipment │ │ // Command sequence: │
│ total, address │ │ LockStock, │
│ place / cancel / ... │ │ CapturePayment, │
└────────┬───────────────┘ │ CreateShipment │
│ └────────────────────────┘
│ 1
┌────────────────────────┐ ┌────────────────────────┐
│ Shipment │ │ PaymentStrategy │◁── Card, UPI,
├────────────────────────┤ ├────────────────────────┤ GiftCard
│ id, items, address │ │ pay(amount) : Receipt │
│ status : ShipState │ │ refund(receipt) │
│ tracking │ └────────────────────────┘
└────────────────────────┘

Three patterns are doing the load-bearing work:

  • State pattern on Order.status and Shipment.status. They are linked but not coupled — Shipment.Shipped causes Order.Shipped via an observer, not a direct call.
  • Command pattern on the checkout pipeline. LockStock, CapturePayment, CreateShipment are commands with execute() + compensate(). The pipeline is the saga.
  • Strategy pattern on PaymentStrategy, and (out-of-scope but seamed) PricingPolicy.

What is deliberately not in the diagram:

  • No OrderManager god class. Checkout is a sequence of commands; once placed, the order owns its transitions.
  • No Inventory aggregate. Stock lives on Product for this round. (Real Amazon would have a multi-warehouse InventoryService; that’s the cut.)
  • No Promotion, Review, Recommendation. Cut.

Sequence diagram (key flows)#

Checkout, as a command saga:

Customer Cart Checkout Product Payment Shipment Order
│ checkout │ │ │ │ │ │
│─────────►│ │ │ │ │ │
│ │ place(cart,address,pay) │ │ │ │
│ │───────────►│ │ │ │ │
│ │ │ LockStock.execute() │ │ │
│ │ │─────────────►│ │ │ │
│ │ │ reserve(qty) per item │ │ │
│ │ │ │ │ │ │
│ │ │ CapturePayment.execute() │ │ │
│ │ │─────────────────────────────►│ │ │
│ │ │ receipt │ │
│ │ │◄─────────────────────────────│ │ │
│ │ │ CreateShipment.execute() │ │ │
│ │ │────────────────────────────────────────────►│ │
│ │ │ shipmentId │ │
│ │ │◄────────────────────────────────────────────│ │
│ │ │ new Order(PLACED) ──────────────────────────────────────────►│
│ │ │ mark(PAID) │ │ │
│ │ │ clear() │ │ │
│ │ clear() │ │ │ │
│ │◄───────────│ │ │ │
│ Order │ │ │ │ │
│◄─────────│ │ │ │ │

Failure path — payment declines after stock is locked:

Checkout Product Payment Shipment Order
│ LockStock.execute() │ │ │
│───────────────────► │ │ │
│ CapturePayment.execute() │ │
│ ─────────────────────────────────► │ │
│ PaymentFailed │ │
│ ◄───────────────────────────────── │ │
│ LockStock.compensate() │ │
│───────────────────► │ │ │
│ release(qty) per item │ │
│ │
│ (no order persisted; checkout returns failure) │

The saga is short — three commands — and the compensation is mechanical because each command knows how to undo itself.

Activity diagram (for non-trivial state)#

The Order lifecycle is the biggest single artefact in this round:

┌─────────┐
│ start │
└────┬────┘
┌────────────┐
┌───────►│ Placed │── payment captured ─────►┌────────────┐
│ └─────┬──────┘ │ Paid │
│ cancel │ └─────┬──────┘
│ (pre-pay) │ │ picked
│ ▼ ▼
│ ┌────────────┐ ┌────────────┐
│ │ Cancelled │◄── cancel ───────────────│ Picked │
│ └────────────┘ └─────┬──────┘
│ │ packed
│ ▼
│ ┌────────────┐
│ │ Packed │
│ └─────┬──────┘
│ │ shipped
│ ▼
│ ┌────────────┐
│ │ Shipped │── delivered ──►┌──────────┐
│ └────────────┘ │Delivered │
│ └─────┬────┘
│ │ return
│ ▼
│ ┌──────────┐
└─── (re-order — new Order) ─────────────────────────────────────────────────│ Returned │
└──────────┘

Cancelled is reachable from Placed/Paid only — once picked, it becomes a Returned flow. Returned is terminal. Delivered → Returned requires the customer to initiate the return (out-of-scope mechanics; the state exists).

Java implementation#

A representative slice. The checkout saga, the order state machine, and the cart are shown; payment and shipment are sketched.

public enum OrderStatus { PLACED, PAID, PICKED, PACKED, SHIPPED, DELIVERED, CANCELLED, RETURNED }
public enum ShipmentStatus { PENDING, IN_TRANSIT, DELIVERED, FAILED }
public final class Product {
private final String id;
private final String title;
private final Money price;
private int stock;
public Product(String id, String title, Money price, int stock) {
this.id = id; this.title = title; this.price = price; this.stock = stock;
}
public synchronized void reserve(int qty) {
if (qty > stock) throw new OutOfStockException(id);
stock -= qty;
}
public synchronized void release(int qty) { stock += qty; }
public String id() { return id; }
public Money price() { return price; }
}
public final class Cart {
private final String customerId;
private final Map<Product, Integer> items = new LinkedHashMap<>();
public Cart(String customerId) { this.customerId = customerId; }
public void add(Product p, int qty) { items.merge(p, qty, Integer::sum); }
public void remove(Product p) { items.remove(p); }
public void update(Product p, int qty) {
if (qty == 0) items.remove(p); else items.put(p, qty);
}
public void clear() { items.clear(); }
public Money subtotal() {
return items.entrySet().stream()
.map(e -> e.getKey().price().times(e.getValue()))
.reduce(Money.zero(), Money::plus);
}
public Map<Product, Integer> items() { return Collections.unmodifiableMap(items); }
}
public interface CheckoutStep {
void execute(CheckoutContext ctx);
void compensate(CheckoutContext ctx);
}
public final class CheckoutContext {
public final Cart cart;
public final Address address;
public final PaymentStrategy payment;
public Receipt receipt;
public String shipmentId;
public CheckoutContext(Cart c, Address a, PaymentStrategy p) {
this.cart = c; this.address = a; this.payment = p;
}
}
public final class LockStockStep implements CheckoutStep {
public void execute(CheckoutContext ctx) {
// Order matters: rely on a deterministic key ordering to avoid deadlocks
// when two checkouts touch overlapping products.
ctx.cart.items().forEach((p, q) -> p.reserve(q));
}
public void compensate(CheckoutContext ctx) {
ctx.cart.items().forEach((p, q) -> p.release(q));
}
}
public final class CapturePaymentStep implements CheckoutStep {
public void execute(CheckoutContext ctx) {
ctx.receipt = ctx.payment.pay(ctx.cart.subtotal());
}
public void compensate(CheckoutContext ctx) {
if (ctx.receipt != null) ctx.payment.refund(ctx.receipt);
}
}
public final class CreateShipmentStep implements CheckoutStep {
private final ShipmentService shipping;
public CreateShipmentStep(ShipmentService s) { this.shipping = s; }
public void execute(CheckoutContext ctx) {
ctx.shipmentId = shipping.create(ctx.cart.items(), ctx.address);
}
public void compensate(CheckoutContext ctx) {
if (ctx.shipmentId != null) shipping.cancel(ctx.shipmentId);
}
}
public final class CheckoutService {
private final List<CheckoutStep> pipeline;
private final OrderRepository orders;
public CheckoutService(List<CheckoutStep> pipeline, OrderRepository orders) {
this.pipeline = pipeline; this.orders = orders;
}
public Order place(Customer c, Cart cart, Address addr, PaymentStrategy pay) {
CheckoutContext ctx = new CheckoutContext(cart, addr, pay);
List<CheckoutStep> executed = new ArrayList<>();
try {
for (CheckoutStep s : pipeline) {
s.execute(ctx);
executed.add(s);
}
} catch (RuntimeException e) {
Collections.reverse(executed);
for (CheckoutStep s : executed) {
try { s.compensate(ctx); } catch (RuntimeException suppressed) { /* log */ }
}
throw e;
}
Order o = new Order(UUID.randomUUID().toString(), c, cart.items(), addr,
ctx.receipt, ctx.shipmentId, cart.subtotal());
o.markPaid();
orders.save(o);
cart.clear();
return o;
}
}
public final class Order {
private final String id;
private final Customer customer;
private final Map<Product, Integer> lines;
private final Address shipTo;
private final Receipt receipt;
private final String shipmentId;
private final Money total;
private OrderStatus status = OrderStatus.PLACED;
public Order(String id, Customer c, Map<Product, Integer> lines, Address a,
Receipt r, String shipmentId, Money total) {
this.id = id; this.customer = c;
this.lines = Map.copyOf(lines); this.shipTo = a;
this.receipt = r; this.shipmentId = shipmentId; this.total = total;
}
public void markPaid() { advance(OrderStatus.PLACED, OrderStatus.PAID); }
public void markPicked() { advance(OrderStatus.PAID, OrderStatus.PICKED); }
public void markPacked() { advance(OrderStatus.PICKED, OrderStatus.PACKED); }
public void markShipped() { advance(OrderStatus.PACKED, OrderStatus.SHIPPED); }
public void markDelivered() { advance(OrderStatus.SHIPPED, OrderStatus.DELIVERED); }
public void markReturned() { advance(OrderStatus.DELIVERED, OrderStatus.RETURNED); }
public void cancel() {
if (status != OrderStatus.PLACED && status != OrderStatus.PAID) {
throw new IllegalStateException("Cannot cancel from " + status);
}
// release stock; refund will be handled by the support workflow
lines.forEach((p, q) -> p.release(q));
status = OrderStatus.CANCELLED;
}
private void advance(OrderStatus from, OrderStatus to) {
if (status != from) throw new IllegalStateException("Expected " + from + ", was " + status);
status = to;
}
public OrderStatus status() { return status; }
public Money total() { return total; }
}

Notes the interviewer will look for:

  • Saga, not transaction. Checkout is three commands with explicit compensate() — there is no single database transaction spanning stock, payment, and shipment.
  • Order ownership of its state machine. advance(from, to) keeps illegal transitions impossible at the call site.
  • Money is its own type. Subtotal, tax, refunds — none are double.
  • Cart and Order are different aggregates. A cart is mutable and pre-checkout; an order is immutable line-items with a status. Conflating them is the regression.
  • Compensation runs in reverse order. The executed list captures only the steps that completed; rollback walks it from the end.

Trade-offs and extensions#

This section doubles as the what-I-cut list — repeat it out loud at the end of the round.

DecisionWhyCost / extension shape
Stock lives on ProductOne number per SKU; no warehouse routing in scope.A real Amazon adds an InventoryService over Warehouses with reservation tokens. Same reserve/release API at the seam.
CatalogService.search is a seam, not an implementationSearch is an HLD problem.Plug in a search service; the OOD does not change.
PaymentStrategy with three concretesStrategy at the right altitude.Add UPI / wallet / installments without editing CheckoutService.
Checkout saga in-processNo distributed-coordination requirement was given.At Amazon scale, the saga is durable (Step Functions or equivalent); each command becomes a message. The shape stays.
Order immutable linesOnce placed, line-items don’t change.Returns / partial returns add a separate ReturnRequest aggregate.
No promotions / coupons / gift wrapCut.PricingPolicy strategy seam on the cart; chain of decorators.
No reviews / Q&ACut.Separate aggregates with their own repositories.
No recommendationsCut.RecommendationService is a seam consumed by the product page renderer.
No fraud checkCut.A FraudCheckStep slots into the checkout pipeline; same execute/compensate shape.
No multi-currencyCut.Money carries currency; PricingPolicy returns money in the customer’s currency.
No notificationsCut.Observer on Order state transitions; channel adapters (email, SMS, push) subscribe.

Likely follow-up extensions:

  • Subscriptions (“Subscribe & Save”). A Subscription aggregate above Order that schedules a recurring place(cart) call. Same saga.
  • Split shipments. One Order to many Shipments. Shipment becomes a 1..n with a per-shipment status.
  • Gift orders. Order gains an optional gifting : GiftOptions; nothing else moves.
  • Marketplace sellers. Product gains a seller; cart can contain items from multiple sellers; Order either fans out into per-seller orders or stays single with per-line shipment tracking.

Mock interview follow-ups#

  • “Why is checkout a saga and not a transaction?” — Stock is local state, payment is a third party, shipment is a downstream system. No single transactional boundary spans all three. The compensating actions are well-defined per step, so a saga is the natural shape.
  • “Two customers buy the last unit.”Product.reserve is synchronized; the loser sees OutOfStockException, LockStock fails, the saga returns failure. No order is created.
  • “What’s the state machine on Order?” — Show the activity diagram. Emphasise Cancelled is reachable from Placed/Paid only; once Picked, the path forward is Returned.
  • “Where would observers fit?” — On Order state transitions. Notifications, audit, analytics, and downstream warehouse picking can all subscribe without Order knowing.
  • “How would you persist this?” — One repository per aggregate root: OrderRepository, CartRepository, ProductRepository. The saga’s commands become idempotent operations against those repositories; the saga itself is durable.
  • “Add coupons. Where?”PricingPolicy decorator chain: BasePrice → CouponDiscount → MembershipDiscount. Cart calls PricingPolicy.priceFor(items). No change to CheckoutService.
  • “You haven’t talked about search.” — Correct. It’s an HLD round. CatalogService.search is the seam; the implementation is sharded, indexed, and ranked, and that conversation deserves its own hour.
  • “What did you cut and why?” — Recite the list. Decisive cuts are the highest-scoring move in this prompt.
  • Parking Lot — the canonical OOD opener; same Money and Strategy discipline at a fraction of the scope.
  • Hotel Management System — the closest sibling: multi-aggregate transaction with compensation.
  • State PatternOrder and Shipment are textbook state machines.
  • Strategy PatternPaymentStrategy and PricingPolicy are clean strategies.
  • Command Pattern — the checkout saga is the command pattern at saga altitude.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.