Capstone II: the incremental refactor
Execute the part-1 refactor end to end. Net the module in characterization tests, then make one small reversible move per commit under green — extract, value objects, polymorphism for the payment switch, a persistence port — never sneaking a behavior change into a refactor.
In part 1 you read the messy OrderProcessor module, named its smells, and wrote down a plan: extract the tangled methods, give Money and the order id real types, kill the paymentType switch with polymorphism, and put the database behind a port. The plan is correct. The plan is also where most engineers ruin the module — they try to do all of it at once, in one giant branch, and a week later they have a half-rewritten file that no longer matches the tests and can’t be shipped or reverted.
This lesson is the execution: the same safe sequence — net it, then make one small reversible move per commit, never changing behavior mid-step — applied from the first characterization test to the inverted persistence dependency. The skill on display is not any single move; it is the rhythm that keeps the module shippable at every commit while it transforms underneath you.
After this lesson you can execute a real refactor end to end without a big-bang rewrite: write characterization tests to pin current behavior before you touch structure; sequence the moves so each is small, reversible, and green; perform the key moves concretely in TypeScript — extract method/class, introduce value objects, replace a type switch with polymorphism, invert a persistence dependency behind a port — one commit per move; and explain why folding a behavior change into a refactor commit destroys your ability to isolate a regression.
Net first: characterization tests pin what the module does today, before you understand why. You do not refactor untested legacy code by reading it harder. You pin it. A characterization test asserts the module’s current observed behavior — not the behavior you wish it had — so that any later structural move that changes output turns a test red instantly.
// characterize.test.ts — written BEFORE any structural change.
// We don't yet trust the rounding or the fee math; we record what it DOES.
test("card order total with tax and fee", () => {
const out = processOrder({ items: [{ price: 1000, qty: 2 }], paymentType: "card" });
expect(out.total).toBe(2070); // 2000 + 5% tax + 2% card fee, as-is
expect(out.currency).toBe("USD");
});
test("invoice order skips the card fee", () => {
const out = processOrder({ items: [{ price: 999, qty: 3 }], paymentType: "invoice" });
expect(out.total).toBe(3147); // whatever it currently computes — pin it
});The number 2070 might encode a bug. That is fine — you are not fixing it now, you are recording it. A characterization test that locks in a wrong value is still doing its job: it guarantees your refactor preserves behavior. Fix the bug later, as its own commit, after the structure is clean enough to fix it safely. No structural edit happens until the net is green.
Extract under green, one move per commit — and commit the message that says “refactor”. With the net in place, the first moves are the cheap, mechanical ones from the catalog. Pull the tangled blocks of processOrder into named methods, then lift the cohesive cluster into a class. Each is its own commit; the suite is green before and after every one.
// commit "refactor: extract calcTax / calcFee" — behavior identical
function calcTax(subtotal: number): number { return Math.round(subtotal * 0.05); }
function calcFee(subtotal: number, paymentType: string): number {
return paymentType === "card" ? Math.round(subtotal * 0.02) : 0;
}
// commit "refactor: extract OrderProcessor class" — same body, new home
class OrderProcessor {
process(order: OrderInput): OrderResult {
const subtotal = order.items.reduce((s, i) => s + i.price * i.qty, 0);
const tax = calcTax(subtotal);
const fee = calcFee(subtotal, order.paymentType);
return { total: subtotal + tax + fee, currency: "USD" };
}
}Nothing here is clever, and that is the point. These are behavior-preserving moves; the green net proves it after each. If a test goes red, you reverted one small commit, not a week of work — the blast radius of any mistake is exactly one move.
Introduce value objects to retire primitive obsession — still no behavior change. The module passes raw numbers for money and raw strings for ids; that is primitive obsession, and it is why rounding and currency logic leaked everywhere. Wrap them. The move is still a refactor: Money must compute the same numbers the net already pinned.
// commit "refactor: introduce Money value object"
class Money {
private constructor(readonly cents: number, readonly currency: "USD") {}
static usd(cents: number): Money { return new Money(Math.round(cents), "USD"); }
add(o: Money): Money { return Money.usd(this.cents + o.cents); }
percent(p: number): Money { return Money.usd(Math.round(this.cents * p)); }
}
// commit "refactor: introduce OrderId" — a typed id, no more bare string
class OrderId { private constructor(readonly value: string) {}
static of(v: string): OrderId { return new OrderId(v); } }Because Money.usd rounds exactly where the old code did, the pinned 2070 still holds — the test stays green. The win is that rounding now lives in one place instead of being re-derived at every call site, which is what makes the next change cheap. Crucially, you did not “fix” the rounding while you were in there; if the rounding is wrong, that is a separate, later, behavior-changing commit.
Replace the payment-type switch with polymorphism, then invert the persistence dependency behind a port. Two structural moves remain, and they are the high-value ones. First, the paymentType switch that fans out across the module becomes a small strategy hierarchy — Open/Closed: a new payment type is a new class, not an edit to a switch in five files.
// commit "refactor: replace paymentType switch with PaymentMethod polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
const methods: Record<string, PaymentMethod> = { card: new Card(), invoice: new Invoice() };Then invert the persistence dependency. OrderProcessor currently imports a concrete db; that is the wrong-way arrow. Define a OrderRepository port the processor owns, and make the database an adapter that implements it — the high-level policy stops depending on the low-level detail.
// commit "refactor: introduce OrderRepository port"
interface OrderRepository { save(id: OrderId, result: OrderResult): Promise<void>; }
// adapter lives at the edge; processor depends on the interface, not on `db`
class PostgresOrderRepository implements OrderRepository { /* the old SQL, moved */ }Both moves are behavior-preserving: same fees, same persisted rows, same green net. They only change who depends on whom.
One move, in isolation: the payment switch becomes polymorphism — without changing a single fee. Before, the type leaks across the module as a stringly-typed switch the processor has to know about:
// before — Open/Closed violated: a new type edits this switch in every place it appears
function feeFor(subtotal: number, paymentType: string): number {
switch (paymentType) {
case "card": return Math.round(subtotal * 0.02);
case "invoice": return 0;
case "wire": return 1500; // flat $15
default: throw new Error("unknown payment type");
}
}After, each rule owns its own class and the processor looks the method up by key:
// after — commit "refactor: replace paymentType switch with polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
class Wire implements PaymentMethod { fee(_: Money) { return Money.usd(1500); } }
const methods: Record<string, PaymentMethod> = {
card: new Card(), invoice: new Invoice(), wire: new Wire(),
};
function methodFor(type: string): PaymentMethod {
const m = methods[type];
if (!m) throw new Error("unknown payment type"); // SAME error, preserved on purpose
return m;
}The fees are byte-for-byte identical: card is still 2%, invoice still 0, wire still a flat $15, the unknown-type error message unchanged — so the characterization net stays green and this ships as a pure refactor. The payoff is the next change: adding crypto is a new Crypto class plus one map entry, with zero edits to the processor. What you must resist is the tempting two-for-one — “while I’m here, wire fee should really be 1%”. That is a behavior change; it goes in its own commit, on top, after this one is green and committed, so that if a fee regression surfaces later, git bisect can point at exactly one cause.
▸Why this works
Why net the module before doing the moves you already know are correct? Because “I know this is behavior-preserving” is exactly the confidence that ships regressions. The extract looks trivial — until the old processOrder had a subtle off-by-one in fee rounding that your “cleaner” extraction silently corrects, changing an invoice total in production. With a characterization test pinning 3147, your clean extraction goes red the instant it diverges, and you see the behavior change you didn’t intend before it leaves your machine. The net’s value is highest precisely on the moves you are most sure about, because those are the ones you’ll do fastest and check least. No net, no refactor — only a hopeful rewrite.
▸Common mistake
The two failure modes that wreck a capstone like this. The big-bang rewrite: you open a branch, change everything at once, and three days later you have a beautiful new module that doesn’t pass the old tests and that nobody can review or revert — you’ve replaced a known-imperfect system with an unknown one, and the only path forward is more big-bang. The smuggled behavior change: mid-refactor you “fix” the rounding or tweak the wire fee inside the extract commit. It works, the suite is green, you push. Weeks later a finance regression appears, git bisect lands on refactor: extract OrderProcessor, and now that commit contains both a structural move and a behavior change — bisect cannot tell you which, and the behavior-preserving guarantee that let reviewers skim it is gone. The rule that prevents both: a commit is either a refactor (structure changes, behavior pinned) or a behavior change (behavior changes, structure pinned) — never both, and never all of it at once.
Mid-way through extracting the OrderProcessor class, you notice the tax rounding is off by a cent and fix it in the same 'refactor: extract OrderProcessor' commit. The suite is green. Why will a senior reviewer block this?
The incremental refactor is a sequence, not an event. You net the module in characterization tests first — pinning current behavior, bugs and all — so any structural move that changes output goes red on your machine instead of in production. Then you make one small reversible move per commit under green: extract methods and a class, introduce Money and a typed OrderId to retire primitive obsession, replace the paymentType switch with polymorphism so new types are new classes not switch edits, and invert the persistence dependency behind an OrderRepository port so the high-level policy stops depending on the low-level database. Every one of those is behavior-preserving, proven green before and after, committed with a refactor: message a reviewer can skim. The two ways to ruin it are the big-bang rewrite — which trades a known system for an unrevertable unknown — and the smuggled behavior change that fuses a fix into a refactor commit and annihilates git bisect. Keep refactor and behavior strictly separate, move small, commit often, and the messy module from part 1 becomes clean code that was never once broken on the way there.
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.