What L5 looks like
- Toppings and crusts as data with per-size prices; explains why not one class per topping or a deep decorator chain.
- Clear stacking rule (exclusive vs stackable) and explains the BOGO tie-break (cheaper unit free).
- Rejects bad coupons with reasons; separates quoting from redeeming; testable, deterministic engine (time passed in).
Requirements
- An order holds one or more pizza lines. Each line has a size (small, medium, large), a crust, zero or more toppings (with portions, e.g. double cheese) and a quantity.
- A pizza's unit price is the base price for its size, plus the crust surcharge for that size, plus each topping's price for that size times its portions.
- Deals apply automatically (e.g. buy one large, get one large free). Coupons apply only when the customer enters a code (e.g.
SAVE10= 10% off orders over $25). - Each promotion has a validity window and a minimum subtotal. Promotions are either stackable (combine with everything) or exclusive (only the best exclusive one applies).
- The calculator returns a quote: line prices, subtotal, each discount applied with its name, and the total. The total never goes below zero.
- An unknown, expired or ineligible coupon code is rejected with a clear error, not silently ignored.
- New toppings, crusts and promotion types must be addable without changing the pricing engine.
- Money is integer cents, no floating point. Out of scope: tax, delivery fees, tips, payment, half-and-half pizzas, per-customer redemption limits, persistence, and UI.
Core objects
-
Size(enum): the pizza sizes; every price table is keyed by it. -
Crust/Topping: a name and a price per size (toppings also carry a category: meat, veg, cheese). -
PizzaLine: one configured pizza (toppings with portions) plus a quantity; knows its unit price given aMenu.Orderis the list of lines. -
Menu: the base price per size, and the catalog of crusts and toppings by name. Data, not code. -
PricingContext: what promotions look at (order, priced lines, subtotal, time). Built once per quote. -
Promotion(ABC): a rule with a name, optional coupon code, validity window, minimum subtotal and a stackable flag. Subclasses implementdiscount(ctx). -
PercentOff,AmountOff,BuyOneGetOneFree: concrete promotions. -
PromotionRegistry: holds the active promotions; looks up coupons by code and lists automatic deals. -
PricingEngine: computes the subtotal, picks the promotions that apply, and builds theQuote(line totals, subtotal, applied discounts, total).
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"engine","x":0,"y":240,"width":200,"height":80,"label":{"text":"PricingEngine\nquote()"}},
{"type":"rectangle","id":"menu","x":440,"y":100,"width":180,"height":70,"label":{"text":"Menu"}},
{"type":"rectangle","id":"reg","x":440,"y":380,"width":230,"height":120,"label":{"text":"PromotionRegistry\nadd()\nby_code()\nautomatic()"}},
{"type":"rectangle","id":"crust","x":860,"y":0,"width":180,"height":70,"label":{"text":"Crust"}},
{"type":"rectangle","id":"topping","x":860,"y":200,"width":180,"height":70,"label":{"text":"Topping"}},
{"type":"rectangle","id":"line","x":1280,"y":70,"width":220,"height":130,"label":{"text":"PizzaLine\nsize, crust,\ntoppings, quantity\nunit_price(menu)"}},
{"type":"rectangle","id":"order","x":1660,"y":100,"width":180,"height":70,"label":{"text":"Order"}},
{"type":"rectangle","id":"promo","x":860,"y":355,"width":220,"height":170,"label":{"text":"Promotion\n(abstract)\ncode, stackable,\nmin_subtotal\nwhy_not()\ndiscount()"}},
{"type":"rectangle","id":"pct","x":1280,"y":300,"width":180,"height":70,"label":{"text":"PercentOff"}},
{"type":"rectangle","id":"amt","x":1280,"y":420,"width":180,"height":70,"label":{"text":"AmountOff"}},
{"type":"rectangle","id":"bogo","x":1280,"y":540,"width":230,"height":70,"label":{"text":"BuyOneGetOneFree"}},
{"type":"arrow","id":"engine-menu","x":200,"y":265,"width":240,"height":-130,"start":{"id":"engine"},"end":{"id":"menu"},"label":{"text":"uses"}},
{"type":"arrow","id":"engine-reg","x":200,"y":295,"width":240,"height":145,"start":{"id":"engine"},"end":{"id":"reg"},"label":{"text":"uses"}},
{"type":"arrow","id":"menu-crust","x":620,"y":120,"width":240,"height":-85,"start":{"id":"menu"},"end":{"id":"crust"},"label":{"text":"has"}},
{"type":"arrow","id":"menu-topping","x":620,"y":150,"width":240,"height":85,"start":{"id":"menu"},"end":{"id":"topping"},"label":{"text":"has"}},
{"type":"arrow","id":"line-crust","x":1280,"y":115,"width":-240,"height":-80,"start":{"id":"line"},"end":{"id":"crust"},"label":{"text":"has"}},
{"type":"arrow","id":"line-topping","x":1280,"y":155,"width":-240,"height":80,"start":{"id":"line"},"end":{"id":"topping"},"label":{"text":"has many"}},
{"type":"arrow","id":"order-line","x":1660,"y":135,"width":-160,"height":0,"start":{"id":"order"},"end":{"id":"line"},"label":{"text":"has many"}},
{"type":"arrow","id":"reg-promo","x":670,"y":440,"width":190,"height":0,"start":{"id":"reg"},"end":{"id":"promo"},"label":{"text":"has"}},
{"type":"arrow","id":"pct-promo","x":1280,"y":335,"width":-200,"height":65,"start":{"id":"pct"},"end":{"id":"promo"},"label":{"text":"extends"}},
{"type":"arrow","id":"amt-promo","x":1280,"y":455,"width":-200,"height":0,"start":{"id":"amt"},"end":{"id":"promo"},"label":{"text":"extends"}},
{"type":"arrow","id":"bogo-promo","x":1280,"y":575,"width":-200,"height":-75,"start":{"id":"bogo"},"end":{"id":"promo"},"label":{"text":"extends"}}
]}
Key flows
1. Price one pizza line. PizzaLine.unit_price(menu) adds menu.base_price[size], crust.surcharge[size] and, for each topping, topping.price[size] * portions. The line total is unit_price * quantity. Prices come from data, so adding "truffle oil" is a new Topping row in the menu, not a code change. A topping with no price for a size raises an error, so a misconfigured menu fails loudly.
2. Quote an order.
-
PricingEngine.quoteprices every line and sums the subtotal. - It builds a
PricingContext(order, per-unit prices, subtotal, now). - It resolves each entered code through
PromotionRegistry.by_code. An unknown code raisesInvalidCoupon. A known code whose window or minimum fails also raises, with the reason. - It adds the automatic deals from
registry.automatic()and keeps the ones wherewhy_not(ctx)isNone. - It splits the candidates. From the exclusive ones it keeps the one with the largest
discount(ctx). All stackable ones apply. - Discounts with value 0 are dropped. Total =
max(0, subtotal - sum(discounts)).
3. Buy one, get one free. The promotion expands matching lines into units (quantity 3 = three units), sorts unit prices from high to low, and frees every second unit. So the customer always pays for the more expensive pizza of each pair, which is the usual store rule.
Promotion eligibility (why_not) is checked in this order; the first failure is the reason reported. A rule that then yields 0 (BOGO with one large) is dropped from the quote.
| Check | Fails when | Result for a coupon | Result for a deal |
|---|---|---|---|
| Window |
now outside [starts, ends)
|
InvalidCoupon("expired") |
skipped |
| Minimum | subtotal < min_subtotal
|
InvalidCoupon("minimum not met") |
skipped |
Code
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class Size(Enum):
SMALL = "S"; MEDIUM = "M"; LARGE = "L"
@dataclass(frozen=True)
class Crust:
name: str; surcharge: dict[Size, int] # cents per size
@dataclass(frozen=True)
class Topping:
name: str; category: str; price: dict[Size, int]
@dataclass
class Menu:
base_price: dict[Size, int]
crusts: dict[str, Crust] = field(default_factory=dict) # catalog by name
toppings: dict[str, Topping] = field(default_factory=dict)
@dataclass
class PizzaLine:
size: Size
crust: Crust
toppings: list[tuple[Topping, int]] = field(default_factory=list) # (topping, portions)
quantity: int = 1
def unit_price(self, menu: Menu) -> int:
if self.quantity < 1:
raise ValueError("quantity must be at least 1")
price = menu.base_price[self.size] + self.crust.surcharge[self.size]
for topping, portions in self.toppings:
price += topping.price[self.size] * portions # KeyError = menu gap
return price
@dataclass
class Order:
lines: list[PizzaLine]
@dataclass
class PricingContext:
order: Order
unit_prices: list[int] # parallel to order.lines
subtotal: int; now: datetime
class InvalidCoupon(Exception): ...
@dataclass
class Promotion(ABC):
name: str
code: str | None = None # None = automatic deal
stackable: bool = False
min_subtotal: int = 0
starts: datetime = datetime.min
ends: datetime = datetime.max
def why_not(self, ctx: PricingContext) -> str | None:
if not (self.starts <= ctx.now < self.ends):
return "expired or not started"
return "minimum not met" if ctx.subtotal < self.min_subtotal else None
@abstractmethod
def discount(self, ctx: PricingContext) -> int: ...
@dataclass
class PercentOff(Promotion):
percent: int = 0
def discount(self, ctx: PricingContext) -> int:
return ctx.subtotal * self.percent // 100
@dataclass
class AmountOff(Promotion):
amount: int = 0
def discount(self, ctx: PricingContext) -> int:
return min(self.amount, ctx.subtotal)
@dataclass
class BuyOneGetOneFree(Promotion):
size: Size = Size.LARGE
def discount(self, ctx: PricingContext) -> int:
units = sorted((p for ln, p in zip(ctx.order.lines, ctx.unit_prices)
if ln.size == self.size for _ in range(ln.quantity)), reverse=True)
return sum(units[1::2]) # every second, cheaper unit free
class PromotionRegistry:
def __init__(self) -> None:
self._by_code: dict[str, Promotion] = {}
self._auto: list[Promotion] = []
def add(self, promo: Promotion) -> None:
if promo.code:
self._by_code[promo.code.upper()] = promo
else:
self._auto.append(promo)
def by_code(self, code: str) -> Promotion:
promo = self._by_code.get(code.strip().upper())
if promo is None:
raise InvalidCoupon(f"unknown code {code!r}")
return promo
def automatic(self) -> list[Promotion]:
return list(self._auto)
@dataclass
class Quote:
line_totals: list[int]; subtotal: int
discounts: list[tuple[str, int]]; total: int
class PricingEngine:
def __init__(self, menu: Menu, promos: PromotionRegistry) -> None:
self.menu, self.promos = menu, promos
def quote(self, order: Order, codes: list[str], now: datetime) -> Quote:
units = [line.unit_price(self.menu) for line in order.lines]
totals = [u * line.quantity for u, line in zip(units, order.lines)]
ctx = PricingContext(order, units, sum(totals), now)
candidates: list[Promotion] = []
for code in dict.fromkeys(c.strip().upper() for c in codes): # dedupe
promo = self.promos.by_code(code)
if reason := promo.why_not(ctx):
raise InvalidCoupon(f"{code}: {reason}")
candidates.append(promo)
candidates += [p for p in self.promos.automatic() if p.why_not(ctx) is None]
scored = [(p, p.discount(ctx)) for p in candidates]
exclusive = [s for s in scored if not s[0].stackable]
chosen = [s for s in scored if s[0].stackable]
if exclusive:
chosen.append(max(exclusive, key=lambda s: s[1]))
applied = [(p.name, d) for p, d in chosen if d > 0]
total = max(0, ctx.subtotal - sum(d for _, d in applied))
return Quote(totals, ctx.subtotal, applied, total)
if __name__ == "__main__":
thin = Crust("thin", {s: 0 for s in Size}); stuffed = Crust("stuffed", {s: 200 for s in Size})
pepperoni = Topping("pepperoni", "meat", {Size.SMALL: 100, Size.MEDIUM: 150, Size.LARGE: 200})
menu = Menu({Size.SMALL: 800, Size.MEDIUM: 1000, Size.LARGE: 1200})
registry = PromotionRegistry()
registry.add(BuyOneGetOneFree("BOGO large", size=Size.LARGE))
registry.add(PercentOff("10% off $25+", code="SAVE10", min_subtotal=2500, percent=10))
registry.add(AmountOff("$2 off", code="TWO", stackable=True, amount=200))
order = Order([PizzaLine(Size.LARGE, stuffed, [(pepperoni, 2)], quantity=1),
PizzaLine(Size.LARGE, thin, quantity=1)])
q = PricingEngine(menu, registry).quote(order, ["save10", "TWO"], datetime.now())
print(q) # subtotal 3000; BOGO (1200) beats SAVE10 (300); +$2 stackable -> 1600
Design patterns
-
Strategy: each
Promotionsubclass is a pricing rule behind onediscount(ctx)interface. The engine never branches on promotion type, so a new deal is a new class plus aregistry.addcall. -
Template method:
Promotion.why_notholds the shared checks (window, minimum) that every rule inherits; subclasses supply only the rule itself. -
Registry:
PromotionRegistrydecouples where promotions come from (config, admin tool, database) from how they are applied, and owns coupon-code normalization. -
Data-driven catalog instead of a Decorator chain: toppings could be modeled as decorators wrapping a pizza, but that makes every new topping a class and makes "double cheese" awkward. Price tables keyed by
Sizekeep toppings as rows of data, which is what "easy to add toppings" really needs.
Extensions
-
Combo deals ("2 large + 1 soda for $25"): add a
ComboPricepromotion that matches line patterns in the context and returnsmatched_price - combo_price. When a menu has sides and drinks, generalizePizzaLineto anOrderLineinterface withunit_price(menu); promotions keep reading only the context. -
Per-customer or global redemption limits: add a
RedemptionStorewith an atomictry_redeem(code, customer_id)(a database row with a unique constraint, or a conditional update). Call it at checkout, not at quote time, so browsing never uses up a coupon. This is the one place that needs concurrency control; quoting itself is a pure function over immutable data and is safe to run on many threads. -
Optimal stacking across many exclusive groups: replace "best single exclusive" with exclusion groups (
group: str) and choose the best per group. If promotions interact (percent applied after amount off), give each apriorityand compute them in order against a running total. -
Tax and delivery fees: add
Chargesteps after discounts in the engine (a small pipeline: subtotal, discounts, fees, tax), each a strategy keyed by region, so the quote shows each step.
Interviewer expectations
Mid-level (L4)
- Clean classes for size, crust, topping, pizza and order, with a correct unit price.
- Promotions behind a common interface instead of
if/elifon type. - Uses integer cents, handles quantity, and keeps the total from going negative.
Senior (L5)
- Toppings and crusts as data with per-size prices; explains why not one class per topping or a deep decorator chain.
- Clear stacking rule (exclusive vs stackable) and explains the BOGO tie-break (cheaper unit free).
- Rejects bad coupons with reasons; separates quoting from redeeming; testable, deterministic engine (time passed in).
Staff (L6+)
- Treats promotions as configuration: loaded at runtime, versioned, with validity windows, and auditable via the quote's itemized discounts.
- Reasons about promotion interaction and ordering, and when to search for the best combination versus a fixed policy.
- Plans for redemption limits under concurrency, price consistency between quote and checkout (quote ID with expiry), and multi-store menus.
Make it yours
Write each chapter yourself, check it against this answer, then let the coach grill you.
