Lesson 0004 · Core Designer · Module 1
Recognizing When Strategy Is Emerging
Patterns are not applied — they are noticed. The Strategy pattern is already in your code the moment one behavior branches on a type. This lesson trains the noticing.
Mission tie-in: "recognize when patterns emerge naturally" is a core Designer skill; it's also the difference between AI that copies patterns and code that grows them from evidence.
Knowledge: the shape that already exists
The Strategy pattern's shape: a varying behavior extracted behind an interface, with implementations swapped at runtime without touching the caller. Before it is a pattern, it is a smell — an if/elif chain selecting behavior by a type field:
def discount(customer: Customer, total: Money) -> Money:
if customer.tier == "gold":
return total * Decimal("0.90")
elif customer.tier == "silver":
return total * Decimal("0.95")
else:
return total
Ask: what will change here? The set of tiers — new tiers appear, discounts change, and every edit lands in this one function. That is a variation point with a growing list of branches. The Strategy is emerging: the chain already plays the role of a strategy selector.
Making it explicit:
class DiscountStrategy(Protocol):
def apply(self, total: Money) -> Money: ...
class GoldDiscount:
def apply(self, total: Money) -> Money:
return total * Decimal("0.90")
class SilverDiscount:
def apply(self, total: Money) -> Money:
return total * Decimal("0.95")
class NoDiscount:
def apply(self, total: Money) -> Money:
return total
def discount(strategy: DiscountStrategy, total: Money) -> Money:
return strategy.apply(total)
Nothing about behavior changed — the chain was already a strategy; now it is a strategy with names and seams. New tiers become new classes instead of new branches.
† The discipline: extract only when the variation is real and growing. One branch is a choice; two branches are a choice; the third repetition is when the rule of three starts whispering "strategy."The branch chain and the strategy set compute the same answers. They are not better and worse code; they are two different bets about the rate at which variants arrive, and each has a price you can name.
A branch chain charges you at write time. Adding a variant means editing a function several people share, so the cost scales with the team: two engineers adding tiers in the same sprint get a merge conflict, and the diff touches a function whose other arms must all be re-tested because the reviewer cannot prove they were untouched. A strategy set charges you at read time. Every future reader pays one level of indirection to answer "what actually runs for a gold customer?" — a jump from the caller to the registry to the class. That toll is small, but it is levied on every reader, forever, whether variants arrive or not.
So the trade is: rate of new variants × cost of a shared edit against readers per year × cost of one indirection. Two variants that have not moved in a year: the chain wins easily. Six variants, one new per quarter, requested by three different stakeholders: the strategy wins easily. Most real cases sit near the crossover, which is exactly why "always extract" and "never extract" are both wrong and why the rule of three is a reasonable default — the third occurrence is the cheapest reliable evidence that the rate is non-zero.
One thing the pattern does not do: remove the conditional. Something still has to map "gold" to GoldDiscount. Strategy moves that decision into one named place — a registry, a factory, a composition root — where it is data rather than control flow. If after your refactor you cannot point at the single line where selection happens, you have not removed the branch; you have scattered it.
Where it stops applying. When the variants differ only in a number, a dictionary is the answer and a class per rate is ceremony. When variants must combine rather than exclude each other — a gold customer with a promo code and a regional surcharge — mutually exclusive strategies degenerate into a combinatorial matrix, and you want composition (a pipeline of adjustments) or a rules table instead. And when the arms share ninety per cent of their body, a set of strategies duplicates that ninety per cent; a parameterised function or a template method is the honest shape.
How a senior designer decides whether to extract
First they read the chain's history, not the chain. How many arms did it have six months ago? Three, and now five. That is a rate: roughly one new arm a quarter, and the trend is what the decision hinges on — a chain that has had two arms since 2021 is not a variation point, it is a boolean.
Then they look at who adds arms. If every new tier arrives as a ticket from a different stakeholder — growth wants a referral tier, finance wants a wholesale tier — the arms are not one team's implementation detail, they are a queue. Queues want a place to append. If instead one engineer has quietly added all five, the chain is fine and the cost of the pattern would be paid by readers who do not benefit.
Then they reject the lighter option, out loud. The cheapest possible fix is a lookup table:
RATES = {"gold": Decimal("0.90"), "silver": Decimal("0.95")}
def discount(customer: Customer, total: Money) -> Money:
return total * RATES.get(customer.tier, Decimal("1.00"))
If the variants differ only in a number, stop here — this is smaller, faster to read, and adding a tier is a data change. The reason to go further is that the variants have stopped being numbers. Gold has acquired a cap ("never more than €50 off"), silver applies only between November and January, and wholesale needs the customer's contract. Those are three different behaviours, and a dictionary of rates cannot hold them without growing a second dictionary of exceptions beside it. That is the moment the strategy earns its indirection: not when the list got long, but when the arms stopped being the same shape.
The consequence over a year. Left as a chain, the tier logic on a mid-sized commerce team reaches nine arms and about 140 lines, and becomes the file with the most merge conflicts in the repository; because every arm lives in one function, every discount change requires re-running and re-reading the whole test class, and the team develops a habit of adding an arm at the top with an early return "to be safe". Extracted, the same nine variants are nine small files, each with its own test, and the selection is a dictionary someone can read in five seconds. The second version is not more elegant — it is cheaper to change concurrently, which is what actually mattered.
| You see this | What it costs | The move |
|---|---|---|
| if kind == …: elif kind == … | Three or more arms: every new variant edits one shared function. | Extract one strategy per arm; the caller stops branching. |
| RATES = {"gold": 0.90, …} | Data-only variation — no behaviour differs, so no strategy is emerging. | Keep the lookup table; it is already the lighter answer. |
| two branches, unchanged for a year | Extracting now buys machinery for a variant that may never arrive. | Keep the branches; write the variation point in a note. |
| the same tier chain in three files | The variation point already leaked — three copies, one shape. | Extract now: the third occurrence is the evidence. |
When this is the wrong move
- Two stable arms. A boolean wearing a string. Extracting gives you two classes, an interface, and a factory to express
if. - Data-only variation. If the arms differ by a rate, a threshold, or a message, put them in a table. A strategy per row is a class hierarchy pretending to be a configuration file.
- Variants that compose. Strategy assumes exactly one implementation is chosen. When several adjustments can apply at once, you want a list you fold over, not a selection — reaching for Strategy here produces a class per combination.
- Selection driven by untrusted input. A registry keyed by a string from a request is a dispatch table an attacker can aim. Validate the key against a closed set before it reaches the lookup, and never build the class name from input.
- Strategy removes the conditional.
- It relocates it. The win is that the decision now happens once, in a named place, as data. If you cannot point at the line that maps a key to an implementation, the branch is still there — spread across the codebase instead of concentrated in one function, which is worse.
- Every if/elif chain is an emerging strategy.
- Only chains that select behaviour and are growing. A chain over three stable error codes, or one that validates input, is just a conditional doing its job. The signal is a variation point with arrivals, not a shape you dislike.
- Once extracted, the code is future-proof.
- It is open for extension along exactly one axis — the one you extracted. Add a second axis (tier × country) and four strategies become twelve. Open–closed is a direction, not a guarantee, and choosing the wrong axis is worse than not choosing one.
- A dictionary of functions is not a real Strategy.
- It is. Python's first-class functions make the class ceremony optional; the pattern is the seam and the swap, not the
classkeyword. Reach for classes when a variant needs its own state or several methods, and for functions when it does not.
Skill: see the strategy inside the code
Which signal says a Strategy is emerging?
Two branches exist today, with no sign of a third. The right move is:
After extracting the strategy, a new tier arrives. You:
Practice on your own code
Find an if/elif chain (or match) in your codebase that selects behavior by a field or string. Gather the two pieces of evidence before deciding: how many arms it had six months ago, and whether the arms differ by a value or by a behaviour. Then commit to a call — extract now, drop to a lookup table, or write the variation point down in a note and keep watching.
In the lab: the returns-resolution service branches on the reason a customer gives for a return, and the list has been growing. The phase-1 checkpoint asks you to decide whether that chain has earned a strategy or only a lookup table, and to justify the call with the arm-arrival rate rather than with taste.
Reveal: a worked judgment
A notification module branches on channel == "email" | "sms" | "push". Three channels exist and a fourth is planned. The branch is a variation point with growing list — extract NotificationChannel as a protocol with one send(). The caller selects by lookup, not by chain.
- Explain to a junior, in two sentences, why an
if/elifchain is sometimes better design than a set of strategy classes. - Find one branching chain in your own code this week and name its cost: how many arms arrived in the last two quarters, and how many people had to edit the same function to add them.
- Predict what happens in a year if it keeps growing untouched — how long the function gets, who ends up owning it, and which arm eventually gets added in the wrong place.
Your win
You now read code looking for variation points, not pattern names. When a chain selects behavior by type, you can name what's emerging — and, just as important, refuse to extract what isn't.
Read and watch deeper
- Head First Design Patterns, Freeman et al. — ch. 1: Strategy taught by noticing a problem, not by memorizing a diagram.
- Design Patterns, Gamma et al. — ch. 5, Strategy: the canonical reference.
- Refactoring Guru — Strategy: the "problem/solution" framing that matches this lesson's noticing-first approach.
- Refactoring, Fowler — "Replace Conditional with Polymorphism" in the refactoring catalogue: the mechanical steps for the transformation above, and the preconditions that tell you it is safe.
- Watch: ArjanCodes YouTube — search "strategy pattern python".
- Reference: Patterns Quick Reference — Strategy, with the shape and the “don't”.
Show your agent-teacher a branching snippet and compare judgments on whether a strategy is truly emerging.