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 #
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
# 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)
# 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
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.
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.