System DesignAdvanced 17 min Lesson 40 of 42

Design an E-commerce System

The full path from browsing to payment: catalogue search, inventory reservation, checkout, idempotent payments and order state.

System Design · Lesson 40 of 42
0/42 done(0%)

What is it? #

An e-commerce system spans browsing, cart, checkout, payment, fulfilment and returns. Each part has different requirements.

Browsing is read-heavy and tolerates slightly stale data. Checkout is write-heavy and tolerates nothing: overselling, double charging and lost orders are all unacceptable.

That difference in tolerance drives the architecture. The catalogue can be cached aggressively; inventory and payment cannot.

The hardest problems are inventory under concurrency and payment correctness. Everything else is standard web application work.

Think of it like this #

A shop where the shelves, the till and the stockroom have different rules.

Customers can browse a slightly out-of-date price list without harm. But the till must not sell the last item twice, and it must never charge a card twice for one purchase.

Simple example #

A platform with 500,000 products, 5,000 concurrent shoppers, flash sales that spike traffic tenfold, and payments through an external gateway.

Code #

TEXT
1. Components and their characteristics

catalogue      read-heavy, cacheable, eventual consistency acceptable
search         separate index, updated asynchronously from the catalogue
cart           per-user, short-lived, high write rate, tolerable to lose
inventory      strongly consistent, the contention point
orders         durable, auditable, a state machine
payments       external, idempotent, never retried blindly
fulfilment     asynchronous, event-driven
PYTHON
# 2. Inventory: reserve, do not just decrement
def reserve_stock(sku: str, quantity: int, order_id: str, minutes: int = 15) -> bool:
    with db.transaction():
        # Row-level lock prevents two checkouts taking the last item
        row = db.query_for_update("SELECT available FROM inventory WHERE sku = %s", sku)
        if row.available < quantity:
            return False
        db.execute(
            "UPDATE inventory SET available = available - %s, reserved = reserved + %s "
            "WHERE sku = %s", (quantity, quantity, sku))
        db.execute(
            "INSERT INTO reservations (order_id, sku, quantity, expires_at) "
            "VALUES (%s, %s, %s, now() + interval '%s minutes')",
            (order_id, sku, quantity, minutes))
    return True


def release_expired_reservations():
    """Runs every minute. Abandoned carts must return stock to the pool."""
    for reservation in reservations.expired():
        with db.transaction():
            db.execute("UPDATE inventory SET available = available + %s, "
                       "reserved = reserved - %s WHERE sku = %s",
                       (reservation.quantity, reservation.quantity, reservation.sku))
            reservations.delete(reservation.id)
PYTHON
# 3. Checkout: idempotent from end to end
def checkout(user, cart, idempotency_key: str, payment_token: str):
    existing = orders.find_by_idempotency_key(idempotency_key)
    if existing:
        return existing                       # a retry, not a second order

    order = orders.create(user_id=user.id, lines=cart.lines,
                          total=price(cart), status="pending",
                          idempotency_key=idempotency_key)

    for line in cart.lines:
        if not reserve_stock(line.sku, line.quantity, order.id):
            release_all(order)
            orders.update(order.id, status="failed", reason="out_of_stock")
            raise OutOfStock(line.sku)

    try:
        payment = gateway.charge(
            amount=order.total, token=payment_token,
            idempotency_key=f"pay-{order.id}",     # the gateway deduplicates too
            timeout=20)
    except GatewayTimeout:
        orders.update(order.id, status="payment_unknown")   # never guess
        reconciliation.enqueue(order.id)                    # verify later
        raise PaymentPending()

    orders.update(order.id, status="paid", payment_id=payment.id)
    confirm_reservations(order.id)                          # reserved → sold
    events.publish("order.paid", {"order_id": order.id})
    return order
TEXT
4. Order state machine

pending ──▶ paid ──▶ packed ──▶ shipped ──▶ delivered
   │         │
   │         └──▶ refunded
   └──▶ failed / cancelled

payment_unknown is a real state. A gateway timeout does not mean failure;
the charge may have succeeded. Reconciliation resolves it, never a guess.
TEXT
5. Flash sales

problem   one popular SKU receives thousands of concurrent reservations
          → row lock contention serialises everything

options   queue purchase attempts and process them in order
          pre-allocate stock into per-shard buckets
          admission control: accept N attempts, reject the rest quickly
          show an honest "sold out" rather than failing at payment

How it works #

Reserving rather than decrementing is the key inventory decision. A customer at checkout holds stock for a limited window; if they abandon, it returns automatically. Decrementing at "add to cart" would lock stock indefinitely; decrementing at payment would allow overselling between check and charge.

The row lock is what prevents two concurrent checkouts both seeing one item available. It serialises access to that row, which is exactly the trade-off needed for correctness — and exactly what becomes a bottleneck during a flash sale.

Idempotency appears twice, deliberately. Your own key prevents a retried checkout creating two orders; the gateway's key prevents a retried charge billing twice. Both are needed because the failure can occur on either side.

payment_unknown is the state most designs omit. A timeout means you do not know the outcome. Assuming failure risks an uncharged order shipped; assuming success risks charging nothing and shipping. Recording the uncertainty and reconciling against the gateway later is the only correct answer.

Confirming reservations after payment moves stock from reserved to sold, closing the loop.

Publishing an event after payment lets fulfilment, notifications and analytics react without checkout knowing about them.

Real-world use #

Pricing is more complex than it looks: taxes by region, discounts, coupons, shipping rules and currency. The rule is to compute the price server-side at checkout and store what was charged, never to trust a price submitted by the client.

Search belongs in a dedicated engine, updated asynchronously from catalogue changes. Filtering and ranking across attributes is not what a relational database does best.

Carts are usually kept in a fast store with an expiry, and merged when a guest signs in. Losing a cart is annoying but not dangerous, which is why it can be stored differently from orders.

Payment webhooks are the authoritative record. Gateways send asynchronous confirmations, and treating those as truth, rather than the synchronous response, resolves most edge cases.

Returns and refunds are a full second lifecycle, with their own state machine, inventory effects and accounting. They are frequently underestimated at design time.

Common mistakes #

  • Decrementing stock without a reservation window, causing overselling or permanently locked stock.
  • No idempotency key, so a retried checkout charges twice.
  • Guessing the outcome of a payment timeout instead of reconciling.
  • Trusting a price sent by the client.
  • Ignoring reservation expiry, so abandoned carts hold stock forever.

Practice #

Design the refund flow for this system. Specify the order states involved, what happens to inventory, how you avoid double refunds, and how the customer is notified. Then describe your approach to a flash sale on a single SKU with 100 units and 50,000 interested buyers.

Quick quiz

  1. 1. Why reserve stock rather than decrement it at checkout?

  2. 2. Why are two idempotency keys used?

  3. 3. What should happen on a payment gateway timeout?

  4. 4. Where should the final price be calculated?

  5. 5. What makes a flash sale difficult?

Summary

  • Browsing tolerates staleness; inventory and payment do not.
  • Reserve stock with an expiry rather than decrementing.
  • Use idempotency keys on both your order creation and the gateway charge.
  • Model payment_unknown explicitly and reconcile, never guess.
  • Compute prices server-side and store what was actually charged.