Design an online shopping system
Catalogue, cart, order and payment with discounts and inventory checks.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Relationships and multiplicity — Association, aggregation, composition, and the numbers on the lines.
- Single responsibility — One reason to change per class.
- Abstract factory — A family of related objects created together.
- Facade — One simple front door to a complicated subsystem.
01The design, in the six-step framework
1. Requirements and use cases
An online shop at the class level is a catalogue, a cart, an order pipeline and a payment step. The breadth is the test: keep each class to one job and the flow readable.
- A shopper searches the catalogue by name and category and sees price and stock.
- A shopper adds, updates and removes cart lines; the cart totals itself.
- A shopper applies discounts (percentage, fixed, buy-two-get-one) and sees the effect.
- A shopper checks out: stock is reserved, payment is taken, an order is created with a state.
- A shopper cancels before shipment and gets stock released and payment refunded.
Out of scope: reviews, recommendations, shipping carriers and persistence. Non-functional constraints: oversell must be impossible when two shoppers buy the last unit; a new discount or payment method must not touch checkout; an order's state transitions are explicit.
2. Core entities and relationships
- Product: immutable id, name, category and price.
- Catalogue: products by id with simple search.
- Inventory: stock per product id with atomic reserve and release.
- Cart and CartLine: product, quantity, and the cart's subtotal.
- Discount: an interface;
PercentOff,FixedOffandBuyNGetOneimplement it. - Order and OrderState: a snapshot of lines and prices with a state machine.
- PaymentMethod: an interface; the shop never sees card details.
- CheckoutService: the facade that runs reserve, pay, create, in that order with compensation.
The cart composes its lines; an order composes an immutable copy of them, so later price changes in the catalogue cannot alter a placed order. Discounts and payment methods are injected, never constructed in the shop.
3. Interfaces and key methods
class Discount(Protocol):
def apply(self, lines: list[CartLine], subtotal: int) -> int:
"""Return the amount to subtract, never more than subtotal."""
class PaymentMethod(Protocol):
def charge(self, amount: int) -> str: ... # returns a reference; raises PaymentFailed
def refund(self, ref: str) -> None: ...
class Inventory:
def reserve(self, lines: list[CartLine]) -> None:
"""All-or-nothing under one lock; raises OutOfStock naming the product."""
def release(self, lines: list[CartLine]) -> None: ...
class CheckoutService:
def checkout(self, cart: Cart, payment: PaymentMethod) -> Order:
"""reserve -> charge -> create order; on charge failure release stock."""
def cancel(self, order_id: str, payment: PaymentMethod) -> Order: ...
class Order:
def move(self, to: OrderState) -> None: ... # raises IllegalTransition
Invariants: stock never goes negative; an order in PAID or later has a payment reference; a cancelled order has released its stock exactly once; the order total equals subtotal minus discounts at the time of checkout.
4. Design patterns and the choices behind them
One job per class
The temptation is a Shop class that searches, holds carts, charges and ships. Single responsibility splits it so that pricing changes touch Discount, stock races touch Inventory and the flow touches CheckoutService alone. Interviewers judge breadth questions on whether the class list reads like the use cases.
Discounts as strategies with a stacking rule
Each discount is a strategy that computes its own amount from the lines; the cart sums them with a cap at the subtotal. A rejected alternative is a discount_type field with an if-chain in total(), which makes every promotion a change to the cart.
Payment behind a factory-made interface
Card, wallet and cash-on-delivery differ completely in mechanics. An abstract factory creates the matching PaymentMethod (and, in a fuller design, its receipt formatter) from a method name, so CheckoutService depends only on the interface.
Checkout as a facade with compensation
The one place that knows the order of operations is the facade: reserve stock, charge, create the order. If the charge fails, release the stock; if order creation fails after a charge, refund. Writing the compensation next to the step is what keeps the multiplicities honest: one order, one payment, one reservation.
5. Core flow and code
- The shopper's
Cartholds lines and discounts;total()applies them. checkout(cart, payment)reserves every line under the inventory lock.- It charges the total; on failure it releases the reservation and re-raises.
- It creates an
Orderfrom a frozen copy of the lines, moves it to PAID and returns it. cancelrefunds, releases stock and moves the order to CANCELLED, only from PAID.
from __future__ import annotations
import itertools, threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Protocol
class OutOfStock(Exception): ...
class PaymentFailed(Exception): ...
class IllegalTransition(Exception): ...
@dataclass(frozen=True)
class Product:
id: str
name: str
category: str
price: int
@dataclass
class CartLine:
product: Product
qty: int
class Catalogue:
def __init__(self, products: list[Product]) -> None:
self.products = {p.id: p for p in products}
def search(self, text: str = "", category: str = "") -> list[Product]:
return [p for p in self.products.values()
if text.lower() in p.name.lower() and (not category or p.category == category)]
class Discount(Protocol):
def apply(self, lines: list[CartLine], subtotal: int) -> int: ...
@dataclass
class PercentOff:
percent: int
def apply(self, lines: list[CartLine], subtotal: int) -> int:
return subtotal * self.percent // 100
@dataclass
class BuyNGetOne:
product_id: str
n: int
def apply(self, lines: list[CartLine], subtotal: int) -> int:
for line in lines:
if line.product.id == self.product_id:
return (line.qty // (self.n + 1)) * line.product.price
return 0
class Cart:
def __init__(self) -> None:
self.lines: dict[str, CartLine] = {}
self.discounts: list[Discount] = []
def add(self, product: Product, qty: int) -> None:
line = self.lines.setdefault(product.id, CartLine(product, 0))
line.qty += qty
def remove(self, product_id: str) -> None:
self.lines.pop(product_id, None)
def subtotal(self) -> int:
return sum(l.product.price * l.qty for l in self.lines.values())
def total(self) -> int:
sub = self.subtotal()
off = sum(d.apply(list(self.lines.values()), sub) for d in self.discounts)
return max(0, sub - off)
class Inventory:
def __init__(self, stock: dict[str, int]) -> None:
self.stock, self.lock = dict(stock), threading.Lock()
def reserve(self, lines: list[CartLine]) -> None:
with self.lock:
for l in lines:
if self.stock.get(l.product.id, 0) < l.qty:
raise OutOfStock(l.product.name)
for l in lines:
self.stock[l.product.id] -= l.qty
def release(self, lines: list[CartLine]) -> None:
with self.lock:
for l in lines:
self.stock[l.product.id] += l.qty
class OrderState(Enum):
CREATED = "created"; PAID = "paid"; SHIPPED = "shipped"; CANCELLED = "cancelled"
ALLOWED = {OrderState.CREATED: {OrderState.PAID}, OrderState.PAID: {OrderState.SHIPPED, OrderState.CANCELLED},
OrderState.SHIPPED: set(), OrderState.CANCELLED: set()}
@dataclass
class Order:
id: str
lines: tuple[CartLine, ...]
total: int
payment_ref: str = ""
state: OrderState = OrderState.CREATED
def move(self, to: OrderState) -> None:
if to not in ALLOWED[self.state]:
raise IllegalTransition(f"{self.state.value} -> {to.value}")
self.state = to
class PaymentMethod(Protocol):
def charge(self, amount: int) -> str: ...
def refund(self, ref: str) -> None: ...
class CheckoutService:
def __init__(self, inventory: Inventory) -> None:
self.inventory = inventory
self.orders: dict[str, Order] = {}
self._ids = itertools.count(1)
def checkout(self, cart: Cart, payment: PaymentMethod) -> Order:
lines = [CartLine(l.product, l.qty) for l in cart.lines.values()]
self.inventory.reserve(lines)
try:
ref = payment.charge(cart.total())
except PaymentFailed:
self.inventory.release(lines)
raise
order = Order(f"ord{next(self._ids)}", tuple(lines), cart.total(), ref)
order.move(OrderState.PAID)
self.orders[order.id] = order
return order
def cancel(self, order_id: str, payment: PaymentMethod) -> Order:
order = self.orders[order_id]
order.move(OrderState.CANCELLED) # raises if already shipped
payment.refund(order.payment_ref)
self.inventory.release(list(order.lines))
return order
6. Extensions, concurrency and what interviewers probe
- The last unit: two checkouts race;
Inventory.reservechecks and deducts under one lock, so the second raisesOutOfStockbefore any payment. Say where the critical section is before being asked. - Stacking rules: today discounts add up and are capped; a real shop needs exclusive promotions, which is a
DiscountPolicythat chooses which discounts apply. It lives next toCart.total, not inside each discount. - Cancel after shipment is refused by the state table; a return flow is a new state pair, not an exception to cancel.
- Payment timeout: unknown whether the charge happened. The honest answer is an order in CREATED with the reservation held and a reconciliation step; do not release stock on a timeout.
- Test first: a failed charge leaves stock exactly as before; buy-two-get-one on quantity five discounts one unit; cancel of a SHIPPED order raises and refunds nothing.
- Scope control: the breadth invites forty classes. Name the five that matter (catalogue, cart, inventory, checkout, order), put payment and discounts behind interfaces, and say that reviews, shipping and recommendations are separate services you would add as observers of the order. That is what interviewers mean by clean abstractions here.
- Common mistake: letting the order reference the live cart lines. The order must hold a copy with the prices at checkout time, which is why
Order.linesis a tuple of freshCartLineobjects.
02Where this question was reported
- Educative · Grokking LLD chapter 19
- DesignGurus · DesignGurus case study (Platforms and Feeds)
- dev.to · Educative top-10 #1: search, cart, checkout, payments, reviews
- Codemia · Codemia Medium; also 'E-commerce Cart and Checkout' (Hard)
- GitHub · awesome-low-level-design, Hard
- thita.ai · thita.ai 'Online Shopping Cart', advanced
- Hello Interview · Hello Interview breakdown (Easy, free)
- Educative · Grokking LLD chapter 10
- ByteByteGo · ByteByteGo OOD chapter 12
- ByteByteGo · ByteByteGo OOD chapter 9
- Educative · Educative 'Order Management System': State pattern for lifecycle