Skip to main content

Shopping Cart

Everyone's added an item to a cart, applied a coupon, and watched the total drop. The interview question underneath that mundane flow is: how many discounts can stack, in what order, and how do you add a new one without editing every discount that came before it.

Requirements

Functional

  • A customer adds and removes Products from a Cart, optionally changing quantity.
  • The cart computes a subtotal from its line items.
  • Multiple discounts can apply to the same cart - a percentage-off coupon, a flat-amount voucher, a free-shipping threshold - and they need to combine predictably, not just overwrite each other.
  • Checkout produces a final total after every applicable discount has run.

Non-functional

  • Adding a new kind of discount must not require editing Cart or any discount that already exists - only writing the new one and hooking it into the chain.
  • The order discounts apply in must be explicit and inspectable, not implicit in whichever order if statements happen to be written.

Design

Each PriceRule is a link in a chain: it looks at the cart, decides whether it applies, does its own adjustment, and passes the running total to the next link. Cart never asks "which discounts apply" - it just hands the subtotal to the head of the chain and takes back whatever comes out the other end.

CustomerCartPercentOffRuleFlatAmountRuleFreeShippingRulecheckout()1apply(total)2apply(discountedTotal)3apply(runningTotal)4return finalTotal5
  1. 1Checkout starts from the subtotal of every line item, before any discount.
  2. 2The subtotal enters the chain at its first link.
  3. 3The coupon's output becomes the voucher's input - order matters.
  4. 4FreeShippingRule only zeroes shipping if the running total already clears its threshold.
  5. 5The last link's output is the number the customer actually pays.

CartItem pairs a Product with a quantity; it is deliberately the only place quantity lives; Product itself is immutable catalog data shared across every cart that references it.

Class diagram

«interface»PriceRule+ apply(cart, runningTotal): doubleCart- items: List<CartItem>- ruleChain: List<PriceRule>+ addItem(product, qty)+ subtotal(): double+ checkout(): doubleCartItem- product: Product- quantity: int+ lineTotal(): doubleProduct- id: string- name: string- price: doublePercentOffRule- percent: double+ apply(cart, runningTotal): doubleFlatAmountRule- amount: double+ apply(cart, runningTotal): doubleFreeShippingRule- threshold: double- shippingCost: double+ apply(cart, runningTotal): double
implementsuses
Cart hands the subtotal to the first PriceRule; each rule adjusts and forwards to the next.

Code

import java.util.*;
 
class Product {
final String id;
final String name;
final double price;
 
Product(String id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
}
 
class CartItem {
final Product product;
int quantity;
 
CartItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
}
 
double lineTotal() {
return product.price * quantity;
}
}
 
interface PriceRule {
double apply(Cart cart, double runningTotal);
}
 
class PercentOffRule implements PriceRule {
private final double percent;
 
PercentOffRule(double percent) {
this.percent = percent;
}
 
public double apply(Cart cart, double runningTotal) {
return runningTotal * (1 - percent / 100.0);
}
}
 
class FlatAmountRule implements PriceRule {
private final double amount;
 
FlatAmountRule(double amount) {
this.amount = amount;
}
 
public double apply(Cart cart, double runningTotal) {
return Math.max(0, runningTotal - amount);
}
}
 
class FreeShippingRule implements PriceRule {
private final double threshold;
private final double shippingCost;
 
FreeShippingRule(double threshold, double shippingCost) {
this.threshold = threshold;
this.shippingCost = shippingCost;
}
 
public double apply(Cart cart, double runningTotal) {
return runningTotal >= threshold ? runningTotal : runningTotal + shippingCost;
}
}
 
class Cart {
private final List<CartItem> items = new ArrayList<>();
private final List<PriceRule> ruleChain;
 
Cart(List<PriceRule> ruleChain) {
this.ruleChain = ruleChain;
}
 
void addItem(Product product, int quantity) {
items.add(new CartItem(product, quantity));
}
 
double subtotal() {
return items.stream().mapToDouble(CartItem::lineTotal).sum();
}
 
double checkout() {
double total = subtotal();
for (PriceRule rule : ruleChain) {
total = rule.apply(this, total);
}
return total;
}
}

Design decisions

  • Discounts are Chain of Responsibility, not Strategy. A FeeStrategy-style "pick one and run it" doesn't fit here because real carts stack discounts - a coupon and a loyalty discount can both be active at once. Chaining each rule's output into the next rule's input is what lets three discounts combine into one answer without any of them knowing the other two exist.
  • Each PriceRule decides its own eligibility. apply takes the whole cart, not just a number, so a rule like "free shipping over $50" can inspect the subtotal itself rather than Cart pre-filtering which rules even get a turn. That keeps eligibility logic next to the rule it governs instead of scattered across a dispatcher.
  • Chain order is an explicit List<PriceRule> built by whoever assembles the cart, not a priority field on each rule. A percentage coupon applied before a flat voucher gives a different final total than the reverse order; making the order a visible list, rather than a number buried in each rule, is what makes that behavior reviewable at a glance.
  • What's missing for a real system: rules here mutate a running total but a production system needs per-line-item discount attribution for receipts and returns, and stacking rules should probably be capped (a "max one coupon" business rule) rather than left unlimited - both are storefront policy, not core cart mechanics, so they're left out.
0%0 of 122 pages studied