Capstone I: the smell inventory
Before you touch a messy module, diagnose it: name each smell with the track vocabulary, locate it, and rank by change-cost. A prioritized inventory tied to likely changes — not a cleanup urge — is what lets you prove the refactor improved anything.
You inherit a 180-line checkout.ts. It builds HTML, applies business rules, talks to the database, and switches on payment type — all in one function. Your instinct is to start cleaning: rename a thing here, pull out a helper there, tidy the switch. An hour later the file looks different and you have no idea whether it is better, because you never said what “worse” was in the first place.
That instinct — edit before you characterize — is the failure mode this capstone exists to break. A senior does not refactor a mess by feel. They first produce a smell inventory: every defect named with precise vocabulary, located in the code, and ranked by how much it raises the cost of the next change. The inventory is the diagnosis; the refactor is the treatment; and you do them in that order so that “I improved it” becomes a claim you can check instead of a feeling you have.
After this lesson you can take a realistic messy module and produce a prioritized smell inventory: name each smell using this track’s vocabulary (primitive obsession, type switch, shotgun surgery, divergent change, low cohesion), point to the exact lines that carry it, and rank the list by change-cost impact tied to the changes you actually expect — so that diagnosis precedes treatment and the eventual refactor has a baseline to be measured against.
Name the smell before you name the fix. A vague “this is messy” justifies any edit and therefore none. A precise name — primitive obsession, shotgun surgery, divergent change — pins down the exact force raising your change-cost, points at a known refactoring, and lets a reviewer agree or disagree. The inventory is a list of named diagnoses, not a list of edits you feel like making.
// Vague: "the checkout file is a mess" → no agreement possible, no plan.
// Precise (one inventory row):
// smell: primitive obsession
// location: total/tax/discount modelled as bare `number`
// why: every site re-derives currency, scale, rounding → bugs disagree
// refactor: introduce a Money value objectThe discipline: each row says what (the smell), where (the lines), and why it costs — never jumps straight to the diff.
Locate the smell — a name with no coordinates is unactionable. “There is primitive obsession somewhere” cannot be reviewed, refactored, or verified. The inventory cites the symbol, the lines, and crucially the spread: a 0.2 literal in one function is local; the same rule copied into rendering, invoicing, and reporting is shotgun surgery — the same logical change forced into many physical edits. Spread is what turns a small smell into an expensive one.
// Same rule, different cost — locate the SPREAD, not just one instance.
function renderInvoice(o: Order) { return o.subtotal * 0.2; } // tax here
function emailReceipt(o: Order) { return o.subtotal * 0.2; } // ...and here
function monthlyReport(o: Order) { return o.subtotal * 0.2; } // ...and here
// One business decision ("tax is region-based now") → three edits, miss one → silent bug.A smell’s location plus its spread is what lets you estimate blast radius, which is the input to ranking.
Rank by change-cost impact, not by ugliness. Not every smell deserves the same urgency. Rank each row by likelihood of the change × blast radius if it comes. A switch (payment.type) that has grown to five cases and is duplicated in three files, in a module whose whole reason to exist is “we keep adding payment methods,” is top of the list: the expected change is frequent and its blast radius is wide. A slightly long but stable helper that hasn’t been touched in a year ranks low even if it’s the ugliest thing on screen — refactoring it buys you nothing because no change is coming.
// Ranking is a product, not a beauty contest.
// type-switch on payment, ×3 files → likely change: HIGH, blast radius: WIDE → P0
// primitive `Money` everywhere → likely change: MED, blast radius: WIDE → P1
// long render function, stable → likely change: LOW, blast radius: LOCAL → P3This is the senior move: you triage the mess against the roadmap, so effort lands where the next change actually hurts.
Tie the inventory to a baseline, or you can’t prove you improved anything. The reason diagnosis comes before treatment is not bureaucracy — it is measurability. If you start editing first, the original behaviour and the original smell list are both gone, and “is it better?” has no answer. So before any structural edit, two things must exist: the written inventory (so you know which defects you were targeting), and a characterization-test safety net pinning current behaviour (so a refactor can’t change behaviour while you’re “cleaning”). The inventory tells you what you intend to remove; the tests tell you that you removed only that.
// Baseline first. Pin behaviour you don't yet understand — bugs included — so the
// refactor is provably behaviour-preserving and the smell list is the scorecard.
test("checkout total for a 2-item USD cart, card payment", () => {
expect(checkout(sampleCart, { type: "card" })).toEqual(EXPECTED_SNAPSHOT);
});
// Now each refactor either removes an inventory row and stays green, or it's wrong.Inventory + characterization tests = a before-state you can refactor against. Without them, refactoring is indistinguishable from rewriting by vibes.
Diagnose this module. Here is the realistic mess this capstone hands you — an order/checkout service that mixes presentation, business rules, and persistence, with primitive obsession and a growing payment switch, and no tests:
// checkout.ts — one function does everything
function checkout(cart: any, payment: any): string {
let total = 0;
for (const item of cart.items) total += item.price * item.qty; // money as bare number
total = total + total * 0.2; // tax literal, also in invoice.ts + report.ts
if (cart.coupon) total = total * 0.9; // discount literal, scattered
let fee = 0; // growing payment switch
if (payment.type === "card") fee = total * 0.029 + 0.30;
else if (payment.type === "paypal") fee = total * 0.034;
else if (payment.type === "wire") fee = 15;
// (a new branch gets added here every quarter)
db.query(`INSERT INTO orders VALUES (${total}, '${payment.type}')`); // persistence inlined + SQLi
return `<div class="receipt">Total: $${(total + fee).toFixed(2)}</div>`; // HTML built inline
}The inventory — named, located, ranked:
P0 type switch on payment.type checkout.ts:9–12 (+ retry.ts, analytics.ts)
why: the module's whole job is "add payment methods"; every new method edits N files
→ replace-conditional-with-polymorphism (a PaymentMethod per type)
P0 mixed responsibilities / low cohesion the whole function
why: presentation + rules + persistence in one place → 3 unrelated actors edit one function (divergent change)
→ split into compute / persist / render seams (SRP)
P1 primitive obsession on money total/tax/discount as bare `number`
why: currency, scale, rounding re-derived everywhere; tax 0.2 duplicated → shotgun surgery on a rate change
→ Money value object; one tax/discount boundary
P2 SQL injection + inlined persistence checkout.ts:14
why: a real bug, but isolated to one line; parameterize now, extract the repository during the refactor
P3 `any` types on cart/payment signature
why: no compiler help, but stable shape; type after seams existNotice what the inventory does not do: it does not start editing. It commits — on paper — to which defects exist, where, and in what order they’ll be removed. The P2 SQLi is a genuine bug but ranks below the structural P0s for refactor sequencing (you’d still patch the injection immediately as a fix, kept separate from the refactor). That ordered list, plus a characterization test capturing the current <div> output and DB row, is the baseline the next lesson refactors against — and the only thing that lets you later say “P0 and P1 are gone, behaviour unchanged” with evidence.
▸Why this works
Why inventory first instead of just refactoring as you read? Because ranking is a global decision and editing is local. While reading line 9 you can’t yet see that the same tax literal reappears in report.ts, so an edit made there would be premature — you’d fix the local instance and miss that the real smell is the spread. The inventory forces you to survey the whole blast radius before committing effort, which is exactly the information that turns “ugliest first” into “most expensive first.” Diagnosis is cheap and reversible; treatment is not. Do the cheap, reversible, global step before the expensive, local, irreversible one.
▸Common mistake
The signature failure mode: opening the file and immediately renaming variables, extracting a helper, or “just fixing” the switch. It feels productive and it destroys your baseline. Once you’ve edited, the original behaviour and the original smell list are both gone, so the question “did this refactor help?” has no answer — you can only say the code looks different. Worse, mid-edit you’ll discover a smell you didn’t plan for, fix that too, and now you’re three half-finished refactors deep with no green tests. The rule is strict: no structural edit before the module is characterized (behaviour pinned) and inventoried (smells named, located, ranked). Diagnosis before treatment, always.
You're handed the messy checkout module and told to clean it up. The module's roadmap shows new payment methods are added almost every quarter. What is the single most senior first move?
A capstone refactor starts with diagnosis, not editing. The smell inventory is that diagnosis: each row names a smell in the track’s vocabulary (primitive obsession, type switch, shotgun surgery, divergent change, low cohesion), locates it down to lines and spread, and ranks it by likelihood of the expected change × blast radius — so the duplicated payment switch in a module built to grow payment methods sits at the top, while a stable-but-ugly helper sits at the bottom. You pair the inventory with characterization tests so a baseline exists; without that baseline, “I improved it” is unfalsifiable. The failure mode the whole exercise prevents is editing by feel before characterizing, after which you can no longer tell a refactor from a rewrite. Diagnose, rank against the roadmap, then treat.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.