Design a coupon and discount system
Rules that stack or exclude, expiry and usage limits as composable specifications.
- Specification and rule objects — Business rules as composable objects you can and, or and not.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Chain of responsibility — Passing a request along handlers until one takes it.
- Value objects and immutability — Objects defined by their value, never changed, safely shared.
- Extensibility points — Deciding in advance what will change, and leaving one door for it.
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.
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
evaluate(cart, codes)looks each code up; unknown codes are rejected with a reason.- For each coupon it checks expiry, then usage limits from the ledger, then the condition tree.
- Stacking: the first non-stackable coupon wins over later non-stackables; stackables combine.
- Amounts are summed and capped at the subtotal, with the cap taken from the last applied coupon.
redeemrepeatsevaluateunder 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
Resolverstrategy so the simple order-given behaviour stays testable. - Usage limits under concurrency:
redeemevaluates and records under the ledger lock, so two shoppers cannot both take the last use of a 100-use coupon. Say thatevaluatealone is advisory and may be stale by the time ofredeem. - 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
- Codemia · Codemia Medium