Low-Level Design: Shopping Cart System — Persistence, Pricing, and Checkout Coordination

Core Entities

Cart: cart_id, user_id (nullable for guest), session_id (for guest carts), status (ACTIVE, MERGED, ORDERED), created_at, updated_at, expires_at. CartItem: item_id, cart_id, product_id, variant_id (size/color), quantity, unit_price_at_add (snapshot of price when added), added_at. Product: product_id, name, base_price, inventory_count, is_active. AppliedPromo: promo_id, cart_id, code, discount_type, discount_value, applied_at.

Cart Persistence and Guest-to-User Merge

class CartService:
    def get_or_create_cart(self, user_id=None, session_id=None) -> Cart:
        if user_id:
            cart = self.repo.get_active_cart_for_user(user_id)
            if cart: return cart
            # Check for guest cart to merge
            if session_id:
                guest_cart = self.repo.get_cart_by_session(session_id)
                if guest_cart:
                    return self.merge_carts(guest_cart, user_id)
            return self.repo.create_cart(user_id=user_id)
        else:
            cart = self.repo.get_cart_by_session(session_id)
            if cart: return cart
            return self.repo.create_cart(session_id=session_id)

    def merge_carts(self, guest_cart: Cart, user_id: int) -> Cart:
        user_cart = self.repo.get_active_cart_for_user(user_id)
        if not user_cart:
            guest_cart.user_id = user_id
            guest_cart.session_id = None
            return self.repo.save(guest_cart)
        # Merge guest items into user cart
        for item in guest_cart.items:
            existing = self.repo.find_item(user_cart.cart_id, item.product_id, item.variant_id)
            if existing:
                existing.quantity += item.quantity
            else:
                item.cart_id = user_cart.cart_id
            self.repo.save_item(item)
        guest_cart.status = CartStatus.MERGED
        self.repo.save(guest_cart)
        return user_cart

Price Snapshots and Stale Price Detection

CartItem stores unit_price_at_add (the price when the item was added). This ensures the cart total is stable even if the product price changes. At checkout: compare CartItem.unit_price_at_add against the current product price. If the price decreased: honor the lower current price (customer benefit). If the price increased: show a warning (“Price has changed from $X to $Y”) and require acknowledgment. The cart total displayed to the user uses the snapshot price for consistency during the session. Price change detection: a background job can scan active carts for items with stale prices and trigger a notification to the user (“Price alert: item in your cart changed”).

Add to Cart with Inventory Check

Two approaches: (1) Soft availability check: check inventory on add but do not reserve. Fast and simple. Risk: items can sell out before checkout. Show “only N left” warnings. (2) Hard reservation on add: reserve inventory immediately on add (same as ticket booking). Ensures items won’t sell out. Risk: users abandon carts, tying up inventory. Use a cart expiry TTL (e.g., 30 minutes) to auto-release abandoned reservations. Decision: for scarce inventory (concert tickets, limited sneaker drops): hard reservation. For most e-commerce: soft check. Implementation of soft check: SELECT inventory_count FROM products WHERE product_id = :id. If inventory_count >= requested_quantity: add to cart. Else: return InsufficientStockError with the available count.

Cart Total Calculation

Calculate at read time (not stored): avoids stale totals when prices change. subtotal = SUM(item.quantity * item.unit_price_at_add). Apply promos: for percentage discount, discount = subtotal * rate. For fixed amount, discount = min(promo_value, subtotal). For free shipping, set shipping_cost = 0. tax = (subtotal – discount) * tax_rate (varies by shipping address). shipping_cost = compute_shipping(weight, dimensions, destination, carrier). total = subtotal – discount + tax + shipping_cost. The calculation is performed in the cart summary API response. Store the promo discount type and value, not the pre-calculated discount amount (prices may change).

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you handle the guest-to-user cart merge when the user already has an existing cart?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a guest logs in and both a guest cart and a user cart exist: merge the guest cart into the user cart. For each item in the guest cart: check if the same (product_id, variant_id) combination already exists in the user cart. If it does: add the quantities (existing.quantity += guest_item.quantity). If not: move the item to the user cart (update cart_id). After merging all items, mark the guest cart status as MERGED (so it no longer appears active). This preserves both carts’ contents with quantity accumulation for duplicates. Edge cases: if a merged item quantity exceeds the product’s inventory limit, cap it at the available inventory and notify the user. If the guest cart is empty: skip merging, just assign the user_id to the guest cart and clear session_id. If the user has no existing cart: re-use the guest cart by updating user_id and clearing session_id (no item-level merge needed).”
}
},
{
“@type”: “Question”,
“name”: “Why should cart totals be calculated at read time rather than stored?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Storing a pre-computed cart total creates a consistency problem: if a product price changes, the stored total is immediately stale. Every cart containing that product would need to be updated — a potentially expensive fan-out write. Promotions and tax rates also change: a stored total calculated before a tax rate update is wrong afterward. Calculating at read time (on every cart summary request) always reflects the current state. Performance concern: calculation is O(n) in the number of cart items — typically 1-20 items — so it’s fast. The calculation uses CartItem.unit_price_at_add for line item prices (stable snapshot per item) but recomputes discounts, tax, and shipping dynamically. The price snapshot (unit_price_at_add) stabilizes the per-item price while still detecting price changes for user notification. Storing the total is only beneficial in rare scenarios with very large carts (100+ items) where read-time calculation has measurable latency.”
}
},
{
“@type”: “Question”,
“name”: “What is the difference between soft and hard inventory reservation in a shopping cart?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Soft reservation: check inventory availability when adding to cart (SELECT inventory_count — ensure it’s >= requested quantity), but do not actually decrement inventory. Show “only N left” warnings. Inventory is decremented only at checkout. Risk: multiple users can add the same scarce item to their carts, but only the first to checkout gets it. Others get a “sold out” error at checkout. Good for most e-commerce (inventory is plentiful relative to demand). Hard reservation: decrement inventory immediately when adding to cart (UPDATE inventory SET reserved = reserved + qty WHERE product_id = :id AND (quantity – reserved) >= :qty). Releases the reservation when the cart expires or item is removed. Ensures the item is guaranteed for the cart holder. Risk: inventory tied up in abandoned carts reduces availability for serious buyers — requires TTL-based auto-release (e.g., 30-minute expiry). Required for scarce items: concert tickets, limited-edition drops, airline seats.”
}
},
{
“@type”: “Question”,
“name”: “How do you detect and handle price changes between when an item was added to the cart and checkout?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “CartItem.unit_price_at_add stores the snapshot price at add time. At checkout, for each cart item: fetch the current product price and compare to unit_price_at_add. Three cases: (1) Price unchanged: proceed normally. (2) Price decreased: honor the lower current price — update unit_price_at_add to the current price and note the saving in the order summary. This is a customer-favorable change and builds goodwill. (3) Price increased: display a warning banner showing the old and new prices (“Price for [item] changed from $X to $Y”). Require the customer to explicitly acknowledge the price change before completing checkout. Do not silently charge a higher price — this is both a UX principle and may have legal implications in some jurisdictions. Implementation: in the checkout flow, run a price freshness check before displaying the payment screen. If any price increased, show the delta and require re-confirmation.”
}
},
{
“@type”: “Question”,
“name”: “How do you design cart expiry and cleanup for abandoned carts?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Cart expiry serves two purposes: release reserved inventory (for hard-reservation systems) and reclaim database storage. Design: Cart has an expires_at column, set to NOW() + 30 days on creation and updated on each modification (item add/remove). A background job (cron or scheduled task) runs hourly: SELECT cart_id FROM carts WHERE status=’ACTIVE’ AND expires_at < NOW(). For each expired cart: release any hard inventory reservations (UPDATE inventory SET reserved = reserved – qty for each cart item), mark cart status as EXPIRED. For soft-reservation systems, simply mark as EXPIRED — no inventory action needed. Abandoned cart recovery: before marking a cart expired, check if the user has an email (registered user). If the cart has items and has been inactive for 24 hours: send an abandoned cart email ("You left something behind"). Send at most one reminder per abandonment event. Expires_at index: index on (status, expires_at) for efficient batch expiry queries."
}
}
]
}

Asked at: Shopify Interview Guide

Asked at: Uber Interview Guide

Asked at: Airbnb Interview Guide

Asked at: Stripe Interview Guide

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top