Skip to content

40 — LLD: Cart System ​

Understanding the Problem ​

An e-commerce cart — add and remove items, compute a total, apply discounts, add tax. What makes this an LLD interview instead of a data-class exercise is the discount system: multiple discount types (fixed, percentage, BOGO, tiered) need to coexist and evolve. Strategy plus Observer turns the nested if/else into something maintainable.

What is a Cart System?

A per-user container for products and quantities. The total depends on base prices, product-level and cart-level discounts, an optional coupon, and tax. Discounts are the feature that keeps changing across releases, so the design must absorb new discount types cleanly.


Requirements ​

Clarifying Questions ​

You: Do coupons stack with product-level discounts?

Interviewer: Product discounts always apply. Coupons: only one cart-level coupon at a time.

So we can model product discounts as baked-in line-level adjustments, and coupon as a single cart-level strategy.

You: Is tax included in the product price or added separately?

Interviewer: Add tax at the end, after discounts.

Order of operations: subtotal → discounts → tax. Cleanly separated.

You: Multi-currency?

Interviewer: Single currency for simplicity.

One Money type is enough. No FX logic.

Final Requirements ​

  1. addItem(product, qty) / removeItem(productId) / updateQty(productId, qty).
  2. applyCoupon(discountStrategy) — replaces any previous coupon.
  3. total() -> Money — subtotal → discounts → tax.
  4. Multiple discount types: fixed amount, percentage, BOGO (buy-one-get-one), tiered (spend more, save more).
  5. Observer — notify UI listeners on total changes.

Out of scope: Multi-currency, shipping costs, inventory reservation, user authentication.

Deferred tradeoff: Coupon validation (min spend, product restrictions) is baked into each strategy; a dedicated CouponValidator would be cleaner at scale, but adds an indirection we do not need yet.


Core Entities and Relationships ​

The discount zoo is the design challenge. Each discount needs to know how to compute itself given the cart state. They all share the signature (subtotal, cart) -> adjusted_total. That signature is a strategy interface.

EntityResponsibility
Productid, name, price, attributes.
CartLineproduct, qty. subtotal() = product.price * qty.
Cartper-user owner of lines, optional coupon, listeners.
DiscountStrategyapply(subtotal, cart) -> newSubtotal.
TaxCalculatorcompute tax on the discounted subtotal.
PriceListenerObserver for UI refresh.
CartServiceOrchestrates (optional — Cart can expose methods directly).

Why not chain discounts as DiscountChain with multiple coupons? Because the requirement is one coupon at a time. A List<DiscountStrategy> is the natural extension if stacking arrives later. Today, a single field.

Design is iterative. When the next requirement "coupon restrictions by product" arrives, we will add a validator layer.


Class Design ​

Product ​

  • id, name, price, attrs. Immutable.

CartLine ​

  • product, qty. subtotal() convenience.

DiscountStrategy ​

Money apply(Money subtotal, Cart cart)

Concrete implementations: FixedAmountCoupon, PercentCoupon, TieredDiscount, BogoDiscount.

Cart ​

State:

  • userId.
  • lines: LinkedHashMap<String, CartLine> — preserves insertion order.
  • coupon: DiscountStrategy | null.
  • listeners: CopyOnWriteArrayList<PriceListener>.

Methods:

  • addItem, removeItem, updateQty, applyCoupon.
  • total() -> Money.
  • subscribe(PriceListener).
  • Private notifyPriceChange() called from every mutating method.
java
public record Product(String id, String name, BigDecimal price, Map<String, String> attrs) {}

public class CartLine {
    final Product product;
    int qty;
    public CartLine(Product p, int q) { this.product = p; this.qty = q; }
    public BigDecimal subtotal() { return product.price().multiply(BigDecimal.valueOf(qty)); }
}

public interface DiscountStrategy {
    BigDecimal apply(BigDecimal subtotal, Cart cart);
}

public class PercentCoupon implements DiscountStrategy {
    private final BigDecimal percent;  // 0.10 for 10%
    public PercentCoupon(BigDecimal p) { this.percent = p; }
    public BigDecimal apply(BigDecimal subtotal, Cart cart) {
        return subtotal.multiply(BigDecimal.ONE.subtract(percent));
    }
}

