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 aCart, 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
Cartor 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
ifstatements 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.
- 1Checkout starts from the subtotal of every line item, before any discount.
- 2The subtotal enters the chain at its first link.
- 3The coupon's output becomes the voucher's input - order matters.
- 4FreeShippingRule only zeroes shipping if the running total already clears its threshold.
- 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
Code
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
PriceRuledecides its own eligibility.applytakes the whole cart, not just a number, so a rule like "free shipping over $50" can inspect the subtotal itself rather thanCartpre-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.
Common follow-ups
- A customer complains their 20%-off coupon and their $10 voucher gave a different total
than they expected - what's the first thing to check? Chain order.
PercentOffRuleapplied beforeFlatAmountRulegives a different final number than the reverse, because a percentage taken off a smaller base is a smaller discount. The fix isn't a bug fix - it's making theruleChain's order a documented, reviewable business decision rather than an accident of which rule got added to the list first. - How would you cap stacking to "at most one coupon, but unlimited vouchers"? Add a
category()method toPriceRule(COUPON,VOUCHER,SHIPPING) and have whoever assembles theruleChainfilter to at most oneCOUPON-category rule before building the list. This keeps the cap a construction-time policy decision, not a runtime check insideCart.checkout(). - How would you show the customer a receipt line for each individual discount, not just
the final total? Change
PriceRule.applyto return a smallAdjustmentrecord (amount, description) alongside the new running total, and haveCart.checkout()collect the list of adjustments as it walks the chain. Each rule already knows exactly what it changed and why - it just isn't currently asked to report it. - A
FreeShippingRulesits before aPercentOffRulein the chain - does order still matter here the same way? Yes, and it's a sharper trap:FreeShippingRulechecks the running total against its threshold at that point in the chain, so a coupon applied after it could drop the total below the free-shipping line without the shipping charge ever being reconsidered. Whether that's correct depends entirely on business intent - which is exactly why the chain being an explicit, visible list matters.
Check yourself
Why does the design use Chain of Responsibility for discounts instead of a Strategy like `FeeStrategy` in parking-lot.mdx?