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 WELCOME for 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, remaining
engine = 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 == 4050

Design 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