Case Studies

Online Shopping Cart

Design a shopping cart and checkout — products, cart, pricing, orders, payments.

4 min readDesigns · LLD & Patterns

Interview practice: try designing this yourself before reading. Go to the exercise ↓

Problem Statement

Design the LLD for the cart-and-checkout part of an e-commerce app:

  • Browse a product catalog
  • Add/remove items in a cart
  • Apply discount coupons
  • Compute taxes and shipping
  • Place an order with payment
  • Order lifecycle (placed → paid → shipped → delivered)

Requirements

Functional

  • Catalog with products (name, price, stock, variants)
  • Cart per user (persistent across sessions)
  • Quantity updates, remove, clear
  • Discount codes (flat / percent / BOGO / shipping-free)
  • Tax calculation per region
  • Shipping options
  • Place order; charge payment
  • Cancel before ship; refund

Non-Functional

  • Don't oversell (atomic stock check at order)
  • Idempotent payment
  • Cart survives logout

Class Diagram


Catalog & Product

public class Product {
    private final String id;
    private final String sku;
    private final String name;
    private final Money price;
    private final AtomicInteger stock;     // mutable, synchronized
    private boolean isActive = true;

    public boolean reserve(int qty) {
        while (true) {
            int current = stock.get();
            if (current < qty) return false;
            if (stock.compareAndSet(current, current - qty)) return true;
        }
    }

    public void release(int qty) { stock.addAndGet(qty); }
}

AtomicInteger + CAS gives atomic, lock-free stock checks. (For multi-server deployments, use database row locks instead.)


Cart

public class Cart {
    private final String userId;
    private final Map<String, CartItem> items = new LinkedHashMap<>();
    private Coupon coupon;

    public void add(Product p, int qty) {
        if (qty <= 0) throw new IllegalArgumentException();
        items.merge(p.getId(), new CartItem(p, qty),
            (existing, fresh) -> new CartItem(p, existing.qty + fresh.qty));
    }

    public void update(String productId, int newQty) {
        CartItem item = items.get(productId);
        if (item == null) return;
        if (newQty <= 0) items.remove(productId);
        else items.put(productId, new CartItem(item.product, newQty));
    }

    public void remove(String productId) { items.remove(productId); }
    public void clear() { items.clear(); coupon = null; }
    public void applyCoupon(Coupon c) { this.coupon = c; }

    public Money subtotal() {
        return items.values().stream()
            .map(i -> i.product.getPrice().times(i.qty))
            .reduce(Money.zero(), Money::plus);
    }

    public List<CartItem> getItems() { return new ArrayList<>(items.values()); }
    public Coupon getCoupon() { return coupon; }
}

LinkedHashMap preserves insertion order — the user sees items in the order they added them.


Coupons (Strategy)

public interface Coupon {
    String code();
    Money apply(Cart cart);
}

public class FlatCoupon implements Coupon {
    private final String code;
    private final Money amount;

    @Override
    public Money apply(Cart cart) {
        return amount.min(cart.subtotal());     // never refund money
    }
}

public class PercentCoupon implements Coupon {
    private final String code;
    private final double percent;
    private final Money maxDiscount;

    @Override
    public Money apply(Cart cart) {
        Money raw = cart.subtotal().times(percent / 100.0);
        return raw.min(maxDiscount).min(cart.subtotal());
    }
}

public class FreeShippingCoupon implements Coupon {
    @Override
    public Money apply(Cart cart) {
        return Money.zero();    // doesn't reduce subtotal; shipping calc handles it separately
    }
}

public class CategoryCoupon implements Coupon {
    private final String category;
    private final double percent;

    @Override
    public Money apply(Cart cart) {
        Money eligible = cart.getItems().stream()
            .filter(i -> i.product.getCategory().equals(category))
            .map(i -> i.product.getPrice().times(i.qty))
            .reduce(Money.zero(), Money::plus);
        return eligible.times(percent / 100.0);
    }
}

Pricing Pipeline

public class PricingService {
    private final TaxCalculator tax;
    private final ShippingCalculator shipping;

    public Bill calculate(Cart cart, Address shipTo) {
        Money subtotal = cart.subtotal();

        Money discount = (cart.getCoupon() == null)
            ? Money.zero()
            : cart.getCoupon().apply(cart);

        Money shippingFee = (cart.getCoupon() instanceof FreeShippingCoupon)
            ? Money.zero()
            : shipping.calculate(cart, shipTo);

        Money taxed = subtotal.minus(discount);
        Money taxAmount = tax.calculate(taxed, shipTo);

        Money total = taxed.plus(shippingFee).plus(taxAmount);
        return new Bill(subtotal, discount, shippingFee, taxAmount, total);
    }
}

Order

public enum OrderStatus { CREATED, PAID, SHIPPED, DELIVERED, CANCELLED, REFUNDED }

public class Order {
    public final String id;
    public final User user;
    public final List<OrderItem> items;
    public final Address shippingAddress;
    public final Bill bill;
    private OrderStatus status = OrderStatus.CREATED;
    private Payment payment;

    public synchronized void transitionTo(OrderStatus next) {
        if (!isValidTransition(status, next))
            throw new IllegalStateException();
        status = next;
    }

    public void attachPayment(Payment p) { this.payment = p; }
}

CheckoutService (Facade)

public class CheckoutService {
    private final PricingService pricing;
    private final PaymentGateway paymentGateway;
    private final OrderRepository orders;
    private final ProductRepository products;

    public Order placeOrder(Cart cart, Address address, PaymentMethod method) {
        if (cart.getItems().isEmpty()) throw new EmptyCartException();

        // Step 1: reserve stock atomically
        List<Product> reserved = new ArrayList<>();
        try {
            for (CartItem item : cart.getItems()) {
                if (!item.product.reserve(item.qty)) {
                    throw new OutOfStockException(item.product.getName());
                }
                reserved.add(item.product);
            }

            // Step 2: compute bill
            Bill bill = pricing.calculate(cart, address);

            // Step 3: charge payment (with idempotency)
            Payment payment = paymentGateway.charge(bill.total(), method, cart.idempotencyKey());

            // Step 4: persist order
            List<OrderItem> orderItems = cart.getItems().stream()
                .map(i -> new OrderItem(i.product, i.qty, i.product.getPrice()))
                .toList();
            Order order = new Order(cart.getUser(), orderItems, address, bill);
            order.attachPayment(payment);
            order.transitionTo(OrderStatus.PAID);
            orders.save(order);

            cart.clear();
            return order;
        } catch (Exception e) {
            // Rollback stock
            for (int i = 0; i < reserved.size(); i++) {
                reserved.get(i).release(cart.getItems().get(i).qty);
            }
            throw e;
        }
    }

    public void cancel(Order order) {
        if (order.getStatus() != OrderStatus.PAID && order.getStatus() != OrderStatus.CREATED) {
            throw new IllegalStateException("can only cancel before ship");
        }
        order.transitionTo(OrderStatus.CANCELLED);

        // Refund
        paymentGateway.refund(order.payment());

        // Release stock
        for (OrderItem item : order.items()) {
            item.product.release(item.qty);
        }
    }
}

The order matters: reserve stock first, then charge. If stock fails, you didn't charge. If charge fails, you release stock.


Sequence: Place Order


Edge Cases

CaseHandling
Stock changes between cart and checkoutReserve at order time; reject if depleted
Coupon expired during checkoutRe-validate; ask user to reapply
Payment failsRelease reserved stock; show error
Duplicate submission (network retry)Idempotency key on payment
Partial fulfillmentOut-of-scope for cart; backorder modeling needed
Price changes mid-cartConfirm with user before paying
Tax rules change by regionStrategy per region

Design Patterns Used

PatternWhere
StrategyCoupons, tax, shipping calculators
FacadeCheckoutService
StateOrderStatus lifecycle
RepositoryCart, Order, Product persistence
SingletonOne CheckoutService per app
CommandEach order operation as a command (refund, ship)
BuilderConstruct Order with many fields

Interview Tips

  • Reserve stock atomically with CAS or row-level lock — no oversell.
  • Coupons are strategies — interviewers ask "what about a 10% off all electronics?" → CategoryCoupon.
  • Idempotency key for payment — interviewers test this. Network retries should not double-charge.
  • Distinguish Cart (mutable, transient) from Order (immutable, persisted).
  • Pricing pipeline: subtotal → discount → tax → shipping. Get the order right.

Practice

Try it yourself

Design the classes for a shopping cart and checkout.

Your design should cover

  • Add, update and remove items in a cart
  • Apply coupons and discounts with clear rules on stacking
  • Calculate totals with taxes
  • Checkout creates an order and takes payment
  • Handle price or stock changes between adding to cart and checkout
0 / 8,000 characters · saved in this browser

Finished reading?

Mark it done to track your progress in Designs.