Design an online shopping cart

System design Easy Uber
Reference
Start your answer

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]
  1. Add an item. Client sends POST /cart/items with If-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 with condition: version = 17, setting version = 18 and bumping expires_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.
  2. 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.
  3. Checkout. Checkout service looks up the idempotency key; if found, it returns the stored order. Otherwise it flips the cart to CHECKING_OUT with a conditional write on the version the user saw, reprices authoritatively, compares to quoted_total, reserves inventory, inserts the order in one DB transaction, authorizes payment, then marks the cart CONVERTED.
  4. 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:

  1. Idempotency-Key lookup in the order DB. Hit with the same request hash → return the stored result. Hit with a different hash → 422.
  2. 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.
  3. Authoritative reprice. If total differs from quoted_total by any amount, roll the cart back to ACTIVE and return CART_CHANGED with the new numbers.
  4. Reserve inventory with a 15-minute hold, keyed by order_id so the call is idempotent.
  5. One DB transaction inserts orders (unique cart_id), order_lines, and the idempotency row, with status PENDING_PAYMENT.
  6. Authorize payment with order_id as the payment idempotency key. On success → PLACED, cart → CONVERTED, new empty cart created lazily on next add.
  7. A sweeper finds carts stuck in CHECKING_OUT over 10 minutes: if an order exists, finish it; if not, release holds and return the cart to ACTIVE.

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_CHANGED until 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_id on 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).