0

Design an online shopping cart

by · about 1 hour ago · ⑂ 0 remixes

Source: https://www.hack2hire.com/question-bank/questions/system-design/69d71d52c4c1bc791ee22b2c
Company: Uber
Difficulty: Easy

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

Sign in to comment, vote or remix.