Software Design Reference Core Designer · Seeing Structural Problems

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.

Reading ≈ 11 minPractice ≈ 20 min

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 principle behind the principle

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.

Field notes · what it looks like in real code
You see thisWhat it costsThe 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 yearExtracting 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 filesThe variation point already leaked — three copies, one shape.Extract now: the third occurrence is the evidence.

When this is the wrong move

What people get wrong after reading this
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 class keyword. 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.

Teach it back
  1. Explain to a junior, in two sentences, why an if/elif chain is sometimes better design than a set of strategy classes.
  2. 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.
  3. 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

Show your agent-teacher a branching snippet and compare judgments on whether a strategy is truly emerging.