Courses › Low-Level Design › Problem Walkthroughs
Design a Discount Engine
Composable conditions and effects, stacking order, rounding and exclusive promotions.
"Design a discount and coupon engine" is a rules engine question. Business people keep inventing new kinds of promotions, and the design has to absorb them without rewriting the checkout. It tests whether you can separate conditions (when a promotion applies) from effects (what it does), and define clear rules for combining several promotions.
Requirements
- A cart has line items: SKU, category, unit price and quantity.
- Promotions such as:
- 10% off the whole cart over $50
- Buy 2 get 1 free on SKU X
- $5 off the electronics category
- Coupon code
WELCOMEfor first-time customers - Each promotion has eligibility conditions: a minimum spend, customer type, date range, usage limits.
- Stacking rules: some promotions combine, some are exclusive, and they're applied in priority order.
- The checkout shows which promotions were applied and how much each one saved.
- Out of scope unless asked: tax, shipping, storing promotions in a database.
Separating conditions and effects
Cart / LineItem sku, category, unit_price (cents), qty; subtotal()
Context cart, customer, now, coupon_codes
Condition (interface) is_met(ctx) -> bool
MinSubtotal, CustomerIs, DateRange, HasCoupon, And / Or / Not (composable)
Effect (interface) discount(ctx) -> cents
PercentOffCart, AmountOffCategory, BuyXGetYFree
Promotion id, condition, effect, priority, stackable
PromotionEngine evaluate(ctx) -> list[AppliedDiscount]Conditions use the Specification pattern (small predicates that combine with and/or/not). Effects use the Strategy pattern. A Promotion is just one condition plus one effect, plus some metadata. A new promotion type is usually a new combination, not new code.
Implementation
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LineItem:
sku: str
category: str
unit_price: int # cents
qty: int
@dataclass
class Context:
items: list
customer_type: str = "returning"
coupons: set = field(default_factory=set)
now: datetime = field(default_factory=datetime.now)
def subtotal(self):
return sum(i.unit_price * i.qty for i in self.items)
# ── conditions (Specification) ──
class Condition:
def is_met(self, ctx): raise NotImplementedError
def __and__(self, other): return _All(self, other)
class _All(Condition):
def __init__(self, *parts): self.parts = parts
def is_met(self, ctx): return all(p.is_met(ctx) for p in self.parts)
class Always(Condition):
def is_met(self, ctx): return True
class MinSubtotal(Condition):
def __init__(self, cents): self.cents = cents
def is_met(self, ctx): return ctx.subtotal() >= self.cents
class HasCoupon(Condition):
def __init__(self, code): self.code = code
def is_met(self, ctx): return self.code in ctx.coupons
class CustomerIs(Condition):
def __init__(self, kind): self.kind = kind
def is_met(self, ctx): return ctx.customer_type == self.kind
# ── effects (Strategy) ──
class PercentOffCart:
def __init__(self, pct): self.pct = pct
def discount(self, ctx, base): return base * self.pct // 100
class AmountOffCategory:
def __init__(self, category, cents): self.category, self.cents = category, cents
def discount(self, ctx, base):
in_cat = sum(i.unit_price * i.qty for i in ctx.items if i.category == self.category)
return min(self.cents, in_cat)
class BuyXGetYFree:
def __init__(self, sku, x, y): self.sku, self.x, self.y = sku, x, y
def discount(self, ctx, base):
item = next((i for i in ctx.items if i.sku == self.sku), None)
if not item:
return 0
free = item.qty // (self.x + self.y) * self.y
return free * item.unit_price
@dataclass
class Promotion:
id: str
condition: Condition
effect: object
priority: int = 100
stackable: bool = True
class PromotionEngine:
def __init__(self, promotions):
self.promotions = sorted(promotions, key=lambda p: p.priority)
def evaluate(self, ctx):
applied, remaining = [], ctx.subtotal()
for promo in self.promotions:
if not promo.condition.is_met(ctx):
continue
if applied and not promo.stackable:
continue # exclusive: only if nothing applied yet
amount = min(promo.effect.discount(ctx, remaining), remaining) # never below zero
if amount > 0:
applied.append((promo.id, amount))
remaining -= amount
if not promo.stackable:
break # exclusive promo ends evaluation
return applied, remainingengine = PromotionEngine([
Promotion("BOGO_SOCKS", Always(), BuyXGetYFree("socks", 2, 1), priority=10),
Promotion("ELEC5", Always(), AmountOffCategory("electronics", 500), priority=20),
Promotion("TENOFF50", MinSubtotal(5000), PercentOffCart(10), priority=30),
Promotion("WELCOME", HasCoupon("WELCOME") & CustomerIs("new"), PercentOffCart(15),
priority=40, stackable=False),
])
ctx = Context(items=[LineItem("socks", "apparel", 500, 3), LineItem("cable", "electronics", 2000, 2)])
applied, total = engine.evaluate(ctx)
assert applied == [("BOGO_SOCKS", 500), ("ELEC5", 500), ("TENOFF50", 450)] and total == 4050Design decisions to explain
- Order changes the result. A 10% discount applied before or after a $5 discount gives different totals. Make the order explicit with a priority, and decide whether percentage discounts apply to the original subtotal or to the remaining amount. This version uses the remaining amount, which is more conservative.
- Integer cents, with a clear rounding rule. Round down discounts so you never give away fractions of a cent, and never go below zero.
- Explainability: return each applied promotion with its amount, for the receipt and for customer support.
- Exclusive promotions: this version applies an exclusive promotion only if nothing else applied before it. A common alternative is "best price wins": evaluate every valid combination (or the stackable set against each exclusive promotion) and pick the cheapest total for the customer.
[!INTERVIEW] Ask about the business rules early "Can coupons combine with sales? Do percentage discounts apply before or after fixed ones? Is there a maximum total discount?" These questions decide the design, and asking them shows you understand that the hard part of a rules engine is the rules, not the code.
Follow-ups
- Usage limits: "first 1,000 redemptions" or "once per customer" need a counter that's checked and incremented atomically at checkout, not when the cart is viewed.
- Configuration instead of code: store promotions as data (condition type plus parameters) and build the objects with a factory, so marketing can create promotions without a deploy.
- Testing: a table-driven test per promotion type, plus tests for combinations and ordering.
- Performance: with thousands of promotions, index them by SKU and category so each cart only evaluates the relevant ones.
[!TIP] Practice Design a Discount and Coupon Engine · Design Patterns · Cross-Product Tagging System