public class TieredDiscount implements DiscountStrategy {
    private final List<Map.Entry<BigDecimal, BigDecimal>> tiers;
    public TieredDiscount(List<Map.Entry<BigDecimal, BigDecimal>> tiers) { this.tiers = tiers; }
    public BigDecimal apply(BigDecimal subtotal, Cart cart) {
        BigDecimal pct = BigDecimal.ZERO;
        for (var t : tiers) {
            if (subtotal.compareTo(t.getKey()) >= 0) pct = t.getValue();
            else break;
        }
        return subtotal.multiply(BigDecimal.ONE.subtract(pct));
    }
}

public class BogoDiscount implements DiscountStrategy {
    private final Set<String> eligibleProductIds;
    public BogoDiscount(Set<String> ids) { this.eligibleProductIds = ids; }
    public BigDecimal apply(BigDecimal subtotal, Cart cart) {
        BigDecimal off = BigDecimal.ZERO;
        for (CartLine l : cart.lines()) {
            if (eligibleProductIds.contains(l.product.id())) {
                int freeUnits = l.qty / 2;
                off = off.add(l.product.price().multiply(BigDecimal.valueOf(freeUnits)));
            }
        }
        return subtotal.subtract(off);
    }
}

public interface PriceListener { void onTotalChanged(Money newTotal); }

public class Cart {
    private final String userId;
    private final Map<String, CartLine> lines = new LinkedHashMap<>();
    private DiscountStrategy coupon;
    private final List<PriceListener> listeners = new CopyOnWriteArrayList<>();

    public Cart(String userId) { this.userId = userId; }
    public Collection<CartLine> lines() { return lines.values(); }

    public void addItem(Product p, int qty) { /* ... */ }
    public void applyCoupon(DiscountStrategy s) { this.coupon = s; notifyPriceChange(); }
    public void subscribe(PriceListener l) { listeners.add(l); }
    public Money total() { /* ... */ }
    private void notifyPriceChange() { Money t = total(); listeners.forEach(l -> l.onTotalChanged(t)); }
}

public record Money(BigDecimal amount, String currency) {}
cpp
struct Product { std::string id, name; double price; };
struct CartLine { Product product; int qty; double subtotal() const { return product.price * qty; } };

struct DiscountStrategy {
    virtual double apply(double subtotal, const class Cart& c) = 0;
    virtual ~DiscountStrategy() = default;
};

class PercentCoupon : public DiscountStrategy {
    double pct;
public:
    explicit PercentCoupon(double p) : pct(p) {}
    double apply(double subtotal, const Cart&) override { return subtotal * (1 - pct); }
};
typescript
export interface Product { id: string; name: string; price: number; attrs: Record<string, string>; }
export interface DiscountStrategy { apply(subtotal: number, cart: Cart): number; }

export class PercentCoupon implements DiscountStrategy {
  constructor(private pct: number) {}
  apply(subtotal: number): number { return subtotal * (1 - this.pct); }
}

export class TieredDiscount implements DiscountStrategy {
  constructor(private tiers: Array<[number, number]>) {}  // sorted asc by threshold
  apply(subtotal: number): number {
    let pct = 0;
    for (const [threshold, percent] of this.tiers) {
      if (subtotal >= threshold) pct = percent; else break;
    }
    return subtotal * (1 - pct);
  }
}

export class BogoDiscount implements DiscountStrategy {
  constructor(private eligibleIds: Set<string>) {}
  apply(subtotal: number, cart: Cart): number {
    let off = 0;
    for (const l of cart.lines()) {
      if (this.eligibleIds.has(l.product.id)) {
        const free = Math.floor(l.qty / 2);
        off += l.product.price * free;
      }
    }
    return subtotal - off;
  }
}

Design principles at play:

  • Strategy: DiscountStrategy is open for extension — new discount types plug in without touching Cart.
  • Observer: PriceListener decouples UI refresh from cart logic.
  • Decorator (alternative): If stacking returns, layer discounts on a base Pricer — each one wraps the next.
  • Information Expert: CartLine knows its own subtotal; Cart composes them.

Implementation ​

Core Logic: addItem ​

  1. Validate qty > 0.
  2. Merge with existing line if present; else insert a new one.
  3. Notify listeners.

Core Logic: total ​

  1. Compute subtotal = sum of line subtotals.
  2. Apply coupon if present.
  3. Apply tax on the discounted subtotal.
  4. Return as Money.

Implementations ​

java
public void addItem(Product p, int qty) {
    if (qty <= 0) throw new IllegalArgumentException();
    lines.merge(p.id(),
        new CartLine(p, qty),
        (existing, fresh) -> { existing.qty += fresh.qty; return existing; });
    notifyPriceChange();
}

