Design an online shopping cart
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
Keep a user's cart across devices and sessions, apply prices and promotions correctly, and hand off to checkout reliably.
Requirements
We are building the cart service for a large online store: it holds what a shopper intends to buy, follows them across devices and sign-ins, prices it with current promotions, and turns it into exactly one order at checkout.
Functional requirements
- Add, update quantity, and remove line items (SKU, quantity, optional seller or variant).
- Fetch the cart with current prices, applied promotions, subtotal, and per-line warnings (price changed, out of stock, item delisted).
- Guest carts work without login; on login the guest cart merges into the account cart.
- Apply and remove coupon codes; automatic promotions (buy 2 get 1, 10% off over $50) apply without a code.
- Checkout converts the cart into an order exactly once, even under retries and double clicks.
- Inactive carts expire (guest after 30 days, signed-in after 90 days, configurable).
Non-functional requirements
- Fetch p99 under 100 ms; add/update p99 under 150 ms. The cart badge is on every page.
- Durable: an acknowledged add is never lost. Losing a cart loses revenue.
- Correct under concurrent edits from phone and laptop: no lost updates, no silently resurrected items.
- Checkout is exactly-once from the shopper's view: one order, one charge.
- Prices shown in the cart may be seconds stale; the price charged at checkout must be current and shown to the user before they pay.
- 99.95% availability for reads and writes; checkout may degrade to "try again" but must never double charge.
Back-of-the-envelope estimation
- 100M monthly shoppers, 20M daily active, 40M live carts at any time.
- Cart size: average 4 lines × ~150 B + 500 B header ≈ 1.1 KB → 40M × 1.1 KB ≈ 44 GB. Small enough for one sharded store with full replicas.
- Reads: badge + cart page ≈ 20 reads per active user per day → 400M/day ≈ 5K QPS average, 50K QPS at a flash-sale peak.
- Writes: ~3 mutations per active user per day → 60M/day ≈ 700 QPS average, 10K QPS peak.
- Checkouts: 2M orders/day ≈ 25/s average, 1–2K/s at peak. Read:write ratio about 7:1.
API design
All mutating calls carry If-Match: <cart_version>; responses return the new ETag.
GET /v1/cart
→ 200 { cart_id, version: 17, currency: "USD",
lines: [{ line_id, sku, qty: 2, unit_price: 1999, added_price: 2199,
discounts: [{ promo_id, amount: 400 }], warnings: ["PRICE_DROPPED"] }],
promotions: [...], subtotal: 3598, discount_total: 400, estimated_total: 3198 }
POST /v1/cart/items { sku, qty, client_op_id } → 200 cart | 409 VERSION_CONFLICT
PATCH /v1/cart/items/{line_id} { qty } → 200 cart (qty 0 removes)
DELETE /v1/cart/items/{line_id} → 200 cart
POST /v1/cart/coupons { code } → 200 cart | 422 COUPON_INVALID
POST /v1/cart/merge { guest_cart_token } → 200 cart (called on login)
POST /v1/checkout Idempotency-Key: <uuid>
{ cart_id, cart_version: 17, quoted_total: 3198, payment_method_id, address_id }
→ 201 { order_id, status: "PENDING_PAYMENT" }
→ 409 { code: "CART_CHANGED", repriced_cart } (price or stock moved; show and re-confirm)
GET /v1/orders/{order_id} → order status for polling
Guests are identified by a signed cart_token cookie; signed-in users by user_id from the session. Money is integer minor units.
Data model
Cart store: DynamoDB (or Cassandra / sharded MySQL). Access is almost entirely by one key (owner), single-row reads and conditional writes, with TTL needed for expiry. A key-value store with conditional put fits; nothing needs cross-cart joins.
| Table | Key | Columns | Notes |
|---|---|---|---|
carts |
PK owner_key (u#<user_id> or g#<token>) |
cart_id, version, status (ACTIVE / CHECKING_OUT / CONVERTED / MERGED / EXPIRED), lines (list of {line_id, sku, qty, added_price, added_at}), coupons, updated_at, expires_at (TTL attr) |
One item per cart; conditional write on version
|
cart_ops |
PK cart_id, SK client_op_id
|
result, TTL 24h | Dedupes retried adds from flaky mobile clients |
Lines are embedded rather than a child table: a cart is small (under 100 lines, capped), always read whole, and one item makes version checks atomic. Cart stores intent only (SKU, qty, price at add time); live price is never persisted in the cart.
Order store: sharded Postgres/MySQL, because checkout needs a transaction across order header, lines, and the idempotency record.
-
orders(order_id PK, user_id, cart_id UNIQUE, cart_version, status, total, currency, created_at), index(user_id, created_at). -
order_lines(order_id, line_no, sku, qty, unit_price, discount). -
idempotency_keys(key PK, user_id, request_hash, order_id, created_at).
The unique constraint on cart_id is the last line of defense against a cart producing two orders.
High-level design
flowchart LR
C[Web and mobile clients] --> GW[API gateway]
GW --> CS[Cart service]
CS --> CDB[(Cart store)]
CS --> PR[Pricing and promo service]
CS --> INV[Inventory service]
PR --> PC[(Price cache)]
GW --> CO[Checkout service]
CO --> CS
CO --> PR
CO --> INV
CO --> ODB[(Order DB)]
CO --> PAY[Payment service]
CDB -. "change stream" .-> EV[Event bus]
EV --> AB[Abandoned cart worker]
EV --> AN[Analytics]
-
Add an item. Client sends
POST /cart/itemswithIf-Match: 17. Cart service validates the SKU exists and is sellable (catalog cache), merges qty into an existing line for the same SKU, and writes withcondition: version = 17, settingversion = 18and bumpingexpires_at. It then prices the cart and returns it. On a version mismatch it re-reads, reapplies the operation if it is commutative (an add), or returns 409 for a qty overwrite. -
Fetch the cart. Read the cart item by
owner_key, batch-call pricing with all SKUs plus coupons, batch-call inventory for availability, and attach warnings. Pricing is computed on read and cached per(cart_id, version, price_epoch)for 30 s. -
Checkout. Checkout service looks up the idempotency key; if found, it returns the stored order. Otherwise it flips the cart to
CHECKING_OUTwith a conditional write on the version the user saw, reprices authoritatively, compares toquoted_total, reserves inventory, inserts the order in one DB transaction, authorizes payment, then marks the cartCONVERTED. - Expiry and recovery. The store's TTL deletes stale carts; the change stream feeds abandoned-cart emails and metrics.
The problem looks like CRUD, but the hard parts are multi-device writes, the gap between displayed and charged price, and making a mutable cart into an immutable order exactly once.
Deep dives
Optimistic concurrency for multi-device editing. Phone and laptop can both edit the cart. Every cart carries a monotonic version; every write is PutItem ... ConditionExpression: version = :seen. Conflicts are rare (under 0.1% of writes), so locks would cost more than they save.
| Operation | On conflict |
|---|---|
| Add item / increment qty | Commutative: server re-reads and retries up to 3 times, invisible to the user |
| Remove line | Idempotent: retry; removing a missing line is a no-op |
| Set qty to N | Not commutative: return 409 with the fresh cart, client shows the new state |
| Apply coupon | Retry; coupon set is a union |
client_op_id stored in cart_ops makes a retried add from a dropped mobile connection apply once, not twice. Each device also subscribes (WebSocket or push) to a cart_updated(version) event so the other screen refreshes instead of acting on stale state.
Atomic cart-to-order transition. The failure to avoid is two orders from one cart (double click, retry after timeout, two devices) or an order with no charge. Sequence:
-
Idempotency-Keylookup in the order DB. Hit with the same request hash → return the stored result. Hit with a different hash → 422. - Conditional update on the cart:
status ACTIVE → CHECKING_OUT where version = :v. Only one caller wins; a loser gets 409 and polls the existing order. - Authoritative reprice. If total differs from
quoted_totalby any amount, roll the cart back toACTIVEand returnCART_CHANGEDwith the new numbers. - Reserve inventory with a 15-minute hold, keyed by
order_idso the call is idempotent. - One DB transaction inserts
orders(uniquecart_id),order_lines, and the idempotency row, with statusPENDING_PAYMENT. - Authorize payment with
order_idas the payment idempotency key. On success →PLACED, cart →CONVERTED, new empty cart created lazily on next add. - A sweeper finds carts stuck in
CHECKING_OUTover 10 minutes: if an order exists, finish it; if not, release holds and return the cart toACTIVE.
This is a small saga with the order row as the source of truth; no distributed transaction is needed because each step is idempotent and keyed by order_id.
Price drift and catalog staleness. The cart stores added_price but never trusts it. Prices are read from a price cache (Redis, refreshed by catalog change events, 60 s max staleness) on display and from the pricing service directly at checkout. Rules:
- Price dropped: apply the lower price, show "price dropped" (good for conversion).
- Price rose: show the new price with a warning; checkout fails with
CART_CHANGEDuntil the user confirms. - Item delisted or out of stock: keep the line, mark it unavailable, exclude it from totals and from checkout.
- Promotions are evaluated as a pure function
price(cart_lines, coupons, user_segment, now)so cart display and checkout use identical code and only the inputs' freshness differs.
Cart merge on login. A guest adds items, then signs in to an account that already has a cart from another device. Merge rule: union by SKU, take the max of the two quantities (not the sum, which double counts items the user re-added), cap at the per-SKU limit, keep the account cart's coupons plus any valid guest coupons. The merge is one conditional write on the account cart; the guest cart is marked MERGED with a pointer so a replayed merge call is a no-op. If the merged cart exceeds 100 lines, keep the most recent and surface the rest as "saved for later".
Abandoned cart expiration. Each write sets expires_at = now + 30d (guest) or +90d (user). The store's native TTL deletes items in the background at no write cost. Before deletion, a worker on the change stream sends reminder events at 1 h and 24 h of inactivity (consented users only). Carts in CHECKING_OUT get no TTL until the sweeper resolves them, so an in-flight checkout is never deleted. Converted carts are kept 7 days for support, then dropped. At 40M carts and ~1.3M expiring per day, a scan-based cleanup job would be the wrong tool; TTL plus stream is cheaper.
Cart health observability. Track add-to-cart latency and error rate, version-conflict rate (a spike means a client bug looping), checkout success rate by step, CART_CHANGED rate (high means price cache lag), carts stuck in CHECKING_OUT, and orders-without-payment from the sweeper. Alert on the checkout funnel dropping more than 5% from the same hour last week.
Trade-offs
- Embedding lines in one cart item makes version checks atomic and reads one round trip, but caps cart size and rewrites the whole item on every edit (fine at ~1 KB).
- Optimistic concurrency avoids lock latency but pushes a 409 to the user for non-commutative edits; pessimistic locks would block a phone while a laptop holds the lock.
- Repricing on every read keeps the cart honest but costs a pricing call per fetch; the short per-version cache trades up to 30 s of staleness for a ~10× cut in pricing load.
- Failing checkout on any price increase annoys some users but avoids charging more than was shown, which matters more for trust and chargebacks.
- Max-quantity merge can lose an intentional "I wanted 2 more" from the guest session; sum would double count far more often.
- DynamoDB TTL deletes lazily (up to ~48 h late), so expiry is approximate; reads must also check
expires_at. - A saga instead of a distributed transaction requires a sweeper and idempotent steps everywhere, but keeps the cart store and order DB independent.
Interviewer expectations
Mid-level (L4)
- Clean CRUD API for the cart, a sensible key-value schema keyed by user, and guest carts via a cookie token.
- Knows the price must be recomputed at checkout rather than trusted from the cart.
- Uses an idempotency key on checkout.
Senior (L5)
- Drives to the hard parts unprompted: multi-device conflicts with versioned conditional writes, and the cart-to-order transition as an idempotent, ordered sequence with a unique
cart_idon orders. - Gives a concrete merge rule on login and a concrete price-drift policy with user-visible warnings.
- Sizes the system and picks the store from the access pattern, with TTL for expiry.
Staff (L6+)
- Treats checkout as a saga with explicit recovery for every partial failure (stuck
CHECKING_OUT, reserved stock with no order, order with no payment). - Separates intent (cart) from authority (pricing, inventory, orders), keeping promotions a shared pure function so display and charge never diverge.
- Defines the funnel metrics and alerts that would catch a regression, and discusses flash-sale load, per-SKU limits, and multi-region carts (home region per user, read-your-writes after login).