Before we touch code, one promise: in refactoring there is exactly one thing that must never change — the observable behaviour. We will treat that like a physicist treats a conserved quantity. Every example computes behaviour before and after and checks they are equal. That equality is the whole game.
Think of "cases" here the way a geometry student thinks of quadrants: distinct regions where the rules or the failure modes differ. Here are the region-classes a refactoring problem can land in.
Cell
What makes it different
Danger it hides
C1 — Clean extract
A self-contained block with no side effects
(almost none — the easy case)
C2 — Duplication
Same logic in ≥2 places
Fix one, forget the other
C3 — Degenerate input: empty
Loop over zero items, empty list
Off-by-one, wrong "identity" value
C4 — Boundary flip
An <= vs <, an inclusive/exclusive edge
Silent behaviour change at the boundary
C5 — Side-effect / ordering
Code that mutates or prints
Reorder → different observable output
C6 — Magic number / units
Unexplained literal, unit mismatch
Meaning lost, unit bug
C7 — Data clump → object
Same variables travel together
Long parameter list, coupling
C8 — Over-refactor (limiting)
Extraction pushed too far
Indirection worse than the mess
C9 — Real-world word problem
Business rule buried in a mess
Getting the concept name right
C10 — Exam twist
"Is this a valid refactoring?" trap
Behaviour secretly changed
The examples below are labelled with the cell(s) they cover. Together they fill every row.
Forecast: guess the string receiptreturns before and after the refactoring. (Both should be the string "Total: 11" — note the function returns this string; it does not print it.)
Identify the self-contained idea. The loop computes a subtotal — one clear concept, no printing, no mutation of the input. Why this step? Extract Method is only safe when the block is a coherent chunk; here it is (cell C1, the clean case).
Name it by intent. Call it subtotal(items). Why this step? A good name (see Naming Conventions) is the documentation — subtotal says what the number means.
Move the block, replace with a call:
def subtotal(items): return sum(it["price"] * it["qty"] for it in items)def receipt(items): return f"Total: {subtotal(items)}"
Why this step?sum(...) is the same accumulation the for loop did, just named. Behaviour is preserved by construction.
Verify: Before: 3⋅2+5⋅1=11, so receipt returns "Total: 11". After: subtotal(items) = 11, so receipt returns "Total: 11". Equal ✅. This is the win Extract Method exists for (see Single Responsibility Principle).
Apply the **Rule of Three**: the third copy triggers the refactor.
Forecast: what single named thing should replace all three literals?
Spot the repeated concept.1.08 = "price with 8% tax." It appears 3× → the wince becomes a refactor. Why this step?DRY Principle: one idea should live in one place, or a rate change means three edits (that's the Shotgun Surgery smell).
Why this step? The literal 1.08 was silently encoding two facts — the tax rate (0.08) and the "add it on" arithmetic (1 +). Splitting them into a named constantTAX_RATE and a named functionwith_tax makes the meaning explicit and gives the rate a single home to change (killing the Magic Number smell at the same time — see Clean Code).
Replace all three call sites with with_tax(price * qty). Why this step? Now changing the rate is a one-line edit — the whole point of killing duplication.
Verify: For price=10, qty=2: old 10*2*1.08 = 21.6; new with_tax(20) = 20*1.08 = 21.6. Equal ✅ across all three sites. This directly pays down Technical Debt.
Forecast: what does each version return for an empty list?
Figure: the input items = [] (violet box) fans out to the two implementations — the for-loop version (magenta) whose body is skipped so total stays 0, and the sum() version (orange) which returns the identity element 0. Both arrows rejoin at the navy "returns 0" box, showing the degenerate case is preserved.
Trace the loop version on [].total = 0; the for body never runs; return 0. Why this step? The most-missed case in all of programming is "the loop that iterates zero times" (cell C3). You must trace it explicitly.
Trace the sum(...) version on [].sum of an empty sequence is 0 (its identity element — the value that changes nothing). Why this step? The refactoring only preserves behaviour if the extracted primitive uses the same identity the original loop started from (total = 0). It does.
Match them up. Both return 0. Look at the figure: the two paths that split at "any items?" rejoin at the same value. Why this step? A refactoring is valid only if every branch — including the degenerate one — lands on the same output.
Verify: loop on [] → 0; sum([]) → 0. Equal ✅. Degenerate case safe.
Figure: two step-curves of is_adult versus age. The violet solid line (age >= 18, correct) jumps to True at 18; the dashed orange line (age > 18, the buggy flip) jumps to True only at 19. The magenta dots at age = 18 mark the single point where the two disagree — correct says True, flipped says False.
Simplify legitimately first.if C: return True; return False is exactly return C, so return age >= 18is a valid refactoring. Why this step? This part preserves the truth value on every age — verify by cases below.
Now test the colleague's version at the boundary. The correct rule keeps >=. Changing to > flips the answer at the single point age == 18: correct says True, theirs says False. Why this step? Cell C4 lives entirely at one point — you must probe the exact edge, not just typical values.
Verdict.return age >= 18 = refactoring ✅. return age > 18 = not a refactoring — it's a behaviour change (a bug). Why this step? The definition is behaviour-preserving; a difference at even one input disqualifies it.
Verify: at age=18 — correct 18>=18 = True; flipped 18>18 = False. Different, so the flip fails. At age=17 and age=19 both agree. The magenta dots in the figure mark the one point where the two curves diverge.
Forecast: is the printed sequence part of the observable behaviour?
List the observable behaviour. It's not just the return value — it's also the sequence of prints: Start, then Total 108.0. Why this step? For code with side effects, behaviour includes order and content of every effect (cell C5). This is the trap people forget.
Compare orders. Correct order: Start, Total. The buggy extraction emits Total, Start. Why this step? Same numbers, but the stream differs — so behaviour is not preserved.
Fix by preserving order. Extract without moving the print earlier:
def total_with_tax(order): return order.amount * 1.08 # pure, no printdef process(order): print("Start") t = total_with_tax(order) print(f"Total {t}") return t
Why this step? Extract the pure part (the arithmetic) and leave the effects in the original order. Pure functions are the safe things to extract.
Verify: For amount=100: total = 100*1.08 = 108.0. Correct output stream ["Start","Total 108.0"]; buggy stream ["Total 108.0","Start"] — not equal, so the reorder is invalid; the pure-extraction version reproduces the correct stream ✅.
Forecast: what does 27.78 actually mean, and is the formula even right?
Decode the literal.100km/h=3600s100000m≈27.78m/s. So 27.78 is a speed in m/s, but the variable multiplies time_hours and stores km. Why this step? Cell C6 hides unit mismatches inside anonymous numbers — you can't audit units you can't name.
Name it correctly for the units in play. If time is in hours and output in km, the constant should be the speed in km/h:
Why this step? Naming forces the unit question and exposes that 27.78 was the wrong unit here — a latent bug the magic number was hiding.
Keep 27.78 only where units match. If you truly work in metres and seconds: SPEED_MS = 27.78; distance_m = time_s * SPEED_MS. Why this step? One source of truth per unit system; see Clean Code.
Verify: unit check — hours × (km/hour) = km ✅. Numeric: for 2 hours, correct 2 * 100 = 200 km. The old buggy 2 * 27.78 = 55.56 was neither km nor m for that time — confirming the magic number masked a real defect.
Forecast: how many parameters does distance have after extracting a Point?
Figure: a right triangle on x–y axes from Point a (0,0) to Point b (3,4). The orange horizontal leg is dx = 3, the violet vertical leg is dy = 4, and the magenta hypotenuse is the distance = 5. The four loose coordinates become two named points, and the Pythagorean value is unchanged.
Name the hidden structure.(x1,y1) and (x2,y2) are two points. Why this step? A Data Clump is variables that always travel together — that togetherness is an unnamed concept (cell C7).
Why this step? Four loose args become two meaningful args — fixing the Long Parameter List smell at the same time (one refactoring, two smells cured).
Behaviour must be identical. The formula is the same Pythagorean distance. Why this step? Extract Class is only a refactoring if the computed value is unchanged.
Verify: points (0,0) and (3,4). Old distance(0,0,3,4) and new distance(Point(0,0), Point(3,4)) both give 32+42=25=5. Equal ✅. The figure shows the 3-4-5 triangle both versions measure.
Forecast: does more extraction always improve readability? (Push the idea to its limit.)
Take extraction to the extreme. In the limit of "extract everything," each method wraps one operation and you bounce through many files to follow one formula. Why this step? Testing a principle at its limiting value reveals where it stops helping (cell C8) — exactly like checking a formula as a variable → ∞.
Apply the naming test. Extract Method earns its keep only when the fragment names a concept. add_one names nothing you didn't already read in x + 1. Why this step? The parent's Wrong Idea D: extract to name a concept, not to cut line count.
Correct amount: keep it as one clear expression result = 2*(x+1) - 3, extracting only if a meaningful sub-concept (e.g. apply_discount) exists. Why this step? Over-indirection creates its own smell (Shotgun-style navigation) — a mess trading one problem for another.
Verify: for x=5, all versions must compute 2*(5+1) - 3 = 9. The over-extracted chain and the single expression both give 9 — behaviour equal, so it's still technically a valid refactoring, but strictly worse for readers. Correctness ≠ improvement.
Forecast: name the two sub-concepts before you read step 1.
Extract the concepts as named booleans:
member_qualifies = total >= 50 and is_memberbulk_qualifies = total >= 100free_shipping = member_qualifies or bulk_qualifiesif free_shipping: ship_free()
Why this step? The tangled boolean hid two business rules. Naming them (member_qualifies, bulk_qualifies) turns cryptic arithmetic into a readable policy anyone can audit — this is Introduce Explaining Variable working on a real rule.
Confirm the boundaries. Member at exactly $50 → member_qualifies uses >= → qualifies. Non-member at $100 → bulk_qualifies uses >= → qualifies. Why this step? Cell C4 lives inside C9 — word problems love boundary edges ("at least" means >=, not >). You must nail the inclusive edge or you change behaviour.
Behaviour unchanged. The named version produces the same truth value for every (total, is_member) as the original inline expression. Why this step? It is only a refactoring if the shipping decision is identical for every input — verified below.
Verify — cover the cases:
Member, $60 → (60≥50 and True)=True → free ✅
Member, $40 → (40≥50)=False, (40≥100)=False → not free ✅
Non-member, $120 → False or (120≥100)=True → free ✅
Non-member, $50 → False or (50≥100)=False → not free ✅
Forecast: guess which one secretly changes behaviour, and for which inputs.
Test (A) across signs and types. For n = 7: 7/2 = 3.5, 7*0.5 = 3.5. For n = -7: both -3.5. Why this step? Cell C10 demands checking every sign and edge, not one friendly value — the trap always hides where you didn't look.
Test (B) — the bit shift.>> 1 is integer floor-division by 2. For n = 7: 7/2 = 3.5 but 7 >> 1 = 3. Different! For n = -7: -7/2 = -3.5 but -7 >> 1 = -4 (Python floors toward negative infinity). Why this step? The shift silently drops the fractional part and rounds negatives the "wrong" way — two hidden behaviour changes at once.
Verdict. (A) is a valid refactoring (identical for all numeric n); (B) is not — it changes behaviour for odd and negative inputs. Why this step? The exam trap rewards the tidy-looking option (B); the definition rejects it because behaviour must be identical everywhere, not just on the value the marker showed you.
Clean extract (C1) ::: name a self-contained pure block, replace with a call.
Duplication (C2) ::: Rule of Three — extract shared logic to one named place.
Empty input (C3) ::: check the loop-runs-zero-times case; identity value must match.
Boundary (C4) ::: probe the exact edge; < vs <= can silently change behaviour.
Side effects (C5) ::: behaviour includes the order of prints/mutations.
Magic number/units (C6) ::: name the literal; naming exposes unit bugs.
Data clump (C7) ::: variables that travel together want to be an object.
Over-refactor (C8) ::: extract to name a concept, not to cut lines.
Word problem (C9) ::: name business rules as explaining variables.
Exam twist (C10) ::: "valid?" = identical output for every input, all signs.