Legacy code is not a quality judgement — it is a description of code that has outlived its original context. The engineers who wrote it made reasonable decisions with the information they had. Your job is not to fix past mistakes but to safely improve the code while keeping production running. That requires discipline, not heroics.
What changed in 2026
- AI-assisted refactoring is real. Copilot and similar tools can propose renamed variables, extracted functions, and simplified conditionals. They are useful for mechanical transforms but still miss semantic correctness — always run your test suite after AI-suggested changes.
- Codemod tooling matured.
jscodeshift, ast-grep, and semgrep now handle large-scale mechanical refactors across thousands of files reliably. Write a codemod, do not edit by hand.
- Feature flags are infrastructure. OpenFeature and LaunchDarkly are standard; routing traffic incrementally between old and new code is much lower risk than it was.
Before you touch a line
The number-one cause of failed refactors is modifying code you do not understand while breaking things you did not know existed.
- Read the code until you understand what it does. Not why, just what.
- Check the git blame. The history often explains the "why."
- Identify the blast radius. What calls this code? What does it call?
- Write characterisation tests. Lock in the current behaviour before changing anything.
# Characterisation test: document what the code does TODAY
# Even if it seems wrong — that is what callers depend on
def test_calculate_discount_existing_behaviour():
# The original code returns 0.0 for amounts < 10; that may be a bug
# but callers depend on it, so we test it as-is first
assert calculate_discount(5.0) == 0.0
assert calculate_discount(10.0) == 1.0
assert calculate_discount(100.0) == 10.0
Refactor incrementally: the three-move rule
Do not rename AND extract AND restructure in the same commit. It makes diffs unreadable and bugs untraceable.
Move 1 — Rename. Make it clear. One commit.
Move 2 — Extract. Pull out a function or class. One commit, tests passing.
Move 3 — Restructure. Move to a new module. One commit, tests passing.
// Step 1: rename (tests still pass)
// function proc(d) → function processUserData(rawData)
// Step 2: extract a helper (tests still pass)
function validateUserData(rawData: unknown): ValidatedUser { ... }
function processUserData(rawData: unknown) {
const validated = validateUserData(rawData);
// ...
}
// Step 3: move processUserData to features/users/processor.ts (tests still pass)
The Strangler Fig pattern
For large subsystems, replace piece by piece while keeping the old system running.
┌──────────────┐
request ───► │ Router / │──── new feature code ──► 200 OK
│ Feature Flag│
└──────┬───────┘
│
└──── old code (for unmatched routes / flag = false)
- Build the new implementation alongside the old.
- Route a small percentage of traffic (or specific users) to the new code.
- Monitor errors and performance. If clean, increase percentage.
- At 100%, delete the old code.
Never delete the old code until the new code has run successfully at 100% for an agreed period (typically 2–4 weeks).
Dealing with untestable code
Common legacy patterns that resist testing — and how to break them:
| Anti-pattern |
Problem |
Fix |
new inside a function |
Cannot inject a test double |
Pass as a parameter or use a factory |
| Global state / singletons |
Tests affect each other |
Pass state explicitly; reset in setUp |
Direct Date.now() / time.time() |
Non-deterministic tests |
Inject a clock interface |
| Hardcoded side effects (file I/O, HTTP) |
Tests hit real systems |
Wrap in an interface; mock the interface |
| 500-line functions |
Cannot test a sub-path |
Extract methods behind a seam |
The campsite rule
You will not refactor everything today. That is fine. Apply the campsite rule: every time you touch a file, leave it slightly cleaner than you found it.
- Rename one confusing variable.
- Extract one 20-line block into a named function.
- Add one test for an untested path.
- Delete one dead code branch.
Compounded over months, this moves a codebase without a painful "refactoring sprint."
How to start
- Pick the highest-traffic, highest-pain module. Pain means: most bugs filed, slowest to change, most feared by the team.
- Add characterisation tests first.
- Rename everything that is confusing. Run the linter. Commit.
- Extract functions until no function exceeds ~50 lines.
- Delete dead code (code that is never called — your IDE can identify it).
- Repeat on the next module.
Common mistakes
Refactoring and fixing bugs simultaneously. These are separate commits. A refactor should be behaviour-preserving. If you find a bug, fix it in a separate commit with a test.
No test coverage before refactoring. Every "safe" refactor that breaks production was done without tests.
Rewriting in a new language or framework. The old code embeds years of implicit domain knowledge. A rewrite throws it away. Do this only when the economics are undeniable.
Renaming across the whole codebase by hand. Use your IDE's refactor tool or a codemod — never find-replace across files manually.
What to skip
- Big-bang rewrites. Joel Spolsky called it "the single worst strategic mistake a software company can make." The data in 2026 still supports that view.
- Refactoring without running tests after every step. The "tests passing" checkpoint is not optional.
- Architectural changes before the code is readable. First make it clear, then make it correct, then make it fast.
FAQ
How do I convince my team to prioritise refactoring?
Measure and show the cost of the current state: bugs per sprint attributable to this module, average time to add a feature there, onboarding time. Frame as a business cost, not a code quality preference.
What if there are no tests at all?
Write characterisation tests for the top-level public interface first — even if they are integration-level. Any coverage is better than none.
Is it safe to refactor with AI tools?
For mechanical transforms (rename, extract, inline) yes — but always run your full test suite. AI tools miss context-dependent behaviour.
When should I just delete the code?
When you can prove it is never called (dead code) or when the feature it implements is being retired. Never delete code you are unsure about — add a deprecation notice and monitor usage first.
Where to go next