public void removeItem(String productId) {
    if (lines.remove(productId) != null) notifyPriceChange();
}

public void updateQty(String productId, int qty) {
    if (qty <= 0) { removeItem(productId); return; }
    CartLine l = lines.get(productId);
    if (l == null) throw new NoSuchElementException();
    l.qty = qty;
    notifyPriceChange();
}

public Money total() {
    BigDecimal subtotal = lines.values().stream()
        .map(CartLine::subtotal)
        .reduce(BigDecimal.ZERO, BigDecimal::add);
    BigDecimal afterDiscount = (coupon != null) ? coupon.apply(subtotal, this) : subtotal;
    BigDecimal tax = afterDiscount.multiply(BigDecimal.valueOf(0.18)).setScale(2, RoundingMode.HALF_UP);
    return new Money(afterDiscount.add(tax), "USD");
}
cpp
double Cart::total() const {
    double subtotal = 0;
    for (auto& [_, l] : lines) subtotal += l.subtotal();
    double after = coupon ? coupon->apply(subtotal, *this) : subtotal;
    return after * 1.18;
}
typescript
total(): number {
  let subtotal = 0;
  for (const l of this.lines.values()) subtotal += l.product.price * l.qty;
  const after = this.coupon ? this.coupon.apply(subtotal, this) : subtotal;
  return +(after * 1.18).toFixed(2);
}

addItem(p: Product, qty: number): void {
  if (qty <= 0) throw new Error("bad qty");
  const existing = this.linesMap.get(p.id);
  if (existing) existing.qty += qty;
  else this.linesMap.set(p.id, { product: p, qty });
  this.notifyPriceChange();
}

Thread Safety ​

A Cart is naturally per-user, so contention is low. One synchronized on the mutating methods is enough. The listener list is CopyOnWriteArrayList — snapshot iteration avoids ConcurrentModificationException during notification.

If the cart is served from multiple hosts (user switches devices mid-session), move state to an external store (Redis) and use optimistic concurrency with a version field — the single-user assumption breaks, and so does our local-lock story.

Never call listeners while holding a lock if they might do I/O. Hand off via an executor in that case.

Verification ​

Add 2 × Book at $20, apply a 10% coupon, tax 18%:

  1. Subtotal = $20 × 2 = $40.
  2. After 10% discount = $40 × 0.90 = $36.
  3. Tax = $36 × 0.18 = $6.48.
  4. Total = $36 + $6.48 = $42.48.

BOGO trace: BogoDiscount({book-id}), 3 copies of that book at $20.

  • Eligible line: qty=3. Free units = 3 / 2 = 1.
  • Off = $20 × 1 = $20.
  • New subtotal = previous - $20.

Complete Code Implementation ​

java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

public record Product(String id, String name, BigDecimal price, Map<String, String> attrs) {}
public record Money(BigDecimal amount, String currency) {}

public class CartLine {
    final Product product;
    int qty;
    public CartLine(Product p, int q) { this.product = p; this.qty = q; }
    public BigDecimal subtotal() { return product.price().multiply(BigDecimal.valueOf(qty)); }
}

public interface DiscountStrategy {
    BigDecimal apply(BigDecimal subtotal, Cart cart);
}

public class FixedAmountCoupon implements DiscountStrategy {
    private final BigDecimal amount;
    public FixedAmountCoupon(BigDecimal a) { this.amount = a; }
    public BigDecimal apply(BigDecimal subtotal, Cart c) {
        return subtotal.subtract(amount).max(BigDecimal.ZERO);
    }
}

public class PercentCoupon implements DiscountStrategy {
    private final BigDecimal percent;
    public PercentCoupon(BigDecimal p) { this.percent = p; }
    public BigDecimal apply(BigDecimal subtotal, Cart c) {
        return subtotal.multiply(BigDecimal.ONE.subtract(percent));
    }
}

public class TieredDiscount implements DiscountStrategy {
    private final List<Map.Entry<BigDecimal, BigDecimal>> tiers;
    public TieredDiscount(List<Map.Entry<BigDecimal, BigDecimal>> tiers) { this.tiers = tiers; }
    public BigDecimal apply(BigDecimal subtotal, Cart c) {
        BigDecimal pct = BigDecimal.ZERO;
        for (var t : tiers) {
            if (subtotal.compareTo(t.getKey()) >= 0) pct = t.getValue();
            else break;
        }
        return subtotal.multiply(BigDecimal.ONE.subtract(pct));
    }
}

public class BogoDiscount implements DiscountStrategy {
    private final Set<String> eligibleProductIds;
    public BogoDiscount(Set<String> ids) { this.eligibleProductIds = ids; }
    public BigDecimal apply(BigDecimal subtotal, Cart c) {
        BigDecimal off = BigDecimal.ZERO;
        for (CartLine l : c.lines()) {
            if (eligibleProductIds.contains(l.product.id())) {
                int free = l.qty / 2;
                off = off.add(l.product.price().multiply(BigDecimal.valueOf(free)));
            }
        }
        return subtotal.subtract(off);
    }
}

public interface PriceListener { void onTotalChanged(Money newTotal); }

public class Cart {
    private final String userId;
    private final Map<String, CartLine> lines = new LinkedHashMap<>();
    private DiscountStrategy coupon;
    private final BigDecimal taxRate = new BigDecimal("0.18");
    private final List<PriceListener> listeners = new CopyOnWriteArrayList<>();

    public Cart(String userId) { this.userId = userId; }

    public Collection<CartLine> lines() { return lines.values(); }

    public synchronized void addItem(Product p, int qty) {
        if (qty <= 0) throw new IllegalArgumentException();
        lines.merge(p.id(), new CartLine(p, qty),
            (existing, fresh) -> { existing.qty += fresh.qty; return existing; });
        notifyPriceChange();
    }

    public synchronized void removeItem(String productId) {
        if (lines.remove(productId) != null) notifyPriceChange();
    }

    public synchronized void updateQty(String productId, int qty) {
        if (qty <= 0) { removeItem(productId); return; }
        CartLine l = lines.get(productId);
        if (l == null) throw new NoSuchElementException();
        l.qty = qty;
        notifyPriceChange();
    }

    public synchronized void applyCoupon(DiscountStrategy s) {
        this.coupon = s;
        notifyPriceChange();
    }

    public synchronized Money total() {
        BigDecimal subtotal = lines.values().stream()
            .map(CartLine::subtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal afterDiscount = (coupon != null) ? coupon.apply(subtotal, this) : subtotal;
        BigDecimal tax = afterDiscount.multiply(taxRate).setScale(2, RoundingMode.HALF_UP);
        return new Money(afterDiscount.add(tax).setScale(2, RoundingMode.HALF_UP), "USD");
    }

    public void subscribe(PriceListener l) { listeners.add(l); }

    private void notifyPriceChange() {
        Money t = total();
        for (PriceListener l : listeners) {
            try { l.onTotalChanged(t); } catch (Exception ignore) {}
        }
    }
}
cpp
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>

struct Product { std::string id, name; double price; };
struct CartLine { Product product; int qty; double subtotal() const { return product.price * qty; } };

class Cart;

struct DiscountStrategy {
    virtual double apply(double subtotal, const Cart& c) = 0;
    virtual ~DiscountStrategy() = default;
};

class Cart {
    std::unordered_map<std::string, CartLine> linesMap;
    std::shared_ptr<DiscountStrategy> coupon;
    double taxRate = 0.18;
public:
    const std::unordered_map<std::string, CartLine>& lines() const { return linesMap; }

    void addItem(const Product& p, int qty) {
        auto it = linesMap.find(p.id);
        if (it == linesMap.end()) linesMap[p.id] = {p, qty};
        else it->second.qty += qty;
    }

    void applyCoupon(std::shared_ptr<DiscountStrategy> s) { coupon = std::move(s); }

    double total() const {
        double subtotal = 0;
        for (auto& [_, l] : linesMap) subtotal += l.subtotal();
        double after = coupon ? coupon->apply(subtotal, *this) : subtotal;
        return after * (1 + taxRate);
    }
};

class PercentCoupon : public DiscountStrategy {
    double pct;
public:
    explicit PercentCoupon(double p) : pct(p) {}
    double apply(double subtotal, const Cart&) override { return subtotal * (1 - pct); }
};
typescript
export interface Product { id: string; name: string; price: number; attrs: Record<string, string>; }
export interface CartLine { product: Product; qty: number; }
export interface DiscountStrategy { apply(subtotal: number, cart: Cart): number; }

export class FixedAmountCoupon implements DiscountStrategy {
  constructor(private amount: number) {}
  apply(subtotal: number): number { return Math.max(0, subtotal - this.amount); }
}

export class PercentCoupon implements DiscountStrategy {
  constructor(private pct: number) {}
  apply(subtotal: number): number { return subtotal * (1 - this.pct); }
}

export class TieredDiscount implements DiscountStrategy {
  constructor(private tiers: Array<[number, number]>) {}
  apply(subtotal: number): number {
    let pct = 0;
    for (const [threshold, percent] of this.tiers) {
      if (subtotal >= threshold) pct = percent; else break;
    }
    return subtotal * (1 - pct);
  }
}

export class BogoDiscount implements DiscountStrategy {
  constructor(private eligibleIds: Set<string>) {}
  apply(subtotal: number, cart: Cart): number {
    let off = 0;
    for (const l of cart.lines()) {
      if (this.eligibleIds.has(l.product.id)) off += l.product.price * Math.floor(l.qty / 2);
    }
    return subtotal - off;
  }
}

export interface PriceListener { onTotalChanged(total: number): void; }

export class Cart {
  private linesMap = new Map<string, CartLine>();
  private coupon: DiscountStrategy | null = null;
  private listeners: PriceListener[] = [];
  private taxRate = 0.18;

  constructor(public userId: string) {}

  lines(): CartLine[] { return [...this.linesMap.values()]; }

  addItem(p: Product, qty: number): void {
    if (qty <= 0) throw new Error("bad qty");
    const existing = this.linesMap.get(p.id);
    if (existing) existing.qty += qty;
    else this.linesMap.set(p.id, { product: p, qty });
    this.notifyPriceChange();
  }

  removeItem(pid: string): void {
    if (this.linesMap.delete(pid)) this.notifyPriceChange();
  }

  updateQty(pid: string, qty: number): void {
    if (qty <= 0) { this.removeItem(pid); return; }
    const l = this.linesMap.get(pid);
    if (!l) throw new Error("not in cart");
    l.qty = qty;
    this.notifyPriceChange();
  }

  applyCoupon(s: DiscountStrategy): void {
    this.coupon = s;
    this.notifyPriceChange();
  }

  total(): number {
    let subtotal = 0;
    for (const l of this.linesMap.values()) subtotal += l.product.price * l.qty;
    const after = this.coupon ? this.coupon.apply(subtotal, this) : subtotal;
    return +(after * (1 + this.taxRate)).toFixed(2);
  }

  subscribe(l: PriceListener): void { this.listeners.push(l); }

  private notifyPriceChange(): void {
    const t = this.total();
    for (const l of this.listeners) { try { l.onTotalChanged(t); } catch {} }
  }
}

Extensibility ​

1. "Stackable coupons — apply two at once?" ​

Replace the single coupon field with List<DiscountStrategy> applied in declared order:

java
for (DiscountStrategy s : coupons) subtotal = s.apply(subtotal, this);

Tradeoff: Stacking order matters for percentages vs. fixed amounts. Document the precedence clearly, or enforce it via a CouponOrdering strategy.

2. "Minimum-spend coupon?" ​

Each strategy validates its own preconditions:

java
public class MinSpendPercentCoupon implements DiscountStrategy {
    private final BigDecimal min;
    private final BigDecimal percent;
    public BigDecimal apply(BigDecimal subtotal, Cart c) {
        if (subtotal.compareTo(min) < 0) return subtotal;
        return subtotal.multiply(BigDecimal.ONE.subtract(percent));
    }
}

3. "Flash sale — product price changes mid-session?" ​

Snapshot the product price in the CartLine at add time so live price changes do not retroactively affect existing carts. This is a correctness-over-freshness trade-off; most e-commerce sites do this to avoid customer surprises at checkout.

java
public class CartLine {
    final String productId;
    final BigDecimal priceAtAdd;
    int qty;
    // ...
}

What is Expected at Each Level ​

LevelExpectations
Junior (L4)Hard-coded if/else for each discount type inside total(). Works but not extensible.
Mid (L5)DiscountStrategy interface, multiple implementations, clean total() method with ordered subtotal → discount → tax pipeline.
Senior (L5A)Strategy + Observer, composable discounts, clear precedence documentation, snapshot prices on add, clean extensibility story for stacking.
Staff (L6)Rules engine (Drools-like), A/B testing on pricing, cross-device cart sync with optimistic concurrency, abandoned-cart recovery, analytics integration.

Frontend interview preparation reference.