Don't open with a pattern. Ask five questions of the prompt, let the answers pick the pattern, and say the trade-off out loud.
A doctor doesn't walk in and announce an antibiotic. She asks where it hurts, since when, and what makes it better, and the answers pick the test. A design prompt works the same way, and the candidate who opens with "I'll use Strategy and Observer" is the doctor who has already decided. The marks go to the candidate who asks, hears the answer, and picks the pattern that the answer called for.
By now you have the tools: Strategy, Decorator, Observer, State, Command and the rest. The lessons you've seen so far began with a pattern and showed where it fits. An interview starts the other way round: a sentence of requirements, and you have to find the pattern. This lesson gives you the questions, then works one prompt live, a coupon engine, from the first minute to the trade-offs. You have met a coupon ladder before, in SOLID part 1 and in the refactoring lesson; there the code already existed and the job was to fix it. Here nothing exists yet, and the job is to decide.
All 22 case studies are the same exercise already done: each one's mistakes panel and patterns table record which question picked its pattern. The last section of this lesson lists them.
A doctor asks where it hurts before choosing a test; a candidate asks what varies and who listens before choosing a pattern.
The first pass: a ladder at minute ten#
Here is what most candidates write when the prompt says coupons and the clock is running. It works. Three codes, one function, an elif for each:
# coupons_v1.py: the first pass at minute ten, one function with an elif for every code
USED = {} # code -> times it gave a discount
AUDIT = [] # one line per applied code
def discount(code, subtotal):
if code == "FLAT100":
d = min(100, subtotal)
elif code == "SAVE10":
d = min(subtotal * 10 // 100, 80)
elif code == "BIG200":
d = 200 if subtotal >= 1000 else 0
else:
raise ValueError("unknown coupon: " + code)
if d > 0:
USED[code] = USED.get(code, 0) + 1
AUDIT.append(code + " " + str(d))
return d
if __name__ == "__main__":
for code, subtotal in [("FLAT100", 1140), ("SAVE10", 1140), ("BIG200", 1140), ("BIG200", 340)]:
print(f"{code} on {subtotal}: {discount(code, subtotal)}")
try:
discount("GOLD", 1140)
except ValueError as e:
print("rejected:", e)
print("used:", ", ".join(f"{c} {n}" for c, n in sorted(USED.items())))
print("audit lines:", len(AUDIT))// CouponsV1.java: the first pass at minute ten, one method with an else-if for every code
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
class CouponsV1 {
static final Map<String, Integer> USED = new TreeMap<>(); // code -> times it gave a discount
static final List<String> AUDIT = new ArrayList<>(); // one line per applied code
static int discount(String code, int subtotal) {
int d;
if (code.equals("FLAT100")) {
d = Math.min(100, subtotal);
} else if (code.equals("SAVE10")) {
d = Math.min(subtotal * 10 / 100, 80);
} else if (code.equals("BIG200")) {
d = subtotal >= 1000 ? 200 : 0;
} else {
throw new IllegalArgumentException("unknown coupon: " + code);
}
if (d > 0) USED.merge(code, 1, Integer::sum);
AUDIT.add(code + " " + d);
return d;
}
public static void main(String[] args) {
String[] codes = {"FLAT100", "SAVE10", "BIG200", "BIG200"};
int[] subtotals = {1140, 1140, 1140, 340};
for (int i = 0; i < codes.length; i++) {
System.out.println(codes[i] + " on " + subtotals[i] + ": " + discount(codes[i], subtotals[i]));
}
try {
discount("GOLD", 1140);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, Integer> e : USED.entrySet()) parts.add(e.getKey() + " " + e.getValue());
System.out.println("used: " + String.join(", ", parts));
System.out.println("audit lines: " + AUDIT.size());
}
}// coupons_v1.cpp: the first pass at minute ten, one function with an else-if for every code
#include <algorithm>
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
std::map<std::string, int> USED; // code -> times it gave a discount (a sorted map)
std::vector<std::string> AUDIT; // one line per applied code
int discount(const std::string& code, int subtotal) {
int d;
if (code == "FLAT100") {
d = std::min(100, subtotal);
} else if (code == "SAVE10") {
d = std::min(subtotal * 10 / 100, 80);
} else if (code == "BIG200") {
d = subtotal >= 1000 ? 200 : 0;
} else {
throw std::invalid_argument("unknown coupon: " + code);
}
if (d > 0) USED[code] += 1;
AUDIT.push_back(code + " " + std::to_string(d));
return d;
}
int main() {
std::vector<std::pair<std::string, int>> runs = {{"FLAT100", 1140}, {"SAVE10", 1140}, {"BIG200", 1140}, {"BIG200", 340}};
for (const auto& run : runs) {
std::cout << run.first << " on " << run.second << ": " << discount(run.first, run.second) << "\n";
}
try {
discount("GOLD", 1140);
} catch (const std::invalid_argument& e) {
std::cout << "rejected: " << e.what() << "\n";
}
std::string used;
for (const auto& entry : USED) used += (used.empty() ? "" : ", ") + entry.first + " " + std::to_string(entry.second);
std::cout << "used: " << used << "\n";
std::cout << "audit lines: " << AUDIT.size() << "\n";
}// couponsV1.js: the first pass at minute ten, one function with an else-if for every code
const USED = {}; // code -> times it gave a discount
const AUDIT = []; // one line per applied code
function discount(code, subtotal) {
let d;
if (code === "FLAT100") {
d = Math.min(100, subtotal);
} else if (code === "SAVE10") {
d = Math.min(Math.floor((subtotal * 10) / 100), 80);
} else if (code === "BIG200") {
d = subtotal >= 1000 ? 200 : 0;
} else {
throw new Error("unknown coupon: " + code);
}
if (d > 0) USED[code] = (USED[code] || 0) + 1;
AUDIT.push(code + " " + d);
return d;
}
for (const [code, subtotal] of [["FLAT100", 1140], ["SAVE10", 1140], ["BIG200", 1140], ["BIG200", 340]]) {
console.log(`${code} on ${subtotal}: ${discount(code, subtotal)}`);
}
try {
discount("GOLD", 1140);
} catch (e) {
console.log("rejected:", e.message);
}
console.log("used:", Object.keys(USED).sort().map((c) => `${c} ${USED[c]}`).join(", "));
console.log("audit lines:", AUDIT.length);FLAT100 on 1140: 100
SAVE10 on 1140: 80
BIG200 on 1140: 200
BIG200 on 340: 0
rejected: unknown coupon: GOLD
used: BIG200 1, FLAT100 1, SAVE10 1
audit lines: 4The rules: FLAT100 takes 100 off (never more than the cart); SAVE10 takes 10 percent off, up to 80; BIG200 takes 200 off but only on carts of 1000 or more. On the 1140-rupee cart, 10 percent is 114, so the cap holds it to 80; on the 340-rupee cart, BIG200 gives nothing.
Nothing is wrong yet. But look at what is in the function. Three different jobs share it, and the output shows two of them:
- The rules are the
ifandelifbranches. Marketing will add one every month, and each one edits this function. - The usage count (
USED) is bookkeeping for finance. It is updated inside the pricing function, so a pricing change can break it. - The audit trail (
AUDIT) is for support. It is written from the same place, and a new consumer, say a dashboard, would be a third line here. - The cap and the minimum are copied into each rule that needs them, and the next rule that needs a cap writes its own.
Three jobs in one function
Look at discount() again. What are the separate jobs hiding in it, and which of them will a requirement change first?
One strong answer
Pricing a rule, counting usage and writing the audit trail. The rule changes first: marketing adds kinds of coupon all the time, while the usage count and the audit line change rarely. That is a "what varies?" answer, and it points at a strategy. The count and the audit trail are reactions to a coupon being applied, which is a "who listens?" answer, and points at an observer. A good design separates the three.
Five questions#
Patterns are answers to questions. Ask the question, listen to the prompt's answer, and only then pick. These five cover almost everything the case studies use:
| Ask | A "yes" sounds like | Pattern | Lesson | Seen in |
|---|---|---|---|---|
| What varies? | "The rule will change", "several ways to do X" | Strategy | dp-16 | parking lot (PricingStrategy) |
| What stacks on top? | "Surge on any fare", "a cap on any rule" | Decorator | dp-11 | ride sharing (SurgeFare) |
| What has a lifecycle? | "An order is placed, then accepted, then delivered" | State | dp-19 | vending machine (Idle, HasMoney) |
| Who listens? | "Notify the customer, the kitchen and the rider" | Observer | dp-17 | auction (AuctionListener) |
| What's undoable? | "Undo", "replay", "queue it", "log every request" | Command | dp-18 | chess (Move and Undo) |
The first two decide the skeleton, so ask them first. What varies gives you the interface; what stacks tells you whether the variation needs wrappers. The other three are about time: what the object goes through, who must hear about it, and whether you can take it back.
A second, shorter list picks the supporting patterns when the prompt says so: a name or config turns into an object (a factory, as in the rate limiter); a foreign system you can't change (an adapter, as in the ATM's Bank); one door over many parts (a facade, as in movie booking); a tree of parts (a composite, as in the file system).
Say the question out loud. "What varies here? The discount rule, I think." That one sentence tells the interviewer what you are doing, and gives them the chance to correct you before you have written a line.
Five questions pick the pattern: what varies, what stacks on top, what has a lifecycle, who listens, and what is undoable.
The coupon engine, worked live#
The prompt, as an interviewer would say it: "Design a coupon engine for a shop's checkout. A customer enters a code, and the engine says how much to take off the cart. We have flat amounts, percentages with a cap, and codes that need a minimum cart, and marketing adds new kinds all the time. Finance wants to know how often each code is used."
Minutes 0 to 5: ask. Questions whose answers change the code:
| You ask | They answer | What it changes |
|---|---|---|
| One coupon per order, or several? | One for now. | The engine takes one code; stacking is a follow-up |
| Which kinds exist today? | Flat off, percent off with a cap, and "only on carts over N". | The rules differ (question 1), and the cap and the minimum apply to any rule (question 2) |
| Who needs to know a coupon was used? | Finance wants counts; support wants an audit trail. | Several parties react (question 4) |
| Can a coupon be taken back if an order is cancelled? | Not now. | Question 5 answered no: no Command, but say so |
| Does a coupon have a life: draft, live, expired? | Marketing switches codes on and off by editing the catalog. | Question 3 answered no: a flag in the catalog, not a state machine |
| Money and carts? | Whole rupees; the checkout passes the cart in. | Integer rupees, a Cart with a subtotal() |
Minutes 5 to 45: three decisions, in the order the requirements asked.
- What varies? The rule.
Couponis an interface with one method,discount(cart);FlatOffandPercentOffimplement it. A catalog maps each code to a coupon, and a new kind is a new class. Cost: a class per kind of rule and one place that maps codes to rules, instead of one function. Chosen because: marketing said it would add kinds all the time. - What stacks on top? The cap and the minimum. They apply to any rule, including ones not written yet, so they are wrappers with the same interface:
Capped(inner, cap)andMinimumCart(minimum, inner). Cost: wrappers compose in the order you write them, so test the order you ship. Alternative rejected: acapfield on every rule, which every new rule has to remember to honour. - Who listens? Finance and support. The engine publishes an event for every code it looks at, and a usage counter and an audit log subscribe. Cost: listeners run in the caller's thread, so a slow one slows the discount, and a failing one must not lose it: the engine catches the error, counts it, and moves on to the next listener. Alternative rejected: counting inside
discount, which is the first pass again.
Here is the whole engine, with the three decisions in place:
# coupon_engine.py: a rule per class (Strategy), wrappers for cap and minimum (Decorator), listeners (Observer)
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict, List
@dataclass(frozen=True)
class Item:
name: str
price: int # rupees
qty: int
@dataclass(frozen=True)
class Cart:
items: List[Item]
def subtotal(self) -> int:
return sum(i.price * i.qty for i in self.items)
class Coupon(ABC):
"""Strategy: one job (how much off this cart), many ways to do it."""
@abstractmethod
def discount(self, cart: Cart) -> int: ...
class FlatOff(Coupon):
def __init__(self, amount: int) -> None:
self.amount = amount
def discount(self, cart: Cart) -> int:
return min(self.amount, cart.subtotal())
class PercentOff(Coupon):
def __init__(self, percent: int) -> None:
self.percent = percent
def discount(self, cart: Cart) -> int:
return cart.subtotal() * self.percent // 100
class Capped(Coupon):
"""Decorator: any coupon, but never more than the cap."""
def __init__(self, inner: Coupon, cap: int) -> None:
self.inner, self.cap = inner, cap
def discount(self, cart: Cart) -> int:
return min(self.inner.discount(cart), self.cap)
class MinimumCart(Coupon):
"""Decorator: any coupon, but only on carts of at least the minimum."""
def __init__(self, minimum: int, inner: Coupon) -> None:
self.minimum, self.inner = minimum, inner
def discount(self, cart: Cart) -> int:
return self.inner.discount(cart) if cart.subtotal() >= self.minimum else 0
@dataclass(frozen=True)
class CouponEvent:
code: str
discount: int
rejected: bool
class CouponEngine:
"""Looks a code up, asks the coupon, and tells the listeners."""
def __init__(self, catalog: Dict[str, Coupon]) -> None:
self.catalog, self.listeners, self.listener_errors = catalog, [], 0
def subscribe(self, listener) -> None:
self.listeners.append(listener)
def discount(self, code: str, cart: Cart) -> int:
coupon = self.catalog.get(code)
if coupon is None:
self._tell(CouponEvent(code, 0, True))
raise ValueError("unknown coupon: " + code)
d = coupon.discount(cart)
self._tell(CouponEvent(code, d, False))
return d
def _tell(self, event: CouponEvent) -> None:
for listener in self.listeners:
try:
listener.on_coupon(event)
except Exception: # one broken listener must not lose the discount or silence the rest
self.listener_errors += 1
class UsageCounter:
def __init__(self) -> None:
self.used: Dict[str, int] = {}
def on_coupon(self, event: CouponEvent) -> None:
if event.discount > 0:
self.used[event.code] = self.used.get(event.code, 0) + 1
class AuditLog:
def __init__(self) -> None:
self.lines: List[str] = []
def on_coupon(self, event: CouponEvent) -> None:
self.lines.append(event.code + (" rejected" if event.rejected else " " + str(event.discount)))
class Flaky:
def on_coupon(self, event: CouponEvent) -> None:
raise RuntimeError("the dashboard is down")
if __name__ == "__main__":
catalog = {
"FLAT100": FlatOff(100),
"SAVE10": Capped(PercentOff(10), 80),
"BIG200": MinimumCart(1000, FlatOff(200)),
}
engine = CouponEngine(catalog)
usage, audit = UsageCounter(), AuditLog()
for listener in (Flaky(), usage, audit):
engine.subscribe(listener)
big = Cart([Item("notebook", 120, 2), Item("pen", 25, 4), Item("bag", 800, 1)])
small = Cart([Item("notebook", 120, 2), Item("pen", 25, 4)])
for code, cart in [("FLAT100", big), ("SAVE10", big), ("BIG200", big), ("BIG200", small)]:
print(f"{code} on {cart.subtotal()}: {engine.discount(code, cart)}")
try:
engine.discount("GOLD", big)
except ValueError as e:
print("rejected:", e)
print("used:", ", ".join(f"{c} {n}" for c, n in sorted(usage.used.items())))
print("audit:", " | ".join(audit.lines))
print(f"listener errors: {engine.listener_errors}, and every discount was still returned")// CouponEngine.java: a rule per class (Strategy), wrappers for cap and minimum (Decorator), listeners (Observer)
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
class CouponEngine {
record Item(String name, int price, int qty) {} // rupees
record Cart(List<Item> items) {
int subtotal() {
int sum = 0;
for (Item i : items) sum += i.price() * i.qty();
return sum;
}
}
/** Strategy: one job (how much off this cart), many ways to do it. */
interface Coupon {
int discount(Cart cart);
}
static final class FlatOff implements Coupon {
private final int amount;
FlatOff(int amount) { this.amount = amount; }
public int discount(Cart cart) { return Math.min(amount, cart.subtotal()); }
}
static final class PercentOff implements Coupon {
private final int percent;
PercentOff(int percent) { this.percent = percent; }
public int discount(Cart cart) { return cart.subtotal() * percent / 100; }
}
/** Decorator: any coupon, but never more than the cap. */
static final class Capped implements Coupon {
private final Coupon inner;
private final int cap;
Capped(Coupon inner, int cap) { this.inner = inner; this.cap = cap; }
public int discount(Cart cart) { return Math.min(inner.discount(cart), cap); }
}
/** Decorator: any coupon, but only on carts of at least the minimum. */
static final class MinimumCart implements Coupon {
private final int minimum;
private final Coupon inner;
MinimumCart(int minimum, Coupon inner) { this.minimum = minimum; this.inner = inner; }
public int discount(Cart cart) { return cart.subtotal() >= minimum ? inner.discount(cart) : 0; }
}
record CouponEvent(String code, int discount, boolean rejected) {}
interface Listener {
void onCoupon(CouponEvent event);
}
/** Looks a code up, asks the coupon, and tells the listeners. */
static final class Engine {
private final Map<String, Coupon> catalog;
private final List<Listener> listeners = new ArrayList<>();
int listenerErrors = 0;
Engine(Map<String, Coupon> catalog) { this.catalog = catalog; }
void subscribe(Listener listener) { listeners.add(listener); }
int discount(String code, Cart cart) {
Coupon coupon = catalog.get(code);
if (coupon == null) {
tell(new CouponEvent(code, 0, true));
throw new IllegalArgumentException("unknown coupon: " + code);
}
int d = coupon.discount(cart);
tell(new CouponEvent(code, d, false));
return d;
}
private void tell(CouponEvent event) {
for (Listener listener : listeners) {
try {
listener.onCoupon(event);
} catch (RuntimeException e) { // one broken listener must not lose the discount or silence the rest
listenerErrors++;
}
}
}
}
static final class UsageCounter implements Listener {
final Map<String, Integer> used = new TreeMap<>();
public void onCoupon(CouponEvent event) {
if (event.discount() > 0) used.merge(event.code(), 1, Integer::sum);
}
}
static final class AuditLog implements Listener {
final List<String> lines = new ArrayList<>();
public void onCoupon(CouponEvent event) {
lines.add(event.code() + (event.rejected() ? " rejected" : " " + event.discount()));
}
}
public static void main(String[] args) {
Map<String, Coupon> catalog = Map.of(
"FLAT100", new FlatOff(100),
"SAVE10", new Capped(new PercentOff(10), 80),
"BIG200", new MinimumCart(1000, new FlatOff(200)));
Engine engine = new Engine(catalog);
UsageCounter usage = new UsageCounter();
AuditLog audit = new AuditLog();
engine.subscribe(event -> { throw new RuntimeException("the dashboard is down"); });
engine.subscribe(usage);
engine.subscribe(audit);
Cart big = new Cart(List.of(new Item("notebook", 120, 2), new Item("pen", 25, 4), new Item("bag", 800, 1)));
Cart small = new Cart(List.of(new Item("notebook", 120, 2), new Item("pen", 25, 4)));
String[] codes = {"FLAT100", "SAVE10", "BIG200", "BIG200"};
Cart[] carts = {big, big, big, small};
for (int i = 0; i < codes.length; i++) {
System.out.println(codes[i] + " on " + carts[i].subtotal() + ": " + engine.discount(codes[i], carts[i]));
}
try {
engine.discount("GOLD", big);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, Integer> e : usage.used.entrySet()) parts.add(e.getKey() + " " + e.getValue());
System.out.println("used: " + String.join(", ", parts));
System.out.println("audit: " + String.join(" | ", audit.lines));
System.out.println("listener errors: " + engine.listenerErrors + ", and every discount was still returned");
}
}// coupon_engine.cpp: a rule per class (Strategy), wrappers for cap and minimum (Decorator), listeners (Observer)
#include <algorithm>
#include <iostream>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
struct Item {
std::string name;
int price; // rupees
int qty;
};
struct Cart {
std::vector<Item> items;
int subtotal() const {
int sum = 0;
for (const Item& i : items) sum += i.price * i.qty;
return sum;
}
};
// Strategy: one job (how much off this cart), many ways to do it.
class Coupon {
public:
virtual ~Coupon() = default;
virtual int discount(const Cart& cart) const = 0;
};
class FlatOff : public Coupon {
int amount_;
public:
explicit FlatOff(int amount) : amount_(amount) {}
int discount(const Cart& cart) const override { return std::min(amount_, cart.subtotal()); }
};
class PercentOff : public Coupon {
int percent_;
public:
explicit PercentOff(int percent) : percent_(percent) {}
int discount(const Cart& cart) const override { return cart.subtotal() * percent_ / 100; }
};
// Decorator: any coupon, but never more than the cap.
class Capped : public Coupon {
std::shared_ptr<const Coupon> inner_;
int cap_;
public:
Capped(std::shared_ptr<const Coupon> inner, int cap) : inner_(std::move(inner)), cap_(cap) {}
int discount(const Cart& cart) const override { return std::min(inner_->discount(cart), cap_); }
};
// Decorator: any coupon, but only on carts of at least the minimum.
class MinimumCart : public Coupon {
int minimum_;
std::shared_ptr<const Coupon> inner_;
public:
MinimumCart(int minimum, std::shared_ptr<const Coupon> inner) : minimum_(minimum), inner_(std::move(inner)) {}
int discount(const Cart& cart) const override { return cart.subtotal() >= minimum_ ? inner_->discount(cart) : 0; }
};
struct CouponEvent {
std::string code;
int discount;
bool rejected;
};
class Listener {
public:
virtual ~Listener() = default;
virtual void onCoupon(const CouponEvent& event) = 0;
};
// Looks a code up, asks the coupon, and tells the listeners.
class CouponEngine {
std::map<std::string, std::shared_ptr<const Coupon>> catalog_;
std::vector<Listener*> listeners_;
void tell(const CouponEvent& event) {
for (Listener* listener : listeners_) {
try {
listener->onCoupon(event);
} catch (const std::exception&) { // one broken listener must not lose the discount or silence the rest
listenerErrors++;
}
}
}
public:
int listenerErrors = 0;
explicit CouponEngine(std::map<std::string, std::shared_ptr<const Coupon>> catalog) : catalog_(std::move(catalog)) {}
void subscribe(Listener& listener) { listeners_.push_back(&listener); }
int discount(const std::string& code, const Cart& cart) {
auto it = catalog_.find(code);
if (it == catalog_.end()) {
tell({code, 0, true});
throw std::invalid_argument("unknown coupon: " + code);
}
int d = it->second->discount(cart);
tell({code, d, false});
return d;
}
};
class UsageCounter : public Listener {
public:
std::map<std::string, int> used; // a sorted map
void onCoupon(const CouponEvent& event) override {
if (event.discount > 0) used[event.code] += 1;
}
};
class AuditLog : public Listener {
public:
std::vector<std::string> lines;
void onCoupon(const CouponEvent& event) override {
lines.push_back(event.code + (event.rejected ? std::string(" rejected") : " " + std::to_string(event.discount)));
}
};
class Flaky : public Listener {
public:
void onCoupon(const CouponEvent&) override { throw std::runtime_error("the dashboard is down"); }
};
int main() {
CouponEngine engine({
{"FLAT100", std::make_shared<FlatOff>(100)},
{"SAVE10", std::make_shared<Capped>(std::make_shared<PercentOff>(10), 80)},
{"BIG200", std::make_shared<MinimumCart>(1000, std::make_shared<FlatOff>(200))},
});
Flaky flaky;
UsageCounter usage;
AuditLog audit;
engine.subscribe(flaky);
engine.subscribe(usage);
engine.subscribe(audit);
Cart big{{{"notebook", 120, 2}, {"pen", 25, 4}, {"bag", 800, 1}}};
Cart small{{{"notebook", 120, 2}, {"pen", 25, 4}}};
std::vector<std::pair<std::string, const Cart*>> runs = {{"FLAT100", &big}, {"SAVE10", &big}, {"BIG200", &big}, {"BIG200", &small}};
for (const auto& run : runs) {
std::cout << run.first << " on " << run.second->subtotal() << ": " << engine.discount(run.first, *run.second) << "\n";
}
try {
engine.discount("GOLD", big);
} catch (const std::invalid_argument& e) {
std::cout << "rejected: " << e.what() << "\n";
}
std::string used, log;
for (const auto& entry : usage.used) used += (used.empty() ? "" : ", ") + entry.first + " " + std::to_string(entry.second);
for (const auto& line : audit.lines) log += (log.empty() ? "" : " | ") + line;
std::cout << "used: " << used << "\n";
std::cout << "audit: " << log << "\n";
std::cout << "listener errors: " << engine.listenerErrors << ", and every discount was still returned\n";
}// couponEngine.js: a rule per class (Strategy), wrappers for cap and minimum (Decorator), listeners (Observer)
// An item is { name, price, qty } with whole rupees; a Cart wraps a list of items.
class Cart {
constructor(items) {
this.items = items;
}
subtotal() {
return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
}
// Strategy: any object with discount(cart). JavaScript has no interfaces, so the contract is the method name.
class FlatOff {
constructor(amount) {
this.amount = amount;
}
discount(cart) {
return Math.min(this.amount, cart.subtotal());
}
}
class PercentOff {
constructor(percent) {
this.percent = percent;
}
discount(cart) {
return Math.floor((cart.subtotal() * this.percent) / 100);
}
}
/** Decorator: any coupon, but never more than the cap. */
class Capped {
constructor(inner, cap) {
Object.assign(this, { inner, cap });
}
discount(cart) {
return Math.min(this.inner.discount(cart), this.cap);
}
}
/** Decorator: any coupon, but only on carts of at least the minimum. */
class MinimumCart {
constructor(minimum, inner) {
Object.assign(this, { minimum, inner });
}
discount(cart) {
return cart.subtotal() >= this.minimum ? this.inner.discount(cart) : 0;
}
}
/** Looks a code up, asks the coupon, and tells the listeners. */
class CouponEngine {
constructor(catalog) {
this.catalog = catalog;
this.listeners = [];
this.listenerErrors = 0;
}
subscribe(listener) {
this.listeners.push(listener);
}
discount(code, cart) {
const coupon = this.catalog.get(code);
if (!coupon) {
this.#tell({ code, discount: 0, rejected: true });
throw new Error("unknown coupon: " + code);
}
const d = coupon.discount(cart);
this.#tell({ code, discount: d, rejected: false });
return d;
}
#tell(event) {
for (const listener of this.listeners) {
try {
listener.onCoupon(event);
} catch (e) {
this.listenerErrors++; // one broken listener must not lose the discount or silence the rest
}
}
}
}
class UsageCounter {
used = new Map();
onCoupon(event) {
if (event.discount > 0) this.used.set(event.code, (this.used.get(event.code) || 0) + 1);
}
}
class AuditLog {
lines = [];
onCoupon(event) {
this.lines.push(event.code + (event.rejected ? " rejected" : " " + event.discount));
}
}
class Flaky {
onCoupon(event) {
throw new Error("the dashboard is down");
}
}
const catalog = new Map([
["FLAT100", new FlatOff(100)],
["SAVE10", new Capped(new PercentOff(10), 80)],
["BIG200", new MinimumCart(1000, new FlatOff(200))],
]);
const engine = new CouponEngine(catalog);
const usage = new UsageCounter();
const audit = new AuditLog();
for (const listener of [new Flaky(), usage, audit]) engine.subscribe(listener);
const big = new Cart([{ name: "notebook", price: 120, qty: 2 }, { name: "pen", price: 25, qty: 4 }, { name: "bag", price: 800, qty: 1 }]);
const small = new Cart([{ name: "notebook", price: 120, qty: 2 }, { name: "pen", price: 25, qty: 4 }]);
for (const [code, cart] of [["FLAT100", big], ["SAVE10", big], ["BIG200", big], ["BIG200", small]]) {
console.log(`${code} on ${cart.subtotal()}: ${engine.discount(code, cart)}`);
}
try {
engine.discount("GOLD", big);
} catch (e) {
console.log("rejected:", e.message);
}
console.log("used:", [...usage.used.keys()].sort().map((c) => `${c} ${usage.used.get(c)}`).join(", "));
console.log("audit:", audit.lines.join(" | "));
console.log(`listener errors: ${engine.listenerErrors}, and every discount was still returned`);All four print the same thing:
FLAT100 on 1140: 100
SAVE10 on 1140: 80
BIG200 on 1140: 200
BIG200 on 340: 0
rejected: unknown coupon: GOLD
used: BIG200 1, FLAT100 1, SAVE10 1
audit: FLAT100 100 | SAVE10 80 | BIG200 200 | BIG200 0 | GOLD rejected
listener errors: 5, and every discount was still returnedCheck one line by hand. SAVE10 is Capped(PercentOff(10), 80): 10 percent of 1140 is 114, and the cap holds it to 80. BIG200 on the 340-rupee cart is MinimumCart(1000, FlatOff(200)) asked about a cart under 1000, so it answers 0; the usage counter skips it because nothing was taken off, and the audit log records it as BIG200 0. The unknown code GOLD is rejected, and the audit log still records that, because listeners hear about rejections too. The Flaky listener throws on all 5 events, the engine counts 5 errors, and all 4 discounts still come back.
%% title: The coupon engine: three decisions
classDiagram
class CouponEngine {
+subscribe(listener)
+discount(code, cart) int
}
class Coupon {
<<interface>>
+discount(cart) int
}
class FlatOff
class PercentOff
class Capped {
-inner Coupon
-cap int
}
class MinimumCart {
-minimum int
-inner Coupon
}
class Listener {
<<interface>>
+on_coupon(event)
}
class UsageCounter
class AuditLog
CouponEngine --> Coupon : asks
CouponEngine --> Listener : tells
Coupon <|.. FlatOff
Coupon <|.. PercentOff
Coupon <|.. Capped
Coupon <|.. MinimumCart
Capped o-- Coupon : wraps
MinimumCart o-- Coupon : wraps
Listener <|.. UsageCounter
Listener <|.. AuditLogRead the arrows. The engine points at two interfaces, never at a concrete rule or listener. Capped and MinimumCart both implement Coupon and hold a Coupon, which is the shape that lets wrappers stack on top of any rule, including each other. The three decisions are the three groups: the rules, the wrappers and the listeners.
What each language brings to the same design:
- Python:
ABCforCoupon, frozen dataclasses forItem,CartandCouponEvent. Listeners are any object withon_coupon; the engine does not check, so a misspelt method would be caught only by the engine's ownexcept, and shows up as an error count. - Java:
records for the values and aninterfaceforCoupon.Listenerhas one method, so the flaky listener is a lambda. - C++: wrappers hold
shared_ptr<const Coupon>because the catalog and the wrapper share one inner rule. The engine keeps raw pointers to listeners, so the listeners must outlive it; heremainowns them.std::mapkeeps the "used" output sorted. - JavaScript: no interfaces, so a coupon is any object with
discountand a listener is any object withonCoupon.#tellis a private method.
The coupon engine took its patterns one at a time: Strategy for the rule, Decorator for the cap and minimum, Observer for finance and support.
What I left out, and why#
Half of choosing is saying what you did not choose. Four patterns were on the table and stayed off it:
| Pattern | The question | Why it stayed out | What would bring it in |
|---|---|---|---|
| State | What has a lifecycle? | A coupon here is on or off in the catalog: one flag, not a machine | Draft, scheduled, live, paused and expired, each allowing different things: a transition table (dp-19) |
| Command | What's undoable? | Nobody asked to take a coupon back | "Cancel the order and give the coupon back": keep each applied coupon as an object with an undo (dp-18) |
| Factory | What is built from a name or config? | The catalog is written in code, so the constructors already are the registry | Marketing edits a file: parse rows into coupons (dp-07) |
| Facade | One door over many parts? | The engine is already the one door | Checkout needs coupons, stock and payment behind one call (dp-12) |
The vending machine has the line for this: for a change-making rule the operator might want, say it; don't build it unasked. Saying it costs ten seconds and shows you saw the pattern. Building it costs ten minutes and gives the interviewer a class to question you about.
Saying the trade-off#
Naming a pattern is not the answer. The answer is the pattern and its cost. The parking lot case study puts it directly: "I made pricing a strategy because the tariff is the thing the business changes most, and I want a new tariff to be a new class, not an if in leave()." That sentence scores more than naming five patterns you didn't need. A template that works for all of them:
I chose X because Y varies. The cost is Z. I'd change it if W.
For the coupon engine:
- Strategy: "because the rule changes monthly; the cost is a class per kind and a catalog; I'd collapse two kinds into one class with a setting if they only differed by a number."
- Decorator: "because the cap and the minimum apply to any rule; the cost is that wrapper order matters, so I'd test the order we ship; I'd fold a wrapper into a rule if only one rule ever used it."
- Observer: "because finance and support react to every use; the cost is that listeners run on the caller's thread, so I catch their errors; I'd put a queue between the engine and slow listeners if one became slow."
Choosing patterns and their look-alikes#
Four pairs cause most wrong choices:
| Pair | The test |
|---|---|
| Strategy or Decorator | Does it replace the rule (FlatOff instead of PercentOff) or add to any rule (Capped around either)? Replace is Strategy; add is Decorator. |
| Observer or Command | Does it tell parties what already happened (a usage count), or is it a request you may queue, retry or undo? Telling is Observer; a request object is Command. |
| State or Strategy | Who changes it: the object itself as events arrive (State), or something outside (Strategy). |
| Decorator or a chain | MinimumCart can skip its inner rule, which a handler in a chain does too. The difference: a chain passes one request down a line of different handlers, and the first to handle it stops it; a decorator wraps one rule. |
When not to use a pattern#
- When no question answered yes. A prompt with no varying rule, no lifecycle and no listeners needs plain classes and a clear name. Three honest classes beat a pattern diagram.
- When the answer is a number. Cap of 80 and cap of 100 are one class with a setting, not two.
- When you are matching the interviewer's keywords instead of the prompt. If they say "use Observer" and nothing listens, ask who the listeners are. If there are none, say so politely.
- When the clock is short. Two patterns, run and tested, beat five in a diagram. Build the first, say the others.
Where it shows up in the case studies#
Each of the 22 rounds is a prompt that was already answered by these questions. The table gives the question that picked each round's main patterns:
| Round | The question | The answer, in the round's classes |
|---|---|---|
| Parking lot | What varies? | PricingStrategy, SpotAssignmentStrategy |
| LRU cache | What varies? | EvictionPolicy with LRUPolicy, FIFOPolicy |
| Rate limiter | What varies? Built from config? | RateLimiter per algorithm; FACTORIES |
| Elevator | What varies? Who listens? | DispatchStrategy; listeners on the controller |
| Movie booking | A lifecycle? A system we swap? | BookingStatus; SeatLockProvider |
| Splitwise | What varies? | SplitStrategy |
| Vending machine | A lifecycle? | Idle, HasMoney, Dispensing, SoldOut |
| Pub-sub logger | A line of checks? Who listens? What stacks? | LevelFilter then Redactor; LogBus; AsyncSink |
| Snake and ladder | What varies? Who listens? | Dice, EndRule; Game.listeners |
| Chess | What's undoable? | Move and Undo |
| ATM | A system we can't change? A lifecycle? | Bank; AtmState |
| Library | What varies? Who listens? | FinePolicy; Notifier |
| Hotel | What varies? A lifecycle? | PricingRule, CancellationPolicy; BookingStatus |
| Ride sharing | What stacks on top? | SurgeFare around any FareStrategy |
| Food delivery | A lifecycle? A system we can't change? | OrderStatus with ALLOWED; PaymentGateway |
| Meeting rooms | What varies? | RoomSelector |
| File system | A tree of parts? | Node, File, Directory |
| Text editor | What's undoable? A snapshot? | Insert, Delete; Snapshot |
| Auction | A lifecycle? Who listens? What varies? | AuctionState; AuctionListener; IncrementPolicy |
| Order book | What's queued and replayed? | Exchange._inbox, one writer per book |
| Task scheduler | What varies? | RetryPolicy per task |
| Key-value store | What varies? | EvictionPolicy |
The last lesson, the pattern map, puts all 22 rounds against every pattern in one table.
In the interview#
Say the choice with its reason, in one breath: "The discount rule is what marketing changes, so it's a strategy; the cap and the minimum apply to any rule, so they're wrappers; finance and support react to every use, so the engine publishes events. I'm leaving State and Command out until you tell me coupons have a life or can be undone." That sentence names three patterns with their reasons and two with their conditions.
Then expect the follow-ups:
- "Customers can stack two coupons." The engine takes a list of codes, and the question is what order and what limits. A coupon that combines others is one more wrapper, and the cap can sit outside both. Decide the order out loud, and say which one you tested.
- "A code can be used once per customer." That is data, not a state machine: a set of (customer, code) pairs checked before applying and written after, under one lock so two requests cannot both pass. A listener is the wrong place for the check, because a listener hears about something after it happened.
- "Cancelling the order gives the coupon back." Now question 5 answers yes: keep each applied coupon as a command with an
undo, or at least a record you can reverse. - "Marketing wants to edit coupons without a deploy." The catalog is built from rows in a file or a table: a factory that turns a row into a rule, with validation at load time.
- "Why not State for the coupon's life?" Because one flag does the job today. If it grew draft, scheduled, live, paused and expired, a transition table would be the first move.
Common mistakes#
- Opening with the list of patterns. The interviewer hears a plan before you have heard the prompt.
- Adding a pattern for every question. A question that answers no gets a sentence, not a class.
- Naming the pattern without the reason. "I'll use Decorator" scores less than "the cap applies to any rule, so it's a wrapper".
- Never saying the cost. Every pattern costs something; saying it shows judgement.
- Putting the check in a listener. Observers hear after the fact. A rule that must stop the action belongs before it.
- Letting one listener break the rest. A failing subscriber must not lose the result or silence the others.
Opening with a list of patterns tells the interviewer you decided before hearing the prompt; opening with questions shows what you are solving.
Choosing patterns
Q1/4 The prompt says "marketing adds a new kind of discount every month". Which question does that answer, and what follows?
Answer & explanation
Answer: B
A rule that keeps changing, with several ways to do one job, is the "what varies?" answer, and the pattern for it is Strategy. The other questions are about time: lifecycle, listeners and undo.
Q2/4 A cap and a minimum cart must work with any coupon rule, including ones not written yet. Which design fits?
Answer & explanation
Answer: C
They add to any rule rather than replacing it, which is the Decorator shape. A field on every rule makes each new rule remember to honour it, and a subclass per combination multiplies classes.
Q3/4 The usage counter listener throws an error while a discount is being applied. What should happen?
Answer & explanation
Answer: C
A listener is a side effect, not part of the answer. One broken listener must not lose the result or silence the rest, and counting the error keeps the failure visible.
Q4/4 You are 20 minutes into a coupon round. The interviewer has not mentioned undo or coupon lifecycles. What do you do about State and Command?
Answer & explanation
Answer: C
Say it; don't build it unasked. Naming the condition shows you saw the pattern and chose against it, without giving the interviewer more code to question.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


