Module 8: Refactoring with Judgment (Capstone)
3. Spotting the pattern that wants to emerge
Description
By the end of this lesson you'll be able to recognize, with concrete signs and not intuition, that a piece of code is asking for structure. You'll have five signs with their anatomy — what each one is, what it looks like, what it's telling you, and what tells it apart from a false positive — and the test that ties them together: the axis test. And you'll learn the part that separates someone who applies patterns from someone who lets them emerge: a four-beat procedure where the pattern's name gets chosen last, once there's nothing left to choose because the code already told you.
This matters because the "add structure" direction is where enthusiasm does the most damage. Someone recognizes a four-branch if, says "Strategy," and writes an interface, four classes, and a factory. Sometimes they're right. When they're wrong, they're wrong in an expensive, hard-to-reverse way: the four branches weren't four behaviors but four steps of the same calculation, or they were four cases about to disappear once the data gets cleaned up, or they were the same axis but the contract came out wrong because it got designed looking at two of the four. Module 2 gave you the brake — every abstraction has a cost. This lesson gives you the precision: knowing structure is needed here isn't enough, you have to get right which one and on which axis.
The difference between imposing and letting it emerge can be said in one sentence, worth keeping on hand through the whole lesson: imposing means starting from the pattern and looking for where it fits; letting it emerge means starting from the code, applying mechanical, safe transformations, and discovering what's left already has the shape of something with a name. The result looks similar in the easy cases. In the hard ones — which are most of them — it looks nothing alike.
Connection to the module: lesson 2 gave you permission to touch, because it taught you to find out why the code is the way it is; this lesson uses that permission for the first of the two directions. Lesson 4 does the reverse: recognizing structure that's unnecessary. Both share the same test — does the structure match the variation? — and that's why it's worth studying them together. Lesson 5 gives you the discipline to execute what you diagnose here, in steps that never leave the system broken; in fact, this lesson's four-beat procedure is also the first half of lesson 5's refactor. Lesson 6 teaches you to defend it, and the unit of measure you're going to use there — how many files you have to touch to add one — comes from this lesson's sign number two. And 7 decides whether it's worth it.
The path people trample into the grass
You're walking through a park and see this: there's a concrete sidewalk going around the garden at a right angle, and there's a bare-dirt trail crossing it diagonally, corner to corner. Nobody built it. It got made by thousands of footsteps, one at a time, each one choosing the short path without thinking about the others.
In architecture and urban design those trails have a name — desire paths — and an associated rule that's exactly this lesson's: when skilled architects design a campus, they often don't lay the entrance sidewalks. They put down grass, let a year pass, look at where people walked, and pave over the footprints. The result is a campus where nobody cuts across the grass, because the sidewalks are where people were already walking.
Compare the two ways of working. The architect who imposes draws the sidewalks on the blueprint, symmetric and right-angled, before a single footstep exists; their campus looks good on the blueprint and half the time ends up with dirt trails crossing it diagonally and "keep off the grass" signs nobody respects. The architect who lets it emerge waits for the footprints and paves them: their final blueprint is less elegant and works better, because the layout got decided by real use, not symmetry.
The same thing happens in code, with two important differences worth being clear about.
The first: the footprints are already there. You don't have to wait a year. Your repository's history is literally the record of where people have walked for years: which files get touched together, which change forced modifying four places, how many times a case got added to the same conditional. This whole lesson's work is learning to see the footprints in code that at first glance just looks ugly.
The second: paving costs. A path paved in the wrong place is worse than grass, because it has to get broken up to move it. That's why the rule isn't "wherever you see a footprint, pave": it's "wherever the footprint is marked, is deep, and keeps growing, pave." You already know from module 2 how many footsteps are needed — the rule of three — and this lesson teaches you to tell a footprint apart from a grass stain.
The five signs that code is asking for structure
Each sign has the same anatomy: what it is, what it looks like, what it's telling you, and — most important — its false twin, the case that looks identical and doesn't mean the same thing.
Sign 1 — The conditional that grows along the same axis
What it is. An if/elif that's gained branches over time, and every new branch answers the same question: "what type is this?"
What it looks like.
if ticket.kind == "general": ...
elif ticket.kind == "vip": ...
elif ticket.kind == "early_bird":...
elif ticket.kind == "courtesy": ...
What it's telling you. That there's a concept with no name. In the code above, the concept is "a ticket type's pricing rule": it genuinely exists in the business, has four variants, and in the code it has neither a name nor a home. When a business concept has no representation in the code, it shows up dissolved across conditionals.
Its false twin. A conditional that doesn't grow and doesn't answer "what type is this," but a momentary condition:
if now <= cutoff: # ← this is NOT an axis
price = base_price * 0.70
else:
price = base_price
That if lives inside a branch and is part of the early-bird calculation. It's never going to gain branches: a date either passed or it didn't. Turning it into two classes would be pure ceremony. The mechanical difference: the axis asks about a category — a value from a set that can grow; the false twin asks about a binary condition of the context.
Sign 2 — Adding a case forces touching several files
What it is. The same knowledge — "which payment providers exist" — written in several places, each with its own shape.
What it looks like. It doesn't show in one file: it shows in the diff of a past change. Search for the commit that added the most recent provider and count the files.
git show <commit-that-added-mercadopago> --stat
boletia/checkout/checkout.py | 12 ++++++
boletia/admin/refunds.py | 9 +++++
boletia/reports/reconciliation.py | 7 +++++
boletia/api/routes.py | 5 ++++
4 files changed, 33 insertions(+)
What it's telling you. This is what module 7 called shotgun surgery: one conceptually single change scattering like buckshot. And it's telling you something more precise than "this is ugly": it's giving you the unit of measure you're going to use to justify the work in lesson 6. Before: four files. After: one. That sentence wins arguments.
Its false twin. A change touching four files because they genuinely are four different things. Adding a provider touches charging, refunds, reconciliation, and validation: those are four legitimate uses of the same concept. The sign isn't "touches four files," it's "the four files repeat the same list." The control question: if I add the new case and forget one of the four, does the system end up silently inconsistent? If yes, that's the sign. If forgetting fails loudly, it's much less serious.
Sign 3 — Duplication with variation
What it is. Three or four blocks doing the same thing, with a small difference in the middle.
What it looks like. In Boletia's reports/, the three exporters:
def export_csv(event_id):
rows = load_attendees(event_id) # same
rows = sort_by_name(rows) # same
content = to_csv(rows) # ← the only different part
return write_file(content, ".csv") # same
def export_pdf(event_id):
rows = load_attendees(event_id) # same
rows = sort_by_name(rows) # same
content = to_pdf(rows) # ← the only different part
return write_file(content, ".pdf") # same
What it's telling you. That there's a shared skeleton and one varying step. That's a named pattern — Template Method, if it goes with inheritance; a function that receives the varying step, if it goes with functions — but the name comes later. What the sign tells you now is that the knowledge "how a report gets exported" is written three times, and a change to the step order has to happen three times.
Its false twin, and it's the most dangerous one in this lesson: duplication that only looks alike. Go back to Boletia's pricing:
fee = price * SERVICE_FEE_RATE
total = price + fee
Those two lines appear identical in three of the four pricing rules. And courtesy doesn't have them. If you extract the service fee "because it's repeated" and apply it to all four, you change behavior and complimentary tickets start getting charged. The rule that saves you: before unifying two similar-looking blocks, ask whether they're going to change together. If tomorrow VIP's service fee goes up to 10% and general's doesn't, those blocks were never the same block: they looked alike by coincidence.
Sign 4 — The module that changes for three different reasons
What it is. A file that shows up in commits that have nothing to do with each other.
What it looks like. The history tells you at a glance:
git log --oneline --since="1 year ago" -- boletia/checkout/checkout.py
c1a9e02 Add payment provider MercadoPago
8f3b117 New ticket type: courtesy with a per-event cap
2d77a90 Email the organizer when a ticket sells
b04c6e1 Bulk-purchase discount from 10 tickets
9ae2f30 Log the event to analytics on payment
Five commits, five completely different reasons: charging, pricing, notifying, promoting, measuring. That file has five reasons to change.
What it's telling you. That several responsibilities are coexisting, and that anyone touching one of them is going to have to open the system's most delicate file. The practical consequence isn't philosophical: it's that merge conflicts concentrate there, and any mistake in an analytics change can take down a sale.
Its false twin. The orchestrator. A function whose legitimate job is coordinating steps — checkout calls pricing, charging, seats, and notices — is going to show up in commits about all those topics, and that's fine: coordinating is its responsibility. The distinction is fine and it's the whole refactor's key: the problem isn't that checkout calls five things, it's that it knows internally how each one works. A healthy orchestrator has five calls and no branches; Boletia's checkout has five calls and two chains of conditionals stuffed in between.
Sign 5 — The parameter that toggles behavior
What it is. A function receiving boolean flags or a text mode, which internally splits into two or three distinct functions.
What it looks like.
def export_report(event_id, fmt, include_totals=False,
anonymize=False, split_by_day=False):
...
What it's telling you. That several distinct operations are disguised as one with parameters. The strong sign is when the parameters don't combine: if anonymize=True only makes sense with fmt="csv", they're not options, they're cases.
Its false twin. Parameters that genuinely are orthogonal options — include_totals can go with any format. Those don't call for structure, at most they call for an options object. The control question: are there combinations that make no sense or are forbidden? If so, you have cases disguised as options.
The test that ties all five together: the axis test
Every sign points at the same thing, and there's a single test confirming you found the right axis. It's three questions:
- Do all branches answer the same question?
Ticket.kind's axis answers "how much does this ticket cost?" If a branch answered "does it need an invoice?", that's not the same axis. - Do the branches change separately? If marketing can change the VIP rule without touching general's, they're different things. If they always change together, they're not four things: they're one with parameters.
- Can the list grow? And grow for real, with evidence: two ticket types added in two years is evidence; "next year maybe we'll sell season passes" is a prediction, and you already know from module 2 what those are worth.
If all three are "yes," you found an axis and the work makes sense. If one is "no," stop: you're very likely about to pave where nobody walks.
Worked example: letting the pattern emerge from checkout
Let's do this on Boletia's checkout's pricing block, and let's do it without saying any pattern's name until the end. That constraint isn't a game: it's the method.
Here's the starting point, inside the checkout function:
# File: checkout/checkout.py (excerpt, inside checkout())
subtotal = 0.0
for ticket in tickets:
if ticket.kind == "general":
price = ticket.base_price
# Group purchase: from 10 tickets on, 5% discount.
if order.quantity >= 10:
price = price * 0.95
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "vip":
# 35% over the base, plus the lounge's fixed fee.
price = ticket.base_price * 1.35 + 150.0
if customer.is_member:
price = price * 0.90
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "early_bird":
cutoff = parse_date(event.early_bird_cutoff)
# After the cutoff, early-bird costs the same as general.
price = ticket.base_price * 0.70 if now <= cutoff else ticket.base_price
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "courtesy":
# Per-event cap: an organizer can't give away the whole venue.
if count_courtesies(ticket.event_id) >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(ticket.event_id)
subtotal += 0.0 # no service fee: a courtesy is a courtesy
else:
raise ValueError(f"Unknown ticket type: {ticket.kind}")
Beat 0 — Confirm the axis and put in the net.
The axis test, answered with data: all four branches answer "how much does this ticket cost?" (yes); marketing changed the VIP rule in March and early-bird's in September, separately (yes); courtesy got added fourteen months ago and there's an open conversation about season passes (yes, with evidence). It's an axis.
And the net, which you already know from lesson 2 isn't optional: characterization tests that fix current behavior, quirks included.
# File: tests/test_pricing_characterization.py
# Fix CURRENT behavior. If any of these fail during the refactor,
# it means you changed something. None should fail until the end.
def test_general_without_group_discount():
# 500 + 8% service = 540.00
assert price_of(kind="general", base=500.0, quantity=2) == 540.00
def test_general_with_group_discount():
# 500 * 0.95 = 475 ; 475 + 8% = 513.00
assert price_of(kind="general", base=500.0, quantity=10) == 513.00
def test_vip_member_pays_less():
# (500*1.35 + 150) * 0.90 = 742.50 ; + 8% = 801.90
assert price_of(kind="vip", base=500.0, is_member=True) == 801.90
def test_early_bird_loses_discount_after_cutoff():
assert price_of(kind="early_bird", base=500.0, when=BEFORE_CUTOFF) == 378.00
assert price_of(kind="early_bird", base=500.0, when=AFTER_CUTOFF) == 540.00
def test_courtesy_is_free_and_capped():
assert price_of(kind="courtesy", base=500.0) == 0.0
with pytest.raises(TooManyCourtesies):
price_of(kind="courtesy", base=500.0, issued=COURTESY_LIMIT_PER_EVENT)
Notice the comments carry the arithmetic. Three steps from now you're going to need to know where each number came from.
Beat 1 — Extract, with no design at all.
Each branch turns into a named function. A purely mechanical move: your editor does it on its own with "extract function." You don't change logic, don't fix the duplication, don't touch the signatures.
# File: pricing/rules.py (new)
def price_general(ticket, order, customer, now, event):
price = ticket.base_price
if order.quantity >= 10:
price = price * 0.95
return round(price + price * SERVICE_FEE_RATE, 2)
def price_vip(ticket, order, customer, now, event):
price = ticket.base_price * 1.35 + 150.0
if customer.is_member:
price = price * 0.90
return round(price + price * SERVICE_FEE_RATE, 2)
def price_early_bird(ticket, order, customer, now, event):
cutoff = parse_date(event.early_bird_cutoff)
price = ticket.base_price * 0.70 if now <= cutoff else ticket.base_price
return round(price + price * SERVICE_FEE_RATE, 2)
def price_courtesy(ticket, order, customer, now, event):
if count_courtesies(ticket.event_id) >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(ticket.event_id)
return 0.0
Run the tests. Green. This step has zero risk and already produced 60% of the value: checkout's core got thinner, and the four business rules stopped being dissolved.
Beat 2 — Look at the signatures. This is where the pattern starts to speak.
This is the step almost everyone skips, and that's why they write bad contracts. Don't invent the interface: read it in what you already have. Line up the four signatures and look at what each function genuinely uses:
| Function | Genuinely uses | Queries outside |
|---|---|---|
price_general | base_price, quantity | no |
price_vip | base_price, is_member | no |
price_early_bird | base_price, now, event's cutoff | yes (event) |
price_courtesy | event_id | yes (count_courtesies) |
Three facts jump out on their own, and all three decide the design:
- None uses all five parameters. The shared signature left over from step 1 is a form where everyone fills three boxes and leaves two blank.
- Two query the outside world. That means testing them requires setting up real data or patching modules. If instead you hand them what they need, they become functions that receive numbers and return numbers.
- One can fail. Courtesy raises an exception; the others always return a number. Any contract you write has to admit that "calculating a price" sometimes doesn't end in a price.
None of those three facts was visible in the original code. They showed up because you extracted first. That's, literally, what "letting the pattern emerge" means: design decisions get made on evidence the refactor itself produced.
Beat 3 — Write the contract the signatures dictated.
Three decisions come from those three facts, each with its reason:
# File: pricing/context.py
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PricingContext:
"""Everything a pricing rule might need, already resolved.
Immutable on purpose: a rule shouldn't be able to modify the context
and affect the following ones.
"""
base_price: float
quantity: int
is_member: bool
now: datetime
early_bird_cutoff: datetime | None # already queried from the event
courtesies_issued: int # already queried from the event
def build_context(ticket, order, customer, now) -> PricingContext:
"""Gathers ALL the external queries in one place.
Rules query nothing: they receive data. That's how they get tested with numbers.
"""
event = get_event(ticket.event_id)
return PricingContext(
base_price=ticket.base_price,
quantity=order.quantity,
is_member=customer.is_member,
now=now,
early_bird_cutoff=parse_date(event.early_bird_cutoff),
courtesies_issued=count_courtesies(ticket.event_id),
)
And the rules, now over the context:
# File: pricing/rules.py
from typing import Protocol
from pricing.context import PricingContext
SERVICE_FEE_RATE = 0.08
COURTESY_LIMIT_PER_EVENT = 50
class PricingRule(Protocol):
"""The contract: given a context, how much this ticket costs.
Can raise TooManyCourtesies if the ticket can't be issued.
"""
def price_for(self, context: PricingContext) -> float:
...
class GeneralPricing:
def price_for(self, context: PricingContext) -> float:
price = context.base_price
if context.quantity >= 10:
price = price * 0.95
return _with_service_fee(price)
class VipPricing:
def price_for(self, context: PricingContext) -> float:
price = context.base_price * 1.35 + 150.0
if context.is_member:
price = price * 0.90
return _with_service_fee(price)
class EarlyBirdPricing:
def price_for(self, context: PricingContext) -> float:
before_cutoff = (
context.early_bird_cutoff is not None
and context.now <= context.early_bird_cutoff
)
price = context.base_price * 0.70 if before_cutoff else context.base_price
return _with_service_fee(price)
class CourtesyPricing:
def price_for(self, context: PricingContext) -> float:
if context.courtesies_issued >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(
f"{COURTESY_LIMIT_PER_EVENT} courtesies have already been issued for this event"
)
# No service fee: a courtesy is a courtesy.
return 0.0
def _with_service_fee(price: float) -> float:
"""Boletia's service fee. Lives here and NOT in the contract
because it doesn't apply to every rule: courtesy doesn't carry it.
"""
return round(price + price * SERVICE_FEE_RATE, 2)
Stop on _with_service_fee: it's sign 3 in action. The temptation is to pull the fee out to the orchestrator, and that would be wrong — courtesy doesn't carry it, so the orchestrator would have to ask whether this rule carries it, and that's the ticket-type if reappearing in a different house. When in doubt between eliminating duplication and keeping decisions with whoever makes them, the second wins.
Beat 4 — The choice point, and only now the name.
# File: pricing/calculator.py
from pricing.rules import GeneralPricing, VipPricing, EarlyBirdPricing, CourtesyPricing
from pricing.context import build_context
RULES = {
"general": GeneralPricing(),
"vip": VipPricing(),
"early_bird": EarlyBirdPricing(),
"courtesy": CourtesyPricing(),
}
def calculate_line_price(ticket, order, customer, now) -> float:
rule = RULES.get(ticket.kind)
if rule is None:
raise ValueError(f"Unknown ticket type: {ticket.kind}")
return rule.price_for(build_context(ticket, order, customer, now))
And checkout is left with one line: subtotal += calculate_line_price(ticket, order, customer, now).
Now, look at what you have and name it: a shared contract, several interchangeable implementations, and a point that chooses which one to use at runtime. That's a Strategy. You didn't impose it: you found it. And the name, which arrives last, serves two things — communicating it in review and searching for its known pitfalls — but decided none of the design.
What to expect from this example. Four observations.
First: the order made the design. If you'd started with "this goes into a Strategy," you'd have written the interface first, and most likely the signature would have been price_for(ticket, order, customer, now) — the parameters that were at hand — with the rules querying the database internally. It works, and it's worse: the rules end up tied to the database and the next exercise's tests would be impossible to write. PricingContext didn't come from any pattern catalog: it came from looking at the signature table.
Second: you could stop at any beat. After beat 1 the system was already better and working; after beat 2, too. That property is all of lesson 5's topic, and here you already used it.
Third: look at what got gained, in a defensible unit. Before, adding a ticket type meant finding the conditionals on Ticket.kind scattered through the code — checkout, the sales-by-type report, request validation — and not missing any. Now it means one new class and one dictionary entry. From "find the four places" to "touch one." That's lesson 6's sentence.
Fourth, and most underrated: tests that used to be very expensive are now four lines.
# File: tests/test_pricing_rules.py
def ctx(**overrides):
"""Default context; every test changes only what it cares about."""
defaults = dict(base_price=500.0, quantity=1, is_member=False,
now=datetime(2026, 6, 1), early_bird_cutoff=None,
courtesies_issued=0)
return PricingContext(**{**defaults, **overrides})
def test_early_bird_loses_the_discount_after_the_cutoff():
cutoff = datetime(2026, 5, 1)
before = EarlyBirdPricing().price_for(ctx(now=datetime(2026, 4, 30),
early_bird_cutoff=cutoff))
after = EarlyBirdPricing().price_for(ctx(now=datetime(2026, 5, 2),
early_bird_cutoff=cutoff))
assert before == 378.00
assert after == 540.00
No database, no fake objects, no order to assemble. When a test becomes easy to write, it gets written. The missing tests in your project aren't missing from carelessness: they're missing because writing them cost too much. And that's a benefit of the refactor almost never mentioned in the justification, even though it's usually the longest-lasting one.
Emerging isn't the same as showing up on its own
It's worth being precise about the metaphor, because "let the pattern emerge" sounds like sitting and waiting, and it isn't. Emerging means the final shape gets decided by the evidence the refactor itself produces, not a catalog consulted beforehand. You're doing active work the whole time; what changes is the order you make decisions in.
Compare the two orders:
| Imposing | Letting it emerge | |
|---|---|---|
| First step | Choose the pattern | Confirm the axis with data |
| Second | Write the interface | Extract each branch, no design |
| Third | Fit the code into the interface | Read the signatures and see what each one needs |
| Fourth | Discover along the way a case doesn't fit, and force it | Write the contract the signatures dictated |
| Pattern's name | At the start, and it guides everything | At the end, and it only serves to communicate |
| When a case doesn't fit | The case gets twisted | The contract gets fixed |
The row that matters most is the last one. In the imposing method, when CourtesyPricing doesn't fit — because it carries no service fee and can fail — the typical reaction is to add a parameter to the interface. In the emerging method, that case is information: it tells you the contract has to admit failure and the fee can't live in the skeleton.
From that comes a rule worth the rest of your career: design the contract by looking at the strangest case, not the most common one. In module 4 you saw it with CashProvider, which doesn't charge but generates a reference: being the one that least resembles the other two, it's the one that tests whether the contract is right. A contract designed with the two easy cases breaks on the third.
How much structure, and on which rung
Recognizing the axis doesn't tell you how much structure to add. Between "an if" and "a plugin architecture" there are several rungs, and the right one is almost always among the low ones:
- An
ifwhere it is. (What was there.) - An
ifextracted to its own function. - A dictionary of functions.
- A dictionary of objects with one method, plus a context object. ← what we did
- A formal interface with explicit registration.
- External configuration that picks the implementation.
- Dynamic module discovery. ← what
plugins/has
Rung 4 solved pricing's problem. Rung 3 would have been enough if the rules needed no context and couldn't fail. Nobody needs rung 7 unless third-party code has to plug in without you deploying it. The right question isn't "which pattern do I use?" but "which is the lowest rung that solves this?", and that question is answered much better after extracting than before.
Common mistakes
Naming the pattern before extracting (method). What happens: someone sees four branches, says "Strategy," opens a new file, and writes the interface. Then they translate each branch into a class fulfilling that interface. The result almost always has the same flaw: the interface's signature is the one that was at hand at the call site, so the implementations end up querying the database internally and become impossible to test. Why it happens: naming produces a strong feeling of having understood, and the interface is the part of the pattern you remember from the diagram. How to spot it: if you wrote a line of the interface before having extracted the branches into functions, you did it backward. How to fix it: the four beats, in order. And a pocket rule: the interface is the last thing you write, not the first. If you struggle to resist it, forbid yourself from saying the pattern's name out loud until beat 4; the rest comes on its own.
Confusing an internal conditional with an axis (diagnosis). What happens: someone counts a function's ifs, finds nine, and concludes there are nine behaviors calling for nine classes. They end up with an absurd hierarchy where BeforeCutoffEarlyBirdPricing and AfterCutoffEarlyBirdPricing are two separate classes. Why it happens: syntax gets counted instead of concepts. An if is a language construct; an axis is a business category, and not every if marks a category. How to spot it: apply the axis test. If the branch can't grow — a date either passed or it didn't, a customer is either a member or isn't — it isn't an axis. How to fix it: identify the axis first, then look at how many branches it has. pricing's axis is the ticket type and has four branches; the ifs inside each rule aren't axes, they're the rule.
Unifying duplication that only looks alike (risk). What happens: someone sees two identical blocks, extracts them into a shared function, and weeks later one of the two has to change. So they add a parameter to the shared function. Then another. A year later there's a function with five boolean flags nobody understands, serving two cases that were never the same one. Why it happens: duplication is visible and the conceptual difference isn't. And because "don't repeat yourself" gets taught as an absolute rule, without its condition, which is: don't repeat knowledge, not lines. How to spot it: ask "if tomorrow one of the two copies changes, does the other have to change?" If the honest answer is "not necessarily," don't unify them. How to fix it: if you already did, the way back is duplicating on purpose — separating the two copies again — and only then deciding. Duplicating on purpose is a legitimate technique and module 2 treats it seriously; a wrong abstraction costs more than the duplication it was meant to avoid.
Exercises
Exercise 1 — Axis or false positive? For each case, apply the axis test — same question, change separately, the list can grow — and decide whether it calls for structure. Justify in two lines.
(a) In notifications/notifier.py: if customer.phone: ... / if customer.push_token: ..., to decide which channels to notify a customer through.
(b) In api/routes.py: if fmt == "csv" ... elif fmt == "pdf" ... elif fmt == "xlsx", also repeated in reports/exporter.py and in admin/panel.py.
(c) In checkout/checkout.py: if order.total > 10000: ..., the MercadoPago antifraud check you investigated in lesson 2.
(d) In pricing/rules.py: if customer.is_member: price = price * 0.90, inside the VIP rule.
(e) In admin/refunds.py: if order.provider == "stripe" ... elif "mercadopago" ... elif "cash", to decide how money gets refunded.
See solution
(a) Not an axis, or at least not that one. The two ifs don't answer "what type is this" but "does this customer have this data?" They're availability conditions, and the channel list doesn't grow with them. That said: there is a problem there, and it's a different one — checkout knows every purchase's interested party by name — but its axis isn't the phone if. This case is valuable because it teaches that a corner can need structure for a different reason than the one that jumps out first.
(b) A very clear axis, with sign 2 on top. Same question (how does this get exported?), branches change separately (the PDF generator changed without touching the CSV one), the list grew last year. And it's repeated in three files, so adding a format forces finding all three. It's the textbook case.
(c) Not an axis. It's a threshold condition, with two possible outcomes, and it's never going to grow — either the amount passes the threshold or it doesn't. You also already know from lesson 2 it's a type-1 fence. What it needs isn't structure: it needs a name for the magic number, a comment, and a test. Turning it into classes would be exactly the kind of ceremony this module wants to avoid.
(d) Not an axis, for the same reason as (c): it's part of the VIP calculation, not a category. The axis test fails on the third question — "member" isn't a list that grows. If tomorrow there were five membership tiers with their own rules, the answer would change; today it doesn't.
(e) Axis, and it's the same axis as checkout's. This is the most important detail of the exercise: refunds.py doesn't have its own problem, it has the same problem as checkout, written again. Sign 2 in its purest form. When you find two conditionals on the same field in two files, they're not two refactors: they're one.
Why it works: of the five, only two are axes, and both turn out to be repeated across several files. That's the real problem's typical shape: real axes are almost never in a single place.
Exercise 2 — Order the refactor and say what breaks if you skip a step. Here are the four beats, out of order, plus two distractors that shouldn't be there. Order the correct ones, discard the distractors, and explain what happens if you do each step too early.
(i) Write PricingContext and build_context. (ii) Extract each branch into a function with the same signature. (iii) Write the PricingRule interface and the four classes. (iv) Write the characterization tests. (v) Create the RULES dictionary and slim down checkout. (vi) Rename Ticket.kind to an enum so it isn't free text.
See solution
The order: (iv) → (ii) → (i) → (iii) → (v). The distractor is (vi).
- (iv) first, no exceptions. Without the net, every following step is blind. And they have to be behavior tests: if you write them mentioning the new classes, they don't test what was there, they test what you're about to do. A test written after the refactor confirms your result, it doesn't protect the original.
- (ii) second, and it's the safest step there is. Extracting a function changes nothing. If you did it after (i), you'd be writing the context based on what you think each branch needs instead of what you saw.
- (i) third. The context can only be designed well after having the signature table: without it, you either have extra fields or missing ones. This is the step where the method shows the most.
- (iii) fourth. The interface is the consequence of the context and the signatures, not the premise. If you write it first, it inherits the parameters that were at hand at the call site.
- (v) last. Changing the call site is what moves traffic to the new path, and it's worth doing once the new path's already tested.
Why (vi) is a distractor. Turning Ticket.kind into an enum is probably a good idea, and it isn't this refactor. It touches the data model, forces migrating existing values, and affects everyone reading that field — the sales-by-type report, request validation, the panel. Mixing it in here produces a change nobody can review, because there's no telling what got moved apart from what got transformed. It goes in its own work, afterward. A refactor that starts growing sideways is a refactor that isn't going to finish.
Why it works: the five correct steps each have a concrete precondition, and skipping it produces a specific, predictable failure. Being able to say what breaks is what turns the procedure into something defensible in a review, and not a style preference.
Exercise 3 — The other corner's axis. Now do it yourself with checkout's payment-provider block. Don't write code: write the diagnosis, in four points. (a) What's the axis and what evidence confirms it? (b) Which of the five signs are present? (c) What does the signature table tell you after extracting, knowing Stripe charges in whole cents, MercadoPago in float with a description, and cash doesn't charge but generates a reference? (d) What rung would you use and why not the next one?
See solution
(a) The axis is Order.provider. Evidence: the three branches answer the same question ("how does this get charged?"); they change separately (Stripe changed API version without MercadoPago finding out); and the list grew twice in two years. The axis test passes all three.
(b) Signs 1, 2, and 4. 1 is the category conditional. 2 is the strongest here and the one worth justifying with: the same if is repeated across four or five files — charging, refunds, reconciliation, validation — so adding a provider means finding all of them, and forgetting one leaves the system silently inconsistent (reconciliation just doesn't add up the following month). 4 shows up because checkout changes for payment reasons on top of pricing and notification reasons.
(c) The signature table tells you the contract can't be "whatever each API returns." The three return incompatible things: an object from Stripe's library, another from MercadoPago's, and a hand-built dictionary. If the contract doesn't also fix the result's shape, the caller's going to have to know which one it got, and the if reappears at the call site. Also, cash doesn't end up "charged" but "pending": the result needs a state, not a boolean. That's the odd case that improves the design, and it's why it has to be looked at before writing the interface.
(d) Rung 4, maybe 5. A shared contract, three implementations, and a dictionary of constructors as the choice point. Not 6 or 7: your team writes all three providers, in your repository, and the list fits in five visible lines. None of this needs external configuration or dynamic discovery. And there's a detail about why a dictionary and not an if: we want to be able to ask which providers exist, because request validation and the payment-method menu need that list, and with an if it would have to be written by hand in a second place — which is the problem we're solving.
Why it works: you just did, with no code, the full diagnosis for half the final project. And notice the contrast with the plugins/ corner, which has exactly rung 7 for an axis with one implementation. Same system, two opposite mistakes, and the same test to detect both.
Summary and next step
In this lesson you learned to recognize when code is asking for structure, with signs instead of intuition. The five: the conditional that grows along the same axis; adding a case forces touching several files — the sign that defends itself best, because it brings its own unit of measure; duplication with variation, with its dangerous twin, duplication that only looks alike; the module that changes for different reasons, with its legitimate twin, the orchestrator; and the parameter that toggles behavior, suspicious when the combinations aren't valid.
And the test that ties them together: the axis test. All branches answer the same question, they change separately, and the list can grow with evidence and not predictions. If one of the three fails, you're about to pave where nobody walks.
You applied the four-beat procedure to Boletia's checkout and saw why the order makes the design. Zero: confirm the axis and put in the net. One: extract each branch with no design at all. Two: look at the signature table, which is where the pattern starts to speak — what each rule genuinely uses, which ones query outside, which one can fail. Three: write the contract those signatures dictated, not the one that was in your head. Four: the choice point, and only there, the name. PricingContext didn't come from any catalog: it came from beat 2's table.
You're walking away with two rules worth more than this case: design the contract by looking at the strangest case, not the most common one — the one that doesn't fit is the one that improves the design, if it arrives before the interface is written; and ask which is the lowest rung that solves the problem, because between an if and dynamic module discovery there are seven steps and the right one is almost always the fourth.
Before moving on you should be able to: state the five signs with their false twin; apply the axis test to any conditional; and explain why the interface gets written last, not first.
Now comes the opposite move, the one almost nobody teaches. Lesson 4 handles recognizing structure that needs removing: the interface with a single implementer, the layer that only forwards calls, the configuration for something that never varied. You'll see the test is this lesson's, read backward, that the procedure is symmetric but not identical — removing demands a certainty adding doesn't need: knowing nobody else uses what you're about to delete — and you'll go back to Boletia's plugins/ corner with the list of what to check before touching a single line.
Resources
- Refactoring: Improving the Design of Existing Code (Martin Fowler) — the catalog of moves you executed:
Extract Function,Replace Conditional with Polymorphism,Introduce Parameter Object. Each with its step-by-step procedure and safety conditions. - The Wrong Abstraction (Sandi Metz) — the argument against unifying duplication that only looks alike. It's the defense for this lesson's third common mistake, worth rereading every time you're tempted to extract a repeated block.
- Shotgun Surgery (Refactoring Guru) — sign 2's formal name, with the associated refactorings. Useful for naming it in a code review.
- Beck's Design Rules / "Make the change easy, then make the easy change" (Kent Beck) — the four rules of simple design, in the order that matters. It's the shortest existing formulation of "first prepare the ground, then make the change," which is this lesson's method.