The 100 problems, solved / Commerce and money / 083 · Design an online shopping system

Design an online shopping system

Catalogue, cart, order and payment with discounts and inventory checks.

Concepts this design leans on

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, FixedOff and BuyNGetOne implement 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.
ShoppingCheckoutRules and rails***productsCatalogueproducts: dictsearch(text, category)Cartlines: dictdiscounts: listadd(product, qty)remove(product_id)total()CartLineproduct: Productqty: intCheckoutServiceinventory: Inventoryorders: dictcheckout(cart, payment)cancel(order_id)Orderid: strlines: tupletotal: intstate: OrderStatemove(to)Inventorystock: dictlock: Lockreserve(lines)release(lines)Discountapply(lines, subtotal)PaymentMethodcharge(amount)refund(ref)

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

  1. The shopper's Cart holds lines and discounts; total() applies them.
  2. checkout(cart, payment) reserves every line under the inventory lock.
  3. It charges the total; on failure it releases the reservation and re-raises.
  4. It creates an Order from a frozen copy of the lines, moves it to PAID and returns it.
  5. cancel refunds, 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.reserve checks and deducts under one lock, so the second raises OutOfStock before 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 DiscountPolicy that chooses which discounts apply. It lives next to Cart.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.lines is a tuple of fresh CartLine objects.

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