Design Amazon (cart, order, inventory, payment)¶
TL;DR¶
- You build the checkout core: a catalog you can filter, a cart with no prices in it, stock that is reserved, then committed or released, and an order that walks a state machine.
- Four decisions carry the interview: one lock per SKU taken in sorted order so a basket is all-or-nothing and cannot deadlock, a TTL on every hold so abandoned carts give stock back, an idempotency key with in-progress and completed states, and compensate across services, roll back inside one.
- Patterns that earn their place: Facade (
CheckoutFacade), State (the order table), Strategy (discount, tax, shipping), Event Bus plus Observer, Repository and Unit of Work, Specification (catalog search), Null Object (NoDiscount).
Problem statement¶
"Design the order and inventory core of a large online store. Customers browse a catalog, add items to a cart, and check out with an address and a card. You must never sell the same unit twice, even in a flash sale where thousands want the last dozen items. The order then moves through paid, packed, shipped and delivered, and can be cancelled or returned. Focus on the classes, the checkout flow, and what happens when two buyers race for the last unit or the same request arrives twice."
Requirements¶
Functional
- Catalog of products, SKUs and categories, searchable by composable filters (in stock, under a price, by attribute).
- Cart: add, update quantity, remove; a guest cart merges into the customer's cart at sign-in.
- Inventory per SKU per warehouse, with
availableandreservedcounters, reserve, commit, release and restock. - Checkout: price the basket (discount, tax, shipping), hold the stock, create the order, then take payment.
- Order pricing through pluggable discount, tax and shipping rules.
- Order state machine: created, paid, packed, shipped, delivered, cancelled, returned.
- Low-stock alerts and restocking; order history per customer; notifications at each step.
Non-functional and constraints
- Never oversell: if twelve units exist, at most twelve orders can hold them, whatever the concurrency.
- A basket is all-or-nothing: a checkout that cannot get every line reserves nothing.
- A retried checkout request must not buy twice, and a concurrent duplicate must not race.
- Held stock must not be held forever: every hold has a deadline.
- In-memory and single-process; repositories are the seam where a database goes; time and IDs are injected.
Out of scope: search ranking and relevance, recommendations, the real payment gateway, warehouse picking optimisation, and cross-region replication.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Is checkout one call or two? | Two: checkout holds stock and creates the order, pay takes the money. A customer sits on the payment page for minutes, and that gap is where the TTL, the abandoned cart and the hold-expiry race live. |
| Do we decrement stock at checkout or at payment? | Neither: we move it from available to reserved at checkout and out of both at payment. Two counters, not one, is what makes cancellation and expiry exact. |
| What if a basket has five lines and one is short? | Nothing is reserved. All-or-nothing, because a partly reserved basket is a state nobody wants to write compensation for. |
| Can one SKU come from two warehouses? | Yes. Allocation walks warehouses in a fixed order, which is also where a real system would put distance and cost. |
| What stops a bot parking all the stock in a cart? | The hold TTL, swept by a background job. Fifteen minutes here; a flash sale would use two. |
| Does the price in the cart bind us? | No. The cart holds ids and quantities; the order snapshots prices at checkout. The client may pass the total it displayed and get a conflict rather than a surprise charge. |
| Should reserving stock be an event? | No: it must be synchronous and ordered with the payment. Low-stock alerts, notifications and shipment creation go on the bus, because those you can retry. |
Core entities and relationships¶
- Sku (frozen) is what you actually sell: a product plus variant attributes and a price. Product and Category organise them.
- Cart holds SKU ids and quantities for one customer or one guest — and no prices at all.
- InventoryItem is one SKU in one warehouse:
available,reserved, and aversioncounter. It is the row a SQLUPDATE ... WHERE version = ?would target. - StockHold is an all-or-nothing reservation across SKUs and warehouses, made of HoldLines, with
expires_atand aHoldStatus. - Order owns immutable OrderItem price snapshots, the address, the money components, the hold that backs it, and its idempotency key.
- InventoryService owns every stock row and the per-SKU locks; OrderService owns the order repository and every transition; CheckoutFacade sequences them and owns the idempotency store.
- EventBus carries
order.placed,order.paid,order.shippedandorder.cancelledto NotificationService, LowStockMonitor and ShipmentDispatcher; UnitOfWork groups repository writes; Specification composes catalog filters.
Multiplicities: product 1 → * SKUs, SKU 1 → * inventory rows (one per warehouse), cart 1 → * lines, order 1 → * order items, order 1 → 1 hold, hold 1 → * hold lines, order 1 → 0..1 payment and 1 → 0..1 shipment.
Class diagram¶
Domain: the catalog, the cart, and the two numbers that matter.
classDiagram
class Category {
+id: str
+parent_id: str
}
class Product {
+id: str
+title: str
+category_id: str
}
class Sku {
+id: str
+price: Money
+attributes: tuple
+label() str
}
class Warehouse {
+id: str
+region: str
}
class Cart {
+customer_id: str
+add(sku_id, quantity)
+set_quantity(sku_id, quantity)
+merge_from(guest)
}
class Address {
+city: str
+region: str
}
class InventoryItem {
+sku_id: str
+available: int
+reserved: int
+version: int
+hold(quantity, expected_version)
+commit(quantity)
+release(quantity)
}
class HoldLine {
+sku_id: str
+warehouse_id: str
+quantity: int
}
class StockHold {
+id: str
+owner: str
+expires_at: float
+status: HoldStatus
+is_live(now) bool
}
class OrderItem {
+sku_id: str
+unit_price: Money
+quantity: int
+line_total Money
}
class Order {
+id: str
+status: OrderStatus
+hold_id: str
+idempotency_key: str
+subtotal Money
+total Money
+can_move_to(target) bool
}
class Payment {
+amount: Money
+status: PaymentStatus
}
class Shipment {
+carrier: str
+tracking: str
}
class InventoryService {
+reserve(lines, owner) StockHold
+commit(hold_id) StockHold
+release(hold_id) StockHold
+expire_holds() List~StockHold~
+available(sku_id) int
}
Category "1" o-- "many" Product
Product "1" *-- "many" Sku
Sku "1" o-- "many" InventoryItem : one row per warehouse
InventoryItem --> Warehouse
Cart ..> Sku : ids only, no prices
InventoryService "1" *-- "many" InventoryItem
InventoryService "1" o-- "many" StockHold
StockHold "1" *-- "many" HoldLine
Order "1" *-- "many" OrderItem
Order --> "1" StockHold : backed by
Order --> Address
Order "1" o-- "0..1" Payment
Order "1" o-- "0..1" Shipment
Services and patterns: one facade, three owners of state, and the rules you swap.
classDiagram
class CheckoutFacade {
+checkout(cart_id, customer_id, ship_to, idempotency_key) Order
+pay(order_id) Payment
+cancel(order_id) Order
+accept_return(order_id) Order
+sweep_expired_holds() List~str~
}
class CheckoutRecord {
+key: str
+order_id: str
}
class CatalogService {
+search(spec, category_id) List~Sku~
+reprice(sku_id, price) Sku
}
class CartService {
+open(customer_id) Cart
+merge_guest_cart(guest_cart_id, customer_id) Cart
}
class OrderService {
+transition(order_id, target) Order
+history(customer_id) List~Order~
}
class InventoryService
class Repository {
<<interface>>
+add(entity)
+get(entity_id)
+all() List~Any~
}
class InMemoryRepository
class UnitOfWork {
+commit()
+rollback()
}
class EventBus {
+subscribe(topic, handler)
+publish(event) int
}
class NotificationService {
+inbox(customer_id) List~str~
}
class LowStockMonitor {
+alerts() List~str~
}
class ShipmentDispatcher {
+shipment_for(order_id) Shipment
}
class DiscountStrategy {
<<interface>>
+discount(items) Money
}
class NoDiscount
class PercentOff
class TaxCalculator {
<<interface>>
+tax(taxable, ship_to) Money
}
class ShippingStrategy {
<<interface>>
+cost(items, ship_to, speed) Money
}
class PaymentGateway {
<<interface>>
+authorize(amount, reference) bool
+capture(amount, reference) bool
}
class Specification {
<<abstract>>
+is_satisfied_by(sku, stock) bool
}
CheckoutFacade --> CatalogService
CheckoutFacade --> CartService
CheckoutFacade --> InventoryService
CheckoutFacade --> OrderService
CheckoutFacade --> UnitOfWork
CheckoutFacade --> EventBus
CheckoutFacade --> PaymentGateway
CheckoutFacade "1" o-- "many" CheckoutRecord
CheckoutFacade --> DiscountStrategy
CheckoutFacade --> TaxCalculator
CheckoutFacade --> ShippingStrategy
Repository <|.. InMemoryRepository
UnitOfWork o-- "many" InMemoryRepository
OrderService --> InMemoryRepository
CatalogService ..> Specification
DiscountStrategy <|.. NoDiscount
DiscountStrategy <|.. PercentOff
EventBus --> NotificationService : notifies
EventBus --> LowStockMonitor : notifies
EventBus --> ShipmentDispatcher : notifies
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Facade | CheckoutFacade |
Checkout touches five collaborators in a fixed order, and getting that order right is the answer. One class owns it; the API layer owns none of it. |
| State (as a table) | ORDER_TRANSITIONS plus OrderService.transition |
Seven statuses touched by customers, warehouse staff and a sweeper. One dict, one gate, and the state diagram below is a drawing of that dict. |
| Strategy | DiscountStrategy, TaxCalculator, ShippingStrategy |
The three rules that change per country, per campaign and per carrier. CheapestFreeInBundle shows why discount takes the lines and not the total: a bundle rule cannot be computed from one number. |
| Event Bus + Observer | EventBus with three subscribers |
Notifications, low-stock alerts and shipment creation are retryable and nobody is waiting on them, so they go on the bus. Reserving stock is neither, so it does not. That sentence is the whole test for "should this be an event?". |
| Repository + Unit of Work | InMemoryRepository, UnitOfWork |
The persistence seam, and why a declined card leaves no payment row. It wraps pay, where two repositories change together — wrapping the single write in checkout would be theatre. |
| Specification | InStock() & PriceBelow(...) |
Filters become named, testable objects that compose, instead of a search() signature that grows a keyword argument per release. |
| Null Object | NoDiscount, ZeroTax |
Pricing never writes if discount is None. The default is an object. |
| Dependency Injection | Clock, IdGenerator, gateway, strategies |
The tests advance a FakeClock by 61 seconds to expire a hold and use a declining gateway to prove the release path. No sleeps, no wall clock. |
What was deliberately not used: a Factory for orders (one construction path, so it adds a file and removes nothing) and Command for fulfilment (three one-line methods, and command objects buy undo you do not want — you cannot un-ship a parcel). Name both out loud: knowing which patterns to leave out is the same skill as knowing which to use.
Key flows¶
Checkout and payment: hold first, take money second, publish last.
sequenceDiagram
autonumber
actor U as Customer
participant F as CheckoutFacade
participant C as CatalogService
participant I as InventoryService
participant O as OrderService
participant G as PaymentGateway
participant B as EventBus
U->>F: checkout(cart, address, key)
F->>F: claim the idempotency key
F->>C: snapshot SKU prices into order items
F->>F: discount, tax, shipping, compare with the total shown
F->>I: reserve(lines, owner=key)
I->>I: lock every SKU in sorted order, plan, then write
I-->>F: StockHold(HELD) with a TTL
F->>O: write Order(CREATED)
F-)B: order.placed
B-)B: low-stock alert and customer notification
F-->>U: Order(CREATED), stock is yours for 15 minutes
U->>F: pay(order_id)
F->>G: authorize(total)
G-->>F: approved
F->>I: commit(hold_id)
F->>O: CREATED to PAID
F-)B: order.paid
B-)B: shipment created, customer notified
The three ways it goes wrong, and what each one gives back.
sequenceDiagram
autonumber
participant F as CheckoutFacade
participant I as InventoryService
participant O as OrderService
participant G as PaymentGateway
alt one line of the basket is short
F->>I: reserve(lines)
I-->>F: OutOfStockError, nothing written
F->>F: drop the idempotency record so a retry is allowed
else card declined at pay
F->>G: authorize(total)
G-->>F: declined
F->>F: unit of work rolls the payment row back
F->>I: release(hold_id) as a compensating call
F->>O: CREATED to CANCELLED
else customer took too long
F->>I: commit(hold_id)
I-->>F: HoldExpiredError, the sweeper already gave the units back
F->>I: release(hold_id), safe even when overdue
F->>O: CREATED to CANCELLED
end
Stock hold lifecycle. Only HELD counts against available, and only one caller can move a hold out of it.
stateDiagram-v2
[*] --> HELD : reserve moves units available to reserved
HELD --> COMMITTED : payment taken, units leave the warehouse
HELD --> RELEASED : declined, cancelled, or basket rejected
HELD --> EXPIRED : TTL elapsed, sweeper returns the units
COMMITTED --> [*]
RELEASED --> [*]
EXPIRED --> [*]
Order lifecycle. This is ORDER_TRANSITIONS drawn out; nothing else enforces it.
stateDiagram-v2
[*] --> CREATED : stock held, awaiting payment
CREATED --> PAID : card authorised, hold committed
CREATED --> CANCELLED : declined, expired, or abandoned
PAID --> PACKED : picked in the warehouse
PAID --> CANCELLED : cancelled before picking, units restocked
PACKED --> SHIPPED : handed to the carrier
PACKED --> CANCELLED : pulled from the line, units restocked
SHIPPED --> DELIVERED : signed for
DELIVERED --> RETURNED : returned, units restocked and refunded
CANCELLED --> [*]
RETURNED --> [*]
Implementation¶
Write it in the order the interviewer wants: vocabulary and the transition table, then the inventory row, then the service that locks it, then the facade that sequences everything. RESTOCK_ON_CANCEL_FROM sits beside the enums because "when do units come back" is a business rule, not a service detail:
class OrderStatus(StrEnum):
CREATED = "created" # stock is held, money is not taken yet
PAID = "paid" # stock committed, ready to pick
PACKED = "packed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
RETURNED = "returned"
class HoldStatus(StrEnum):
HELD = "held" # units moved from available to reserved, TTL running
COMMITTED = "committed" # units have left the warehouse
RELEASED = "released" # given back deliberately
EXPIRED = "expired" # given back by the sweeper
class PaymentStatus(StrEnum):
AUTHORIZED = "authorized"
CAPTURED = "captured"
VOIDED = "voided"
REFUNDED = "refunded"
class ShippingSpeed(StrEnum):
STANDARD = "standard"
EXPRESS = "express"
#: The order lifecycle in one dict. ``OrderService.transition`` is the only gate,
#: and the state diagram on the page is a drawing of exactly this table.
ORDER_TRANSITIONS: dict[OrderStatus, frozenset[OrderStatus]] = {
OrderStatus.CREATED: frozenset({OrderStatus.PAID, OrderStatus.CANCELLED}),
OrderStatus.PAID: frozenset({OrderStatus.PACKED, OrderStatus.CANCELLED}),
OrderStatus.PACKED: frozenset({OrderStatus.SHIPPED, OrderStatus.CANCELLED}),
OrderStatus.SHIPPED: frozenset({OrderStatus.DELIVERED}),
OrderStatus.DELIVERED: frozenset({OrderStatus.RETURNED}),
OrderStatus.CANCELLED: frozenset(),
OrderStatus.RETURNED: frozenset(),
}
#: Cancelling from these states must give the held or committed units back.
RESTOCK_ON_CANCEL_FROM = frozenset({OrderStatus.CREATED, OrderStatus.PAID, OrderStatus.PACKED})
class OutOfStockError(ConflictError):
"""Not enough units of a SKU to satisfy the request. The oversell guard."""
class HoldExpiredError(ConflictError):
"""The reservation TTL elapsed and the units went back on the shelf."""
class OrderStateError(InvalidStateError):
"""The order is not in a state that allows this transition."""
class PriceChangedError(ConflictError):
"""The basket total moved between the cart page and the checkout call."""
class CheckoutInProgressError(ConflictError):
"""Another request with the same idempotency key is still running."""
class PaymentDeclinedError(ConflictError):
"""The gateway refused the charge."""
class UnknownSkuError(NotFoundError):
"""No SKU with that id."""
class UnknownOrderError(NotFoundError):
"""No order with that id."""
The catalog is ordinary; the line to point at is that Cart stores ids and quantities and never a price.
@dataclass(frozen=True, slots=True)
class Category:
id: str
name: str
parent_id: str | None = None
@dataclass(frozen=True, slots=True)
class Product:
id: str
title: str
category_id: str
brand: str = ""
@dataclass(frozen=True, slots=True)
class Sku:
"""The unit you actually sell: one product plus its variant attributes."""
id: str
product_id: str
price: Money
attributes: tuple[tuple[str, str], ...] = ()
weight_grams: int = 500
def label(self) -> str:
return f"{self.id} ({', '.join(f'{k}={v}' for k, v in self.attributes)})" if self.attributes else self.id
@dataclass(frozen=True, slots=True)
class Warehouse:
id: str
name: str
region: str
@dataclass(frozen=True, slots=True)
class Address:
line1: str
city: str
postcode: str
country: str
region: str = ""
@dataclass(slots=True)
class Cart:
"""SKU ids and quantities. It holds no prices, which is the whole point."""
id: str
customer_id: str | None = None # None means a guest cart
lines: dict[str, int] = field(default_factory=dict)
def add(self, sku_id: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValidationError("quantity must be positive")
self.lines[sku_id] = self.lines.get(sku_id, 0) + quantity
def set_quantity(self, sku_id: str, quantity: int) -> None:
if quantity < 0:
raise ValidationError("quantity cannot be negative")
if quantity == 0:
self.lines.pop(sku_id, None)
else:
self.lines[sku_id] = quantity
def remove(self, sku_id: str) -> None:
self.lines.pop(sku_id, None)
def is_empty(self) -> bool:
return not self.lines
def merge_from(self, guest: Cart) -> None:
"""Sign-in merge: quantities add up, and the guest cart is left untouched."""
for sku_id, quantity in guest.lines.items():
self.lines[sku_id] = self.lines.get(sku_id, 0) + quantity
InventoryItem is where the interview is won or lost. Two counters instead of one, and a version that is the row version a database would check:
@dataclass(slots=True)
class InventoryItem:
"""One SKU in one warehouse: what is on the shelf and what is spoken for.
``available`` and ``reserved`` are the two numbers the whole problem turns
on, and ``version`` is the row version a SQL ``UPDATE ... WHERE version = ?``
would check. In this process the per-SKU lock plays that role, but the
counter is still here: it is how a caller that read the row earlier finds out
the row moved underneath it, and it is the exact field you would carry into
a database implementation.
"""
sku_id: str
warehouse_id: str
available: int = 0
reserved: int = 0
version: int = 0
def hold(self, quantity: int, expected_version: int | None = None) -> None:
self._check_version(expected_version)
if quantity <= 0:
raise ValidationError("quantity must be positive")
if quantity > self.available:
raise OutOfStockError(f"{self.sku_id}@{self.warehouse_id}: {self.available} left, wanted {quantity}")
self.available -= quantity
self.reserved += quantity
self.version += 1
def commit(self, quantity: int, expected_version: int | None = None) -> None:
"""The units left the building. They are gone from both counters."""
self._check_version(expected_version)
if quantity > self.reserved:
raise ConflictError(f"{self.sku_id}@{self.warehouse_id}: only {self.reserved} reserved")
self.reserved -= quantity
self.version += 1
def release(self, quantity: int, expected_version: int | None = None) -> None:
self._check_version(expected_version)
if quantity > self.reserved:
raise ConflictError(f"{self.sku_id}@{self.warehouse_id}: only {self.reserved} reserved")
self.reserved -= quantity
self.available += quantity
self.version += 1
def restock(self, quantity: int) -> None:
if quantity <= 0:
raise ValidationError("restock quantity must be positive")
self.available += quantity
self.version += 1
def _check_version(self, expected_version: int | None) -> None:
if expected_version is not None and expected_version != self.version:
raise ConflictError(
f"{self.sku_id}@{self.warehouse_id} moved: version {self.version}, expected {expected_version}"
)
@dataclass(frozen=True, slots=True)
class HoldLine:
sku_id: str
warehouse_id: str
quantity: int
@dataclass(slots=True)
class StockHold:
"""An all-or-nothing reservation across SKUs and warehouses, with a deadline."""
id: str
owner: str # the checkout key or order id that owns these units
lines: tuple[HoldLine, ...]
created_at: float
expires_at: float
status: HoldStatus = HoldStatus.HELD
def is_live(self, now: float) -> bool:
return self.status is HoldStatus.HELD and now < self.expires_at
def units(self) -> int:
return sum(line.quantity for line in self.lines)
InventoryService owns the locks. reserve plans every line first and writes only once all of them can be satisfied, holding every lock throughout.
class InventoryService:
"""The only object allowed to move a unit between available and reserved.
**Lock granularity.** One lock per SKU, created lazily. Not one global lock
(every buyer in the shop would queue behind the one popular item) and not one
lock per warehouse row (a two-SKU basket would then take an unbounded number
of locks in an order nobody has reasoned about). A basket spanning several
SKUs acquires their locks **in sorted id order**, so two baskets containing
the same pair of SKUs in opposite order still cannot deadlock.
**Why all-or-nothing works.** ``reserve`` holds every lock it needs for the
whole check-then-write. Either it finds enough of everything and writes all
the rows, or it has written nothing at all and raises. There is no window in
which a partly-reserved basket exists, so there is no compensation to run.
**The TTL.** A hold is a promise with a deadline. ``expire_holds`` puts the
units of abandoned checkouts back on the shelf, which is what stops a
flash-sale bot from parking your whole stock in a cart for an hour.
"""
HOLD_TTL_SECONDS = 900.0 # 15 minutes to finish a checkout
def __init__(
self,
warehouses: Sequence[Warehouse] = (),
clock: Clock | None = None,
ids: IdGenerator | None = None,
hold_ttl: float = HOLD_TTL_SECONDS,
) -> None:
self._warehouses = list(warehouses)
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("H")
self._ttl = hold_ttl
self._rows: dict[tuple[str, str], InventoryItem] = {}
self._sku_locks: dict[str, threading.Lock] = {}
self._registry_lock = threading.Lock() # guards _sku_locks and _rows membership
self._holds: dict[str, StockHold] = {}
self._holds_lock = threading.Lock()
# -- stock levels -------------------------------------------------------------
def add_stock(self, sku_id: str, warehouse_id: str, quantity: int) -> InventoryItem:
with self._locked([sku_id]):
row = self._row(sku_id, warehouse_id, create=True)
row.restock(quantity)
return row
def row(self, sku_id: str, warehouse_id: str) -> InventoryItem:
with self._locked([sku_id]):
return self._row(sku_id, warehouse_id)
def available(self, sku_id: str) -> int:
"""Free units across every warehouse. A read, so it takes only that SKU's lock."""
with self._locked([sku_id]):
return sum(r.available for r in self._rows_for(sku_id))
def reserved(self, sku_id: str) -> int:
with self._locked([sku_id]):
return sum(r.reserved for r in self._rows_for(sku_id))
def on_hand(self, sku_id: str) -> int:
"""available + reserved. This number only changes when goods physically move."""
with self._locked([sku_id]):
return sum(r.available + r.reserved for r in self._rows_for(sku_id))
# -- the reserve / commit / release cycle -------------------------------------
def reserve(self, lines: dict[str, int], owner: str) -> StockHold:
"""All-or-nothing hold across SKUs and warehouses. The oversell guard."""
if not lines:
raise ValidationError("nothing to reserve")
allocated: list[HoldLine] = []
with self._locked(lines):
for sku_id in sorted(lines):
allocated.extend(self._plan(sku_id, lines[sku_id])) # raises before any write
for line in allocated:
self._rows[(line.sku_id, line.warehouse_id)].hold(line.quantity)
now = self._clock.now()
hold = StockHold(self._ids.next_id(), owner, tuple(allocated), now, now + self._ttl)
with self._holds_lock:
self._holds[hold.id] = hold
return hold
def commit(self, hold_id: str) -> StockHold:
"""The parcel is packed: the units leave both counters for good.
This is the one operation the deadline blocks. A checkout that comes back
after its TTL must not take units the shop has already re-offered.
"""
return self._settle(hold_id, HoldStatus.COMMITTED)
def release(self, hold_id: str) -> StockHold:
"""Payment failed or the order was cancelled: the units go back on the shelf.
Giving units back is always safe, so an overdue hold can still be released
-- only committing it is refused.
"""
return self._settle(hold_id, HoldStatus.RELEASED)
def expire_holds(self) -> list[StockHold]:
"""Sweep abandoned checkouts. Run it from a timer, or before a flash sale."""
now = self._clock.now()
with self._holds_lock:
stale = [h for h in self._holds.values() if h.status is HoldStatus.HELD and now >= h.expires_at]
expired: list[StockHold] = []
for stale_hold in stale:
try:
expired.append(self._settle(stale_hold.id, HoldStatus.EXPIRED))
except HoldExpiredError:
continue # a checkout committed or released it while we were sweeping
return expired
def hold(self, hold_id: str) -> StockHold:
with self._holds_lock:
try:
return self._holds[hold_id]
except KeyError:
raise UnknownSkuError(f"no hold {hold_id}") from None
def low_stock(self, threshold: int) -> list[tuple[str, int]]:
"""SKUs at or below the reorder point, for the restock alert."""
with self._registry_lock:
sku_ids = {sku_id for sku_id, _ in self._rows}
low = [(sku_id, self.available(sku_id)) for sku_id in sorted(sku_ids)]
return [(sku_id, free) for sku_id, free in low if free <= threshold]
# -- internals ----------------------------------------------------------------
def _settle(self, hold_id: str, target: HoldStatus) -> StockHold:
"""Check-and-flip the hold, then move the counters. One winner per hold."""
hold = self.hold(hold_id)
with self._holds_lock:
if hold.status is not HoldStatus.HELD:
raise HoldExpiredError(f"hold {hold_id} is already {hold.status}")
if target is HoldStatus.COMMITTED and not hold.is_live(self._clock.now()):
raise HoldExpiredError(f"hold {hold_id} expired at {hold.expires_at}")
hold.status = target
with self._locked({line.sku_id for line in hold.lines}):
for line in hold.lines:
row = self._rows[(line.sku_id, line.warehouse_id)]
if target is HoldStatus.COMMITTED:
row.commit(line.quantity)
else:
row.release(line.quantity)
return hold
def _plan(self, sku_id: str, quantity: int) -> list[HoldLine]:
"""Split one SKU across warehouses. Raises before anything is written."""
remaining = quantity
plan: list[HoldLine] = []
for row in self._rows_for(sku_id):
if remaining == 0:
break
take = min(row.available, remaining)
if take:
plan.append(HoldLine(sku_id, row.warehouse_id, take))
remaining -= take
if remaining:
raise OutOfStockError(f"{sku_id}: short by {remaining} of {quantity}")
return plan
def _rows_for(self, sku_id: str) -> list[InventoryItem]:
with self._registry_lock:
keys = [k for k in self._rows if k[0] == sku_id]
return [self._rows[k] for k in sorted(keys)]
def _row(self, sku_id: str, warehouse_id: str, create: bool = False) -> InventoryItem:
key = (sku_id, warehouse_id)
with self._registry_lock:
row = self._rows.get(key)
if row is None:
if not create:
raise UnknownSkuError(f"no stock row for {sku_id} at {warehouse_id}")
row = self._rows.setdefault(key, InventoryItem(sku_id, warehouse_id))
return row
def _lock_for(self, sku_id: str) -> threading.Lock:
with self._registry_lock:
return self._sku_locks.setdefault(sku_id, threading.Lock())
@contextmanager
def _locked(self, sku_ids: Iterable[str]) -> Iterator[None]:
"""Take every SKU lock in sorted id order, so lock cycles are impossible."""
locks = [self._lock_for(sku_id) for sku_id in sorted(set(sku_ids))]
for lock in locks:
lock.acquire()
try:
yield
finally:
for lock in reversed(locks):
lock.release()
The unit of work is what makes a declined card leave nothing behind. Its docstring is deliberate about what it does not do:
class Repository(Protocol):
"""Collection-like access to one entity type. The only shape services depend on."""
def add(self, entity: Any) -> None: ...
def get(self, entity_id: str) -> Any: ...
def all(self) -> list[Any]: ...
class InMemoryRepository:
"""Dict-backed repository. Swap it for a SQL one without touching a service."""
def __init__(self, name: str) -> None:
self.name = name
self._items: dict[str, Any] = {}
self._lock = threading.Lock()
def add(self, entity: Any) -> None:
with self._lock:
self._items[entity.id] = entity
def remove(self, entity_id: str) -> None:
with self._lock:
self._items.pop(entity_id, None)
def get(self, entity_id: str) -> Any:
with self._lock:
try:
return self._items[entity_id]
except KeyError:
raise NotFoundError(f"no {self.name} {entity_id}") from None
def find(self, entity_id: str) -> Any | None:
with self._lock:
return self._items.get(entity_id)
def all(self) -> list[Any]:
with self._lock:
return list(self._items.values())
def snapshot(self) -> dict[str, Any]:
with self._lock:
return dict(self._items)
def restore(self, snapshot: dict[str, Any]) -> None:
with self._lock:
self._items = dict(snapshot)
class UnitOfWork:
"""Group writes across repositories so a failure leaves nothing half-written.
``with uow:`` takes a membership snapshot of every repository. Leaving the
block normally commits; leaving it with an exception restores every snapshot,
so a checkout that dies after writing the order but before taking payment
leaves no orphan order behind.
What it does *not* do is undo field-level edits to entities already in a
repository -- that needs a Memento, and saying so out loud is the honest
answer to "is this a real unit of work?". Inventory is deliberately outside
it: those units live in another service, so giving them back is a
compensating call, not a rollback. That distinction is the saga in miniature.
"""
def __init__(self, **repositories: InMemoryRepository) -> None:
self._repositories = repositories
self._snapshots: dict[str, dict[str, Any]] = {}
self._lock = threading.RLock()
for name, repository in repositories.items():
setattr(self, name, repository)
def __enter__(self) -> UnitOfWork:
self._lock.acquire()
self._snapshots = {name: repo.snapshot() for name, repo in self._repositories.items()}
return self
def __exit__(self, exc_type: type[BaseException] | None, *_: object) -> bool:
try:
if exc_type is not None:
self.rollback()
finally:
self._snapshots = {}
self._lock.release()
return False # never swallow the exception
def commit(self) -> None:
self._snapshots = {name: repo.snapshot() for name, repo in self._repositories.items()}
def rollback(self) -> None:
for name, snapshot in self._snapshots.items():
self._repositories[name].restore(snapshot)
Pricing rules are Strategies over the order lines, not over a total:
class DiscountStrategy(Protocol):
"""How much comes off the basket. Implementations are stateless."""
def discount(self, items: tuple[OrderItem, ...]) -> Money: ...
class NoDiscount:
"""Null Object: the default. Pricing never writes ``if discount is None``."""
def discount(self, items: tuple[OrderItem, ...]) -> Money:
return Money(0)
class PercentOff:
"""A capped percentage off the basket, above a minimum spend."""
def __init__(self, percent: Decimal, cap: Money, min_subtotal: Money | None = None) -> None:
if not 0 < percent < 1:
raise ValidationError("percent must be a fraction between 0 and 1")
self._percent = percent
self._cap = cap
self._min = min_subtotal or Money(0)
def discount(self, items: tuple[OrderItem, ...]) -> Money:
subtotal = total_of(items)
if subtotal < self._min:
return Money(0)
return min(subtotal * self._percent, self._cap)
class CheapestFreeInBundle:
"""Buy N of anything, the cheapest one is free. Needs the lines, not the total."""
def __init__(self, bundle_size: int = 3) -> None:
self._size = bundle_size
def discount(self, items: tuple[OrderItem, ...]) -> Money:
units = sorted(item.unit_price for item in items for _ in range(item.quantity))
free = len(units) // self._size
total = Money(0)
for price in units[:free]:
total = total + price
return total
class TaxCalculator(Protocol):
def tax(self, taxable: Money, ship_to: Address) -> Money: ...
class ZeroTax:
def tax(self, taxable: Money, ship_to: Address) -> Money:
return Money(0)
class RegionTax:
"""One rate per region, with a default. Regions are strings from the address."""
def __init__(self, rates: dict[str, Decimal], default: Decimal = Decimal("0.20")) -> None:
self._rates = dict(rates)
self._default = default
def tax(self, taxable: Money, ship_to: Address) -> Money:
return taxable * self._rates.get(ship_to.region, self._default)
class ShippingStrategy(Protocol):
def cost(self, items: tuple[OrderItem, ...], ship_to: Address, speed: ShippingSpeed) -> Money: ...
class WeightBandShipping:
"""Flat fee per speed, free over a threshold. The rule every store starts with."""
def __init__(
self,
standard: Money | None = None,
express: Money | None = None,
free_over: Money | None = None,
) -> None:
self._standard = standard or Money.of("4.99")
self._express = express or Money.of("12.99")
self._free_over = free_over or Money.of("50.00")
def cost(self, items: tuple[OrderItem, ...], ship_to: Address, speed: ShippingSpeed) -> Money:
if speed is ShippingSpeed.EXPRESS:
return self._express
return Money(0) if total_of(items) >= self._free_over else self._standard
class Specification(ABC):
"""A composable catalog predicate. ``InStock() & PriceBelow(...)`` reads as English.
The point is not the operators; it is that search filters become objects you
can name, unit-test and reuse in a query builder, instead of a growing pile
of keyword arguments on ``search()``.
"""
@abstractmethod
def is_satisfied_by(self, sku: Sku, stock: int) -> bool: ...
def __and__(self, other: Specification) -> Specification:
return AndSpecification(self, other)
def __or__(self, other: Specification) -> Specification:
return OrSpecification(self, other)
def __invert__(self) -> Specification:
return NotSpecification(self)
class AndSpecification(Specification):
def __init__(self, left: Specification, right: Specification) -> None:
self._left, self._right = left, right
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return self._left.is_satisfied_by(sku, stock) and self._right.is_satisfied_by(sku, stock)
class OrSpecification(Specification):
def __init__(self, left: Specification, right: Specification) -> None:
self._left, self._right = left, right
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return self._left.is_satisfied_by(sku, stock) or self._right.is_satisfied_by(sku, stock)
class NotSpecification(Specification):
def __init__(self, inner: Specification) -> None:
self._inner = inner
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return not self._inner.is_satisfied_by(sku, stock)
class InStock(Specification):
def __init__(self, minimum: int = 1) -> None:
self._minimum = minimum
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return stock >= self._minimum
class PriceBelow(Specification):
def __init__(self, ceiling: Money) -> None:
self._ceiling = ceiling
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return sku.price < self._ceiling
class HasAttribute(Specification):
def __init__(self, key: str, value: str) -> None:
self._pair = (key, value)
def is_satisfied_by(self, sku: Sku, stock: int) -> bool:
return self._pair in sku.attributes
The bus is dull on purpose, and its docstring names what is not allowed on it:
class EventBus:
"""Topic to handlers, dispatched synchronously with per-handler error isolation.
The lock guards the subscriber map and the failure log only. Handlers run
*outside* it, so a slow listener never stalls the checkout that published the
event, and a listener that raises is recorded rather than propagated -- an
order must not fail because a low-stock alert did.
What is deliberately **not** on this bus: reserving and committing stock.
Those must be synchronous and ordered with the payment, so they are direct
calls inside the checkout. The bus carries the effects you can afford to
retry later: restock alerts, notifications, shipment creation.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._handlers: dict[str, list[Handler]] = {}
self._failures: list[tuple[str, str]] = []
def subscribe(self, topic: str, handler: Handler) -> None:
with self._lock:
self._handlers.setdefault(topic, []).append(handler)
def publish(self, event: Event) -> int:
with self._lock:
handlers = list(self._handlers.get(event.topic, ()))
delivered = 0
for handler in handlers:
try:
handler(event)
delivered += 1
except Exception as exc: # isolation is the whole point of a bus
with self._lock:
self._failures.append((event.topic, str(exc)))
return delivered
def failures(self) -> list[tuple[str, str]]:
with self._lock:
return list(self._failures)
class LowStockMonitor:
"""Inventory's ear on the bus: every placed order re-checks the reorder point.
This is the part of inventory that *is* event-driven. Reserving stock has to
happen inside the checkout; noticing that a SKU dropped below its reorder
point does not, and running it here keeps the checkout path short.
"""
def __init__(self, bus: EventBus, inventory: InventoryService, threshold: int = 3) -> None:
self._bus = bus
self._inventory = inventory
self._threshold = threshold
self._lock = threading.Lock()
self._alerts: list[tuple[str, int]] = []
bus.subscribe(ORDER_PLACED, self._on_order_placed)
def _on_order_placed(self, event: Event) -> None:
for sku_id in event.payload.get("sku_ids", "").split(","):
if not sku_id:
continue
free = self._inventory.available(sku_id)
if free <= self._threshold:
with self._lock:
self._alerts.append((sku_id, free))
self._bus.publish(Event(STOCK_LOW, event.at, {"sku_id": sku_id, "available": str(free)}))
def alerts(self) -> list[tuple[str, int]]:
with self._lock:
return list(self._alerts)
class NotificationService:
"""One inbox per customer, filled by whatever the bus carries."""
TEMPLATES: dict[str, str] = {
ORDER_PLACED: "order {order_id} received, total {total}",
ORDER_PAID: "payment taken for {order_id}",
ORDER_SHIPPED: "{order_id} is on its way, tracking {tracking}",
ORDER_CANCELLED: "{order_id} was cancelled",
}
def __init__(self, bus: EventBus) -> None:
self._lock = threading.Lock()
self._inboxes: dict[str, list[str]] = {}
for topic in self.TEMPLATES:
bus.subscribe(topic, self._on_event)
def _on_event(self, event: Event) -> None:
customer = event.payload.get("customer_id")
if not customer:
return
message = self.TEMPLATES[event.topic].format_map(_Blank(event.payload))
with self._lock:
self._inboxes.setdefault(customer, []).append(message)
def inbox(self, customer_id: str) -> list[str]:
with self._lock:
return list(self._inboxes.get(customer_id, ()))
class _Blank(dict):
"""``format_map`` helper: a missing key renders as ``-`` instead of raising."""
def __missing__(self, key: str) -> str:
return "-"
The facade last. Every line of checkout and pay is about ordering and recovery:
class CheckoutFacade:
"""Turn a cart into a paid order, or leave nothing behind.
Checkout is deliberately **two calls**, because a real customer sits on the
payment page for minutes:
* ``checkout`` claims an idempotency key, prices the basket, reserves stock
all-or-nothing, and writes an order in ``CREATED``. The units are now
*held*, with a TTL running.
* ``pay`` authorises the card, commits the held units and moves the order to
``PAID`` -- or, if the customer took too long and the sweeper already gave
the units back, fails cleanly and cancels the order.
Two rules run through both. **Compensate, do not roll back, across services:**
stock lives in ``InventoryService``, so undoing a hold is a release call, not
a database rollback. **Publish after committing:** events go out once the
unit of work has closed, because publishing inside it is how stores send
"order received" emails for orders that never existed.
"""
def __init__(
self,
catalog: CatalogService,
carts: CartService,
inventory: InventoryService,
orders: OrderService,
bus: EventBus,
discount: DiscountStrategy | None = None,
tax: TaxCalculator | None = None,
shipping: ShippingStrategy | None = None,
gateway: PaymentGateway | None = None,
clock: Clock | None = None,
ids: IdGenerator | None = None,
) -> None:
self.catalog = catalog
self.carts = carts
self.inventory = inventory
self.orders = orders
self.bus = bus
self.payments = InMemoryRepository("payment")
self._discount = discount or NoDiscount()
self._tax = tax or RegionTax({})
self._shipping = shipping or WeightBandShipping()
self._gateway = gateway or ApprovingGateway()
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("PAY")
self._uow = UnitOfWork(orders=orders.repository, payments=self.payments)
self._checkouts: dict[str, CheckoutRecord] = {}
self._lock = threading.Lock()
def checkout(
self,
cart_id: str,
customer_id: str,
ship_to: Address,
idempotency_key: str,
speed: ShippingSpeed = ShippingSpeed.STANDARD,
expected_total: Money | None = None,
) -> Order:
"""Price, hold stock, and create the order. Safe to call twice with one key."""
replay = self._claim_key(idempotency_key)
if replay is not None:
return replay # the same request arrived twice; nothing is bought twice
hold = None
try:
items = snapshot_items(self.catalog, self.carts.cart(cart_id))
discount = self._discount.discount(items)
subtotal = Money(sum(item.line_total.cents for item in items))
tax = self._tax.tax(subtotal - discount, ship_to)
shipping = self._shipping.cost(items, ship_to, speed)
total = subtotal - discount + tax + shipping
if expected_total is not None and expected_total != total:
raise PriceChangedError(f"basket is now {total}, you were shown {expected_total}")
hold = self.inventory.reserve({i.sku_id: i.quantity for i in items}, owner=idempotency_key)
order = Order(
id=self.orders.next_id(),
customer_id=customer_id,
items=items,
ship_to=ship_to,
discount=discount,
tax=tax,
shipping=shipping,
hold_id=hold.id,
idempotency_key=idempotency_key,
placed_at=self._clock.now(),
)
self.orders.repository.add(order)
except Exception:
if hold is not None:
self._safe_release(hold.id) # compensating call, not a rollback
self._abandon_key(idempotency_key)
raise
self._complete_key(idempotency_key, order.id)
self._publish(ORDER_PLACED, order)
return order
def pay(self, order_id: str) -> Payment:
"""Authorise, commit the held units, and move to PAID -- all or none of it.
The unit of work earns its place here and not in ``checkout``: two
repositories change together, and a decline must leave neither behind.
"""
order = self.orders.order(order_id)
if order.status is not OrderStatus.CREATED:
raise OrderStateError(f"order {order_id} is {order.status}, not awaiting payment")
payment = Payment(self._ids.next_id(), order.id, order.total, PaymentStatus.CAPTURED)
try:
with self._uow:
if not self._gateway.authorize(order.total, order.id):
raise PaymentDeclinedError(f"card declined for {order.total}")
self._uow.payments.add(payment)
self.inventory.commit(order.hold_id) # raises if the TTL sweeper won the race
self.orders.transition(order.id, OrderStatus.PAID)
except (PaymentDeclinedError, HoldExpiredError):
self._safe_release(order.hold_id)
self.orders.transition(order.id, OrderStatus.CANCELLED)
self._publish(ORDER_CANCELLED, order)
raise
self._publish(ORDER_PAID, order)
return payment
# -- fulfilment --------------------------------------------------------------
def pack(self, order_id: str) -> Order:
return self.orders.transition(order_id, OrderStatus.PACKED)
def ship(self, order_id: str) -> Order:
order = self.orders.transition(order_id, OrderStatus.SHIPPED)
self._publish(ORDER_SHIPPED, order)
return order
def deliver(self, order_id: str) -> Order:
return self.orders.transition(order_id, OrderStatus.DELIVERED)
def cancel(self, order_id: str) -> Order:
"""Give the units back the right way: release a hold, restock a commit."""
previous = self.orders.order(order_id).status
order = self.orders.transition(order_id, OrderStatus.CANCELLED)
if previous is OrderStatus.CREATED:
self._safe_release(order.hold_id)
elif previous in RESTOCK_ON_CANCEL_FROM:
self._restock(order)
self._refund(order)
self._publish(ORDER_CANCELLED, order)
return order
def accept_return(self, order_id: str) -> Order:
order = self.orders.transition(order_id, OrderStatus.RETURNED)
self._restock(order)
self._refund(order)
return order
def sweep_expired_holds(self) -> list[str]:
"""Abandoned checkouts give their units back and their orders are cancelled."""
cancelled = []
for expired in self.inventory.expire_holds():
for order in self.orders.repository.all():
if order.hold_id == expired.id and order.status is OrderStatus.CREATED:
self.orders.transition(order.id, OrderStatus.CANCELLED)
cancelled.append(order.id)
return cancelled
# -- internals ---------------------------------------------------------------
def _claim_key(self, key: str) -> Order | None:
"""In-progress or completed: the two states an idempotency record can be in."""
with self._lock:
record = self._checkouts.get(key)
if record is None:
self._checkouts[key] = CheckoutRecord(key, self._clock.now())
return None
order_id = record.order_id
if order_id is None:
raise CheckoutInProgressError(f"checkout {key} is already in flight")
return self.orders.order(order_id)
def _complete_key(self, key: str, order_id: str) -> None:
with self._lock:
self._checkouts[key].order_id = order_id
def _abandon_key(self, key: str) -> None:
with self._lock:
self._checkouts.pop(key, None) # a failed attempt may be retried
def _safe_release(self, hold_id: str) -> None:
try:
self.inventory.release(hold_id)
except HoldExpiredError:
pass # already released, expired or committed; nothing to give back
def _restock(self, order: Order) -> None:
for line in self.inventory.hold(order.hold_id).lines:
self.inventory.add_stock(line.sku_id, line.warehouse_id, line.quantity)
def _refund(self, order: Order) -> None:
for payment in self.payments.all():
if payment.order_id == order.id and payment.status is PaymentStatus.CAPTURED:
payment.status = PaymentStatus.REFUNDED
def _publish(self, topic: str, order: Order) -> None:
payload = {
"order_id": order.id,
"customer_id": order.customer_id,
"total": str(order.total),
"sku_ids": ",".join(item.sku_id for item in order.items),
"tracking": f"TRK-{order.id}",
}
self.bus.publish(Event(topic, self._clock.now(), payload))
Running python -m lld.ecommerce_order_inventory.demo walks a multi-warehouse hold, a retry, an oversell rejection and an abandoned cart:
search kitchen, in stock, under 50.00: ['sku-kettle', 'sku-mug']
kettle: 5 available across two warehouses
ORD-1 created: 175.00 USD - 15.00 USD + 32.00 USD tax + 0.00 USD ship = 192.00 USD
held ['2xkettle@w-east', '2xkettle@w-west', '2xmug@w-east']; kettles now 1 available, 4 reserved
retrying with key idem-1 returns ORD-1 again, not a second order
second buyer rejected: sku-kettle: short by 1 of 2
low-stock alerts raised on order.placed: [('sku-kettle', 1)]
PAY-1 captured 192.00 USD; kettles on hand 1
after the 15 min TTL the sweeper cancels ['ORD-2'], grinder back to 1
ORD-1 shipped via TRK-ORD-1
cust-1: order ORD-1 received, total 192.00 USD
cust-1: payment taken for ORD-1
cust-1: ORD-1 is on its way, tracking TRK-ORD-1
Concurrency and edge cases¶
Which lock protects what. Four, and no method holds two of the interesting ones at once:
InventoryService._sku_locks[sku_id]guards every warehouse row of that SKU: the oversell guard. One lock per SKU is the right granularity — a global lock puts every buyer behind whoever wants the popular item, and a lock per warehouse row makes a five-line basket take an unbounded set of locks in an order nobody has reasoned about.InventoryService._holds_lockguards the hold registry and the status flip, which makescommit,releaseandexpiremutually exclusive per hold.OrderService._lockguards the order repository and every transition.CheckoutFacade._lockguards the idempotency store, held just long enough to read and write one key.
Why all-or-nothing works. reserve takes every SKU lock it needs in sorted id order, then plans all lines, then writes. Two baskets holding the same pair of SKUs in opposite order still acquire them in the same order, so there is no lock cycle. Because the plan is computed while holding every lock, either all lines succeed or nothing is written — no partly reserved basket, so no compensation to design. The database version of that sentence is "one transaction, rows locked in primary-key order".
Sizing it. A store doing 2M orders a day averages 2,000,000 / 86,400 ≈ 23 orders/s, and roughly 70/s at the usual 3x peak — trivially served. A flash sale is what breaks you, because every attempt contends on one SKU lock. An uncontended lock is about 17 ns, so the lock is never the cost; the critical section is, and it is a handful of integer updates. The real constraint is downstream: a single relational primary sustains roughly 5k-20k writes/s in total, which is why the production answer for a flash sale is an atomic decrement in Redis (about 100k ops/s on one instance) with the relational row reconciled behind it.
The reserve/commit/release cycle. available and reserved are two numbers, not one: reserve moves units between them, commit removes reserved units for good, release puts them back. The invariant the tests assert is the sum — units physically on hand — which only changes when goods really move. Stating that invariant is the difference between a candidate who has decremented a counter and one who has run a warehouse.
The TTL, and the one thing it blocks. expire_holds returns the units of abandoned checkouts and sweep_expired_holds cancels the orders waiting on them. The deadline blocks exactly one operation — commit — because taking units the shop has already re-offered is the only unsafe thing an overdue hold can do. Releasing an overdue hold is always allowed, and that asymmetry is what lets pay recover when the sweeper won the race.
The idempotency key. _claim_key implements the two states a real idempotency record has. No order id means in progress, so a concurrent duplicate is rejected with CheckoutInProgressError rather than allowed to reserve a second basket. An order id means completed, so the original order is replayed. A failed attempt deletes its record so a genuine retry can proceed. Those three behaviours are what "idempotent" means on a write endpoint.
Compensate across services, roll back inside one. The unit of work rolls the order and payment repositories back together because they are the same store; inventory is a different service, so undoing a hold is a release call — a compensating action, which is a saga in two steps. The same reasoning governs publishing: events go out after the unit of work closes, because publishing inside it is how stores send "order received" emails for orders that never existed. A transactional outbox is the production form of that fix.
Edge cases handled: one line of a basket short, a SKU split across two warehouses, an empty cart, a guest cart merging into an existing one, a repriced SKU after the order was placed, a total that moved between cart and checkout, a declined card, a hold that expired before payment, cancelling before and after payment, a return, a stale row version, and a subscriber that throws.
Common mistake
Modelling stock as a single quantity integer and decrementing it at checkout. It cannot answer any of the questions that follow: what happens when the payment fails, how much could you still sell, and what stops a bot holding your whole stock in a cart? Two counters plus a hold with a deadline answers all three, and it is the same shape as SELECT ... FOR UPDATE plus a reservations table.
Extensibility and follow-ups¶
- Flash sales: shorten the TTL to about two minutes and move the hot SKU's counter to an atomic decrement in a cache, reconciled behind.
InventoryServicekeeps its interface; only four method bodies change. - Returns as a saga: with a real gateway the refund is a network call that can fail, so
accept_returnbecomes a compensating step with retries and aRETURN_PENDINGstatus betweenDELIVEREDandRETURNED. - Multi-warehouse allocation:
_planwalks warehouses in id order. Sorting by distance or cost is anAllocationStrategyinjected intoInventoryService— one interface, no other change. - Backorders: a partly satisfiable basket fails today. Allowing it means a
promised_atper hold line, anAWAITING_STOCKstatus, and a subscriber on the restock event that retries. - Persistence and scale:
InMemoryRepositorybecomes a SQL repository,UnitOfWork.__enter__becomesBEGIN, and the per-SKU lock becomesSELECT ... FOR UPDATEordered by SKU id — or the optimisticWHERE version = ?theversionfield already anticipates. Beyond that it is a system design question, and the HLD case study is this problem with those constraints.
Interview tip
When they say "now make sure we never oversell", do not reach for a lock. Say the invariant first: "available + reserved is the physical stock and only changes when goods move; overselling means available went negative." Then choose the mechanism — a per-SKU lock here, SELECT ... FOR UPDATE in Postgres, an atomic decrement in Redis — and say why the granularity is a SKU. Invariant before mechanism is the clearest senior signal in this problem.
Tests¶
tests/test_ecommerce_order_inventory.py has 25 cases. The flagship is the oversell race: forty buyers, twelve units, sixteen threads, exactly twelve winners — plus the physical-stock invariant, which catches bugs the winner count alone would miss.
def test_forty_buyers_race_for_twelve_units_and_exactly_twelve_win(clock: FakeClock) -> None:
shop = build(clock, stock={("sku-kettle", EAST.id): 12})
def buy(i: int) -> bool:
cart_id = cart_with(shop, f"cust-{i}", kettle=1)
try:
shop.checkout(cart_id, f"cust-{i}", SHIP_TO, f"key-{i}")
except OutOfStockError:
return False
return True
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(buy, range(40)))
assert results.count(True) == 12 # never 13, never 11
assert shop.inventory.available("sku-kettle") == 0
assert shop.inventory.reserved("sku-kettle") == 12
assert shop.inventory.on_hand("sku-kettle") == 12 # nothing was created or destroyed
def test_concurrent_duplicate_keys_produce_exactly_one_order(clock: FakeClock) -> None:
shop = build(clock)
cart_id = cart_with(shop, "cust-1", kettle=1)
def submit(_: int) -> str | None:
try:
return shop.checkout(cart_id, "cust-1", SHIP_TO, "same-key").id
except CheckoutInProgressError:
return None
with ThreadPoolExecutor(max_workers=10) as pool:
ids = list(pool.map(submit, range(10)))
created = [i for i in ids if i is not None]
assert len(set(created)) == 1 # every caller that got an answer got the same order
assert len(shop.orders.repository.all()) == 1
assert shop.inventory.reserved("sku-kettle") == 1 # one hold, not ten
The second half of that snippet fires ten concurrent checkouts with the same idempotency key and asserts one order, one hold, and one answer shared by every caller that got one.
The rest cover: checkout to payment with the pricing arithmetic and a repricing the order ignores; all-or-nothing reserve leaving the other SKU untouched; a SKU split across warehouses; the TTL expiring and the sweeper cancelling the order; paying after expiry with no payment row; a declined card releasing the hold; the transition table; a return restocking and refunding; price drift rejected before anything is held; the guest-cart merge; composed specifications; a stale version; a throwing subscriber; and five discount strategies. Run them with uv run pytest code/lld/ecommerce_order_inventory -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | One checkout call or two? All-or-nothing baskets? Multi-warehouse? What happens on a retry? Out of scope: search ranking, recommendations, real gateway. |
| 5–10 | The invariant | Draw available and reserved as two boxes and the arrows between them: "physical stock is the sum, and it only changes when goods move". |
| 10–18 | Entities and class diagram | Sku, Cart with no prices, InventoryItem, StockHold with a TTL, Order with a snapshot. Then the facade over three services. |
| 18–34 | Code | InventoryItem.hold → InventoryService.reserve with sorted locks → CheckoutFacade.checkout → pay with the unit of work and the compensating release. |
| 34–40 | Concurrency | Per-SKU locks and why that granularity, the sorted order, the TTL asymmetry, and the idempotency record's two states. |
| 40–45 | Extensions | Flash sale with an atomic decrement, returns as a saga, allocation strategy, the outbox — then hand off to the HLD version. |
Related¶
- Design Amazon (e-commerce with inventory and flash sales) — the same checkout across services, queues and regions
- Transactions, 2PC, sagas and idempotency — the database and saga forms of everything on this page
- Facade — why one class owns the order of operations
- Event Bus — topics, synchronous dispatch and handler isolation
- Unit of Work — grouping repository writes and what rollback cannot undo
- Specification — composable catalog filters
- Primary source: Stripe API documentation, "Idempotent requests" — the in-progress versus completed record this checkout implements