The 100 problems, solved / Commerce and money / 086 · Design a coupon and discount system

Design a coupon and discount system

Rules that stack or exclude, expiry and usage limits as composable specifications.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A coupon system decides, for a cart and a customer, which discounts apply and how much they are worth. The rules multiply fast, so the design is about keeping each rule small and composing them.

  • An operator creates a coupon with a code, a benefit (percent off, fixed off, free item) and conditions (minimum order, first order only, category, date window, usage limit).
  • A shopper applies codes to a cart and sees the resulting discount per coupon.
  • The system refuses an invalid or expired coupon with the specific reason.
  • The system handles stacking: some coupons combine, some are exclusive, and the total discount never exceeds the subtotal.
  • The system records usage so per-customer and global limits hold.

Out of scope: the cart itself beyond a small model, payments and fraud. Non-functional constraints: a new condition or benefit type is a new class, not an edit; evaluation is deterministic and explainable; coupons are immutable once created, and usage counts are the only mutable state.

2. Core entities and relationships

  • Cart, CartItem and Customer: the context a rule looks at.
  • Condition: a specification over the context; composable with and, or, not.
  • Benefit: a strategy that computes a discount amount for a cart.
  • Coupon: an immutable code with one condition, one benefit, a stacking flag and limits.
  • UsageLedger: how many times each coupon was used, globally and per customer.
  • CouponEngine: evaluates a list of codes against a cart and resolves stacking.
ContextCouponRuleschildren*Cartitems: listcustomer: Customersubtotal()Customerid: strorders_placed: intCouponcode: strcondition: Conditionbenefit: Benefitstackable: boolmax_uses: intper_customer: intCouponEnginecoupons: dictledger: UsageLedgerevaluate(cart, codes)redeem(cart, codes)UsageLedgeruses(code)uses_by(code, customer)record(code, customer)Conditioncheck(cart)AllOf / AnyOf / NotBenefitamount(cart)

A coupon composes its condition tree and its benefit: they are built together and never shared. The engine aggregates coupons (a catalogue that outlives the engine) and composes the ledger (its own state).

3. Interfaces and key methods

class Condition(Protocol):
    def check(self, cart: Cart) -> Optional[str]:
        """None if satisfied; otherwise the reason it is not."""

class Benefit(Protocol):
    def amount(self, cart: Cart) -> int:
        """Discount in minor units for this cart, before capping."""

@dataclass(frozen=True)
class Coupon:
    code: str; condition: Condition; benefit: Benefit
    stackable: bool; max_uses: int; per_customer: int; valid_until: float

class CouponEngine:
    def evaluate(self, cart: Cart, codes: list[str]) -> Evaluation:
        """Per code: applied amount or rejection reason; total capped at subtotal; no side effects."""
    def redeem(self, cart: Cart, codes: list[str]) -> Evaluation:
        """evaluate, then record usage for the applied coupons atomically."""

Invariants: total discount is at most the subtotal; at most one non-stackable coupon is applied; a coupon is never applied past max_uses or per_customer; evaluate changes nothing.

4. Design patterns and the choices behind them

Conditions as composable specifications

Minimum order, first order, category, date window: each is a specification with a check, and AllOf, AnyOf and Not combine them into a tree. Returning a reason instead of a boolean is what makes rejections explainable. The alternative, a Coupon with twelve optional fields and a long is_valid method, grows a new field per promotion.

Benefits as strategies

Percent off, fixed off and free cheapest item compute an amount differently but are used identically. A strategy per benefit keeps the engine ignorant of the arithmetic, and capping at the subtotal happens once in the engine.

Stacking as a chain of checks

Expiry, usage limits, the condition tree, then the stacking rule: a small chain in a fixed order so the first failing check is the reason reported. Order matters for the message a shopper sees.

Immutability and the one extension point

Coupons are frozen value objects; the ledger is the only thing that changes, so concurrency is a lock on the ledger. The deliberate extension point is the pair of interfaces: new conditions and benefits, never new engine code.

5. Core flow and code

  1. evaluate(cart, codes) looks each code up; unknown codes are rejected with a reason.
  2. For each coupon it checks expiry, then usage limits from the ledger, then the condition tree.
  3. Stacking: the first non-stackable coupon wins over later non-stackables; stackables combine.
  4. Amounts are summed and capped at the subtotal, with the cap taken from the last applied coupon.
  5. redeem repeats evaluate under the ledger lock and records usage for applied coupons.
from __future__ import annotations
import threading, time
from dataclasses import dataclass, field
from typing import Optional, Protocol


@dataclass(frozen=True)
class CartItem:
    sku: str
    category: str
    price: int
    qty: int


@dataclass(frozen=True)
class Customer:
    id: str
    orders_placed: int


@dataclass
class Cart:
    items: list[CartItem]
    customer: Customer
    def subtotal(self) -> int:
        return sum(i.price * i.qty for i in self.items)


class Condition(Protocol):
    def check(self, cart: Cart) -> Optional[str]: ...


@dataclass(frozen=True)
class MinOrder:
    amount: int
    def check(self, cart: Cart) -> Optional[str]:
        return None if cart.subtotal() >= self.amount else f"order below {self.amount}"


@dataclass(frozen=True)
class FirstOrderOnly:
    def check(self, cart: Cart) -> Optional[str]:
        return None if cart.customer.orders_placed == 0 else "first order only"


@dataclass(frozen=True)
class HasCategory:
    category: str
    def check(self, cart: Cart) -> Optional[str]:
        return None if any(i.category == self.category for i in cart.items) else f"needs a {self.category} item"


@dataclass(frozen=True)
class AllOf:
    parts: tuple[Condition, ...]
    def check(self, cart: Cart) -> Optional[str]:
        for p in self.parts:
            reason = p.check(cart)
            if reason:
                return reason
        return None


@dataclass(frozen=True)
class Not:
    inner: Condition
    label: str
    def check(self, cart: Cart) -> Optional[str]:
        return self.label if self.inner.check(cart) is None else None


class Benefit(Protocol):
    def amount(self, cart: Cart) -> int: ...


@dataclass(frozen=True)
class PercentOff:
    percent: int
    def amount(self, cart: Cart) -> int: return cart.subtotal() * self.percent // 100


@dataclass(frozen=True)
class FixedOff:
    value: int
    def amount(self, cart: Cart) -> int: return self.value


@dataclass(frozen=True)
class FreeCheapestItem:
    def amount(self, cart: Cart) -> int:
        return min((i.price for i in cart.items), default=0)


@dataclass(frozen=True)
class Coupon:
    code: str
    condition: Condition
    benefit: Benefit
    stackable: bool = True
    max_uses: int = 10**9
    per_customer: int = 1
    valid_until: float = float("inf")


@dataclass
class Evaluation:
    applied: dict[str, int] = field(default_factory=dict)
    rejected: dict[str, str] = field(default_factory=dict)
    total: int = 0


class UsageLedger:
    def __init__(self) -> None:
        self.global_uses: dict[str, int] = {}
        self.customer_uses: dict[tuple[str, str], int] = {}
        self.lock = threading.Lock()
    def uses(self, code: str) -> int: return self.global_uses.get(code, 0)
    def uses_by(self, code: str, customer: str) -> int: return self.customer_uses.get((code, customer), 0)
    def record(self, code: str, customer: str) -> None:
        self.global_uses[code] = self.uses(code) + 1
        self.customer_uses[(code, customer)] = self.uses_by(code, customer) + 1


class CouponEngine:
    def __init__(self, coupons: list[Coupon], ledger: UsageLedger) -> None:
        self.coupons = {c.code: c for c in coupons}
        self.ledger = ledger

    def _reject_reason(self, c: Coupon, cart: Cart, now: float) -> Optional[str]:
        if now > c.valid_until:
            return "expired"
        if self.ledger.uses(c.code) >= c.max_uses:
            return "no longer available"
        if self.ledger.uses_by(c.code, cart.customer.id) >= c.per_customer:
            return "already used"
        return c.condition.check(cart)

    def evaluate(self, cart: Cart, codes: list[str], now: Optional[float] = None) -> Evaluation:
        now = time.time() if now is None else now
        ev = Evaluation()
        exclusive_taken = False
        for code in codes:
            c = self.coupons.get(code)
            if c is None:
                ev.rejected[code] = "unknown code"; continue
            reason = self._reject_reason(c, cart, now)
            if reason:
                ev.rejected[code] = reason; continue
            if not c.stackable:
                if exclusive_taken or ev.applied:
                    ev.rejected[code] = "cannot combine"; continue
                exclusive_taken = True
            elif exclusive_taken:
                ev.rejected[code] = "cannot combine"; continue
            ev.applied[code] = c.benefit.amount(cart)
        raw = sum(ev.applied.values())
        if raw > cart.subtotal() and ev.applied:
            last = list(ev.applied)[-1]
            ev.applied[last] -= raw - cart.subtotal()
        ev.total = min(raw, cart.subtotal())
        return ev

    def redeem(self, cart: Cart, codes: list[str]) -> Evaluation:
        with self.ledger.lock:
            ev = self.evaluate(cart, codes)
            for code in ev.applied:
                self.ledger.record(code, cart.customer.id)
        return ev

6. Extensions, concurrency and what interviewers probe

  • A new condition (loyalty tier, payment method, a specific SKU) is one frozen class with a check; the engine and coupon do not change. Interviewers ask for one live to see that it is really one class.
  • Best-for-customer selection: today codes are applied in the order given. Choosing the combination that maximises the discount is a search over subsets with the stacking constraint; keep it as a separate Resolver strategy so the simple order-given behaviour stays testable.
  • Usage limits under concurrency: redeem evaluates and records under the ledger lock, so two shoppers cannot both take the last use of a 100-use coupon. Say that evaluate alone is advisory and may be stale by the time of redeem.
  • Item-level benefits (20 percent off shoes only) need the benefit to receive the items that matched the category; pass the cart and let the benefit filter, which the current interface already allows.
  • Test first: an expired code is rejected with "expired" even if its condition passes; a non-stackable code after a stackable one is rejected with "cannot combine"; two fixed-off coupons worth more than the cart cap the total at the subtotal with the second reduced; a per-customer limit of one rejects the second redeem but not the second evaluate.
  • Common mistake: returning a boolean from check. The shopper then sees "invalid coupon" for every reason, and support cannot tell why.

02Where this question was reported