2.19 Refactoring and technical debt
Overview and motivation
Refactoring is changing the internal structure of code without changing what it does from the outside. You rename a variable, split a long function, extract a class, collapse a tangle of conditionals into something a reader can follow, and the program behaves exactly as before. That last part is the whole discipline. Refactoring is behavior-preserving by definition, and the moment you also change behavior you are no longer refactoring, you are doing two risky things at once and hiding each one behind the other. This chapter treats those as separate acts on purpose, because the confusion between them is where most refactoring goes wrong.
On a large team this matters more than it does for a solo developer, because the code you are cleaning up is code hundreds of other people read, depend on, and are afraid to touch. Refactoring is how a shared codebase stays habitable across years and staff turnover. It connects directly to software construction (chapter 2.9), which is where day-to-day coding quality is set, and to testing strategy (chapter 2.4), which is the safety net that makes refactoring safe at all. It also connects to the harder question of technical debt: the accumulated cost of shortcuts, aging designs, and deferred cleanup that make every future change slower. Refactoring is the main way you pay that debt down, so the two topics belong in one chapter.
In enterprise and government settings the stakes rise. These systems are long-lived, often decades old, and frequently under audit and change-control regimes that treat any code change as a governed event. You cannot simply rewrite a citizen benefits system over a long weekend; you modernize it in small, reversible, evidence-backed steps, which is exactly what disciplined refactoring gives you. Coordinating that work across many teams and long-lived systems (chapter 10.4) is one of the defining challenges of large-scale engineering, and getting it wrong is how organizations end up frozen, unable to change software they no longer understand.
Key principles
- Refactoring preserves behavior; if you are changing what the code does, that is a separate change, done separately.
- A trustworthy test suite is the precondition for safe refactoring, not an optional extra.
- Work in small, named, reversible steps, and keep the code working after every one.
- Make technical debt visible and tracked, then finance paydown as steady capacity rather than heroics.
- Refactor the code you are already changing, where the cleanup earns its keep.
- Not all debt is worth paying; stable, rarely touched, or soon-retired code can be left alone.
- Measure internal quality to inform judgment, never as a target to be gamed.
Recommendations
Keep refactoring and behavior change strictly separate
Decide before you start which one you are doing, and never blur the two in a single commit. When you refactor, the tests that passed before must pass after, unchanged, because the observable behavior has not moved. When you change behavior, do that as its own commit with its own tests. The reason is practical: if a mixed change breaks something, you cannot tell whether your restructuring introduced the bug or your behavior change did, and in code review (chapter 2.5) a reviewer cannot reason about either half cleanly. The habit that works is the two-hat rule from Martin Fowler: you are always wearing either the refactoring hat or the feature hat, you know which, and you switch deliberately. Separate commits also make version control history readable, so an engineer bisecting a failure can skip the pure-refactor commits with confidence.
Establish a trustworthy safety net before you restructure
Refactoring without tests is just editing and hoping. Before you restructure anything of consequence, you need a suite you trust to catch a behavior change if you cause one, which is the core argument of testing strategy (chapter 2.4). For code that already has good coverage, run the tests, refactor in small steps, and run them again after each step. For legacy code with no tests, the honest move is to write characterization tests first. A characterization test does not assert what the code should do; it captures what the code actually does right now, including its quirks, so that any change in behavior shows up as a failing test. Michael Feathers popularized this approach for exactly the situation large organizations live in: code that works, matters, and has no tests. Once the current behavior is pinned down, you can refactor underneath it safely, and only then change behavior on top.
Learn to recognize code smells and apply small named refactorings
A code smell is a surface sign that something underneath may need attention: a function that has grown too long, a class that knows too much, duplicated logic, a long parameter list, names that lie about what they do. A smell is a hint, not a verdict, so you investigate rather than obey it blindly. The response is a small, named refactoring from Fowler’s catalog: Extract Function, Rename Variable, Move Method, Replace Conditional with Polymorphism, and dozens more. The value of using named moves is that each one is small, understood, mechanically safe, and often supported directly by your IDE. You compose big improvements out of many tiny reliable steps, keeping the code green throughout, rather than making one large leap you cannot verify.
Prefer opportunistic refactoring, and reserve campaigns for real structural need
Most refactoring should be opportunistic, folded into the work you are already doing. The boy-scout rule captures it: leave the code a little cleaner than you found it. When you touch a file to add a feature or fix a bug, you already understand that corner, and small cleanups there compound over time without needing anyone’s permission or a separate budget. Planned refactoring campaigns, where a team stops feature work to restructure a large area, are sometimes necessary, but they are expensive, hard to schedule against product pressure, and risky if the area is poorly tested. Reserve campaigns for structural problems that opportunistic cleanup cannot reach, and make the case with evidence about the change-cost you are paying. Prefer the steady drip of small cleanups; it is more durable than the occasional heroic rewrite.
Use the strangler fig pattern for large structural change
When a whole subsystem needs replacing, do not attempt a big-bang rewrite that runs for a year and merges at the end; that is how modernization projects die. Use the strangler fig pattern, named by Martin Fowler after the vine that grows around a tree and gradually replaces it. You put a facade in front of the old system, route one slice of functionality at a time to new code behind that facade, verify it in production, and repeat until the old system is fully surrounded and can be removed. Each slice is small, shippable, and reversible, so risk stays bounded and value arrives continuously. A close cousin, branch by abstraction, does the same inside a single codebase: you introduce an abstraction layer over the thing you want to replace, build the new implementation behind it while both coexist, switch consumers over gradually, and delete the old implementation once nothing depends on it. Both let a legacy system evolve while staying alive, which is the only kind of modernization most large organizations can actually afford.
Treat technical debt as a portfolio, and make it visible
The debt metaphor, coined by Ward Cunningham, separates two things: the principal (the messy code or shortcut itself) and the interest (the extra effort every future change pays because of it). Not all debt is equal. Fowler’s quadrant sorts it along two axes: deliberate versus inadvertent, and prudent versus reckless. Prudent-deliberate debt (“we ship now and clean up next sprint, and we know the cost”) is a legitimate business decision. Reckless-inadvertent debt (“what is a design pattern?”) is just damage. The management job, which connects to decision-making and governance (chapter 1.5) and its treatment of debt as a portfolio, is to make the debt visible so it can be reasoned about: track significant items where the work lives, tag the code, and record the interest you are paying so paydown competes for capacity on evidence rather than on whoever complains loudest. Debt you cannot see, you cannot manage.
Finance paydown as steady capacity, not heroics
The failure mode is treating cleanup as something you will do “when things calm down,” which is never. The durable pattern is a fixed, protected capacity for paydown: an explicit slice of every cycle, or a standing agreement that cleanup rides along with feature work in the same area. What does not work is the periodic heroic sprint where someone burns a weekend to fix everything, because it is unsustainable, unreviewed, and usually undoes itself. Steady capacity keeps interest payments down and avoids the boom-bust cycle where debt accretes until a crisis forces an expensive rewrite. This is a management commitment as much as an engineering practice, and it belongs in how you plan software maintenance (chapter 3.7) over the life of a system.
Measure internal quality, but do not let the measure become the target
You can measure internal quality with signals such as cyclomatic complexity (a count of independent paths through a function), duplication, test coverage, change-failure rate, and how long changes take in the areas you suspect. These numbers are useful for spotting where debt concentrates and for watching a trend over time. The danger is Goodhart’s law: when a measure becomes a target, it stops measuring anything real. Mandate a coverage number and you get tests that assert nothing; reward low complexity scores and you get logic smeared across more functions to dodge the metric. Use metrics to start conversations and locate hotspots, and never wire a quality metric to a gate that people are motivated to game.
Know when not to refactor
Refactoring is an investment, and some code will never pay it back. If a module is stable, rarely touched, and understood well enough to change on the rare occasions you must, cleaning it up is effort spent for interest you were not paying. If code is slated for retirement, refactoring it is polishing something you are about to throw away. The discipline is to spend your cleanup budget where change is frequent and painful, which is where reducing the interest actually compounds, and to leave the quiet corners alone.
Trade-offs: pros and cons
| Approach | Pros | Cons |
|---|---|---|
| Opportunistic refactoring (boy-scout rule) | Cheap, continuous, no separate budget, compounds over time | Uneven coverage; hot files improve while cold ones rot |
| Planned refactoring campaign | Fixes structural problems cleanup cannot reach | Expensive; competes with features; risky without good tests |
| Strangler fig / branch by abstraction | Incremental, reversible, keeps system live, bounds risk | Slower than a rewrite on paper; needs discipline to finish |
| Big-bang rewrite | Clean slate; no legacy constraints | High failure rate; long time to value; behavior gaps |
| Deliberate prudent debt | Ships value now; explicit, planned payoff | Turns reckless if the payoff never gets scheduled |
| Metric-gated quality | Objective, visible, catches drift early | Invites gaming; punishes nuance; can degrade real quality |
The central tension is speed now versus changeability later, and it is real. Shipping a shortcut can be the right call when the deadline is genuine and the debt is prudent and tracked. The mistake is pretending the debt is free, or letting it accumulate invisibly until the system is too expensive to change. Resolve it by making the trade explicit every time: name the debt, estimate the interest, decide deliberately, and record the decision so paydown can be scheduled rather than forgotten. A team that borrows knowingly and pays steadily stays fast for years; a team that borrows blindly grinds to a halt.
Questions to discuss with your team
How do we keep refactoring and behavior change from bleeding into the same commit, and does our review actually enforce it? This is the foundational discipline of the whole chapter, and it is the one most often violated under deadline pressure, because it feels efficient to “clean this up while I’m in here” and ship it all together. The cost lands later: when a mixed commit breaks production, no one can tell whether the restructuring or the feature caused it, and a bisect through your history stops being trustworthy. Bring a handful of recent pull requests and check honestly how many mixed the two hats. The competing consideration is friction, since splitting work into separate commits is a little more effort up front. The answer should shape your commit conventions and your review checklist.
Where is our technical debt, how much interest are we paying on it, and who decides what gets paid down? Most teams cannot answer this, which is the actual problem, because debt you cannot see gets managed by whoever complains loudest rather than by where the cost really is. Making it visible means tracking significant items, tagging the code, and gathering evidence about which areas make changes slow and failure-prone. The competing pull is that every hour spent on paydown is an hour not spent on features, so the decision has to be a portfolio decision made with leadership, connecting to how you govern engineering work (chapter 1.5). Bring your change-failure data and your list of the files everyone dreads touching. The answer should turn into a protected, steady paydown capacity, not a vague intention to clean up when things calm down.
Which parts of our codebase should we deliberately not refactor, and how would we know? Refactoring everything is as much a failure as refactoring nothing, because effort spent cleaning stable, rarely touched, or soon-retired code is interest paid on a loan you did not owe. The judgment call is real: a module can look ugly and still be the wrong place to invest if nobody ever changes it. Bring your change-frequency data alongside your complexity signals, because the intersection of high churn and high complexity is where cleanup compounds, while low-churn code is usually best left alone. The competing risk is that “we’ll leave it” becomes an excuse to never touch anything hard. The answer should give you an explicit shortlist of hot spots worth investment and permission to ignore the quiet corners.
Do we actually trust our test suite enough to refactor the code we most need to change, and where would we have to write characterization tests first? A safety net you cannot trust turns refactoring into editing and hoping, and on a large team the scariest code is usually the least-tested code, which is exactly where cleanup would pay off most. Bring the coverage and change-failure data for your hot spots, and be honest about which critical modules would give you no warning if a restructuring changed behavior. The competing consideration is that writing characterization tests for legacy code is slow, unglamorous work that ships no feature, so it is easy to defer forever. In enterprise and government systems under audit and change-control, those pinned-down tests are also the evidence that a change preserved behavior, so funding them is a safety measure and a compliance one at once; the answer should name which areas get a test harness before anyone touches them.
When a subsystem genuinely needs replacing, how do we decide between an incremental strangler fig approach and a rewrite, and who has authority to say no to the rewrite? The big-bang rewrite is the most seductive and most failure-prone option on the table, because a clean slate always looks cheaper on paper than living with the old constraints. For a large organization the incremental path (a facade, one slice at a time, verified in production) keeps the system alive and bounds risk, but it is slower, demands discipline to finish, and competes with the appetite for a fresh start. Bring the change-frequency map of the subsystem, an honest estimate of how long a rewrite would run before it delivered value, and the behavior gaps a parallel rewrite would have to close. In government and regulated settings a multi-year rewrite that merges at the end is rarely survivable under audit, so the answer should default to strangler fig or branch by abstraction and treat any rewrite as an exception that must be argued for with evidence.
How do we use internal-quality metrics to find where debt concentrates without letting a number become a target people game? Metrics such as complexity, duplication, coverage, and change-failure rate are the only way a large organization can see across code no single person reads, yet the moment one is wired to a gate or a performance review, Goodhart’s law takes over and the number stops measuring anything real. Bring examples of where a metric already drives behavior, and ask whether it is starting conversations or quietly rewarding tests that assert nothing and logic smeared across functions to dodge a threshold. The competing pull is that leadership wants a simple dashboard number, and “use judgement” is a harder sell than a green bar. In enterprise and government contexts where metrics feed governance reporting, be explicit that quality signals inform investment and locate hotspots but never gate individuals; the answer should draw a firm line between measuring to learn and measuring to judge.
Sector lens
Startup. With a handful of engineers and no runway to spare, refactor only opportunistically: wear one hat per commit so history stays bisectable, and keep a short honest list of the shortcuts you took on purpose. Do not launch cleanup campaigns or polish stable modules; spend your scarce attention on the one file everyone dreads, and write characterization tests only where a change actually scares you. Deliberate, visible debt is fine at this stage; reckless invisible debt is what kills you.
Small business. With no dedicated platform or tooling specialist and a tight budget, lean on what your IDE and language ecosystem give you for free: automated rename and extract moves, a linter, and a basic coverage signal. Treat most debt as something you manage in the course of normal work rather than something you hire a consultant to fix, and prefer buying well-maintained libraries over building and then having to refactor your own. Reserve the rare paid effort for the one system whose slowness is directly costing you customers.
Enterprise. Across many teams the problem is portfolio governance: a shared debt register, consistent tagging of hot spots by change frequency and complexity, and a protected slice of every team’s capacity for paydown so cleanup stops losing to features by default. Standardize the two-hat discipline and characterization-test practice so any engineer moving between teams finds the same rules, and use strangler fig and branch by abstraction for structural change coordinated across groups. Keep quality metrics informational so they locate debt without being gamed in performance reviews.
Government. Long-lived systems under strict audit and change-control make disciplined refactoring a compliance asset, not just an engineering one: keeping restructuring strictly separate from behavior change lets auditors see exactly which commits altered behavior and which merely tidied. Procurement and transparency rules favor small, reversible, evidence-backed steps over big-bang rewrites, so default to strangler fig with characterization tests documenting that behavior is preserved. Make the debt register and its paydown plan part of the system’s maintenance record so oversight bodies get the traceability they require.
Examples
Startup. A six-person startup ships fast and knows it is taking on debt, so it does two cheap things well. Every pull request wears one hat: refactor commits are separate from feature commits, which keeps their history bisectable even at high velocity. And they keep a short, honest list of the shortcuts they took on purpose, with a one-line note on the interest each one costs. When a payment module becomes the file everyone dreads, that list plus their change-failure history makes the case to spend two days extracting a cleaner boundary. They write characterization tests to pin the current behavior, refactor underneath with the IDE’s rename and extract moves, and never touch the stable modules that no one changes. The debt they carry is deliberate and visible, so it never turns into the reckless kind.
Enterprise. A global logistics company runs a fifteen-year-old order system that many teams change every week. Rather than a rewrite, they adopt the strangler fig pattern: a facade sits in front of the monolith, and one bounded capability at a time is rerouted to new services behind it, verified in production before the next slice begins. Coordinating this across teams and a long-lived system (chapter 10.4) is the hard part, so they maintain a shared debt register, tag hot spots by change frequency and complexity, and reserve a fixed slice of each team’s capacity for paydown. Internal-quality metrics inform where to look but never gate anyone’s performance review, which keeps the numbers honest. Over two years the monolith shrinks steadily and no single change ever risks the whole system.
Government. A national tax agency must modernize a decades-old assessment platform under strict audit and change-control rules, where every code change is a governed, evidence-backed event. A big-bang rewrite is impossible, so they use branch by abstraction: an abstraction layer is introduced over the legacy calculation engine, a new implementation is built behind it, and consumers are migrated one tax rule at a time, each migration documented as a small, reversible change with characterization tests proving behavior is unchanged. Because refactoring is kept strictly separate from any legislative behavior change, auditors can see exactly which commits altered behavior and which merely restructured. The debt register and its paydown plan become part of the system’s maintenance record (chapter 3.7), giving oversight bodies the traceability they require.
Business case: motivations, ROI, and TCO
The return on refactoring and debt paydown is the sustained ability to change software cheaply, and for most systems the majority of lifetime cost is maintenance, so this is where total cost of ownership is largely decided. Interest on technical debt is paid in the currency leadership already tracks: slower delivery, higher change-failure rate, longer time to recover from incidents, and engineers who avoid the scariest code. When you make debt visible and finance paydown steadily, you lower the cost of every future change in the areas that matter most, and avoid the boom-bust pattern where neglected debt forces an expensive emergency rewrite.
The cost to adopt is modest and mostly cultural: establish the two-hat discipline, build the safety net where you need to refactor, keep a debt register, and protect a steady slice of capacity for paydown. The cost of neglect compounds silently. Interest accrues on every change until velocity collapses and the organization finds itself frozen, unable to safely modify a system it no longer understands, which is the most expensive outcome of all. To make the case to leadership, connect debt directly to delivery metrics they already care about, and frame paydown as a portfolio decision with a measurable payoff, not as engineers asking for time to tidy up.
Anti-patterns and pitfalls
- Mixing refactoring with behavior change: one commit does both, so a break cannot be attributed and history becomes untrustworthy.
- Refactoring without a safety net: restructuring untested code and hoping, which is editing by faith.
- The big-bang rewrite: replacing a working system all at once, a pattern with a high failure rate and long time to value.
- Refactoring as a heroic weekend: unreviewed, unsustainable cleanup that undoes itself instead of steady capacity.
- Invisible debt: shortcuts nobody tracks, so paydown is driven by complaint volume rather than actual cost.
- Gaming quality metrics: hitting a coverage or complexity target while real quality drops, because the measure became the target.
- Refactoring the wrong code: polishing stable or soon-retired modules while the true hot spots keep costing you.
- Perpetual refactoring: endless restructuring that never ships value, the mirror image of never cleaning up.
Maturity model
- Level 1, Initiate: Refactoring is ad hoc and reactive, often mixed with behavior change in the same commit. There is no trustworthy safety net, technical debt is invisible and untracked, and cleanup happens only in occasional heroic bursts or not at all.
- Level 2, Develop: Some teams separate refactoring from behavior change and lean on tests where they exist, and named refactorings and characterization tests appear in pockets. Practice is inconsistent across teams, debt is discussed and sometimes logged, and paydown competes ad hoc against features and usually loses.
- Level 3, Standardize: The two-hat discipline, characterization tests for legacy code, and small named refactorings are documented and expected org-wide. Debt is tracked in a shared register that separates principal from interest, and a protected capacity for paydown is planned each cycle and enforced in review.
- Level 4, Manage: Debt and cleanup are measured and controlled with data against baselines. You track change frequency and complexity to locate hot spots, watch change-failure rate and change lead time in refactored areas, and record the interest each significant item costs, so paydown decisions rest on evidence and kill-or-invest calls are made on trends rather than complaint volume. Quality signals inform investment without being wired to gates people can game.
- Level 5, Orchestrate: Debt is managed as a continuously rebalanced portfolio integrated with product and maintenance planning across the whole organization. Structural change routinely uses strangler fig and branch by abstraction coordinated across teams, paydown is continuous and matched to where change is frequent and painful, and the practice adapts as the system and its risk picture shift, so long-lived code stays changeable over decades.
Ideas for discussion
- What is your team’s actual, enforced rule for keeping refactoring separate from behavior change, and where does it break down under deadline pressure?
- How do you decide, with evidence, which code deserves cleanup and which is best left alone?
- Where would characterization tests let you safely refactor a legacy area you currently avoid?
- For your next major modernization, what would a strangler fig approach look like, and what facade or abstraction would you introduce first?
- Who owns the technical-debt register, and how does paydown actually win capacity against feature work?
Key takeaways
- Refactoring preserves behavior; keep it strictly separate from behavior change, in separate commits.
- A trustworthy test suite is the precondition for safe refactoring, and characterization tests give legacy code one.
- Work in small, named, reversible steps, prefer opportunistic cleanup, and use strangler fig or branch by abstraction for big structural change.
- Make technical debt visible, separate principal from interest, and finance paydown as steady capacity rather than heroics.
- Measure internal quality to guide judgment, never as a target to game, and do not refactor code that is stable or slated for retirement.
References and further reading
- Martin Fowler, Refactoring: Improving the Design of Existing Code, second edition
- Michael Feathers, Working Effectively with Legacy Code
- Ward Cunningham, The WyCash Portfolio Management System (OOPSLA 1992 experience report, origin of the debt metaphor)
- Martin Fowler, “TechnicalDebtQuadrant” and “StranglerFigApplication” (martinfowler.com)
- Kent Beck, Tidy First? A Personal Exercise in Empirical Software Design
- Steve McConnell, Code Complete: A Practical Handbook of Software Construction
- Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship