A payment processing module breaks in production. The fix requires changing a function that hasn't been touched since 2019. There are no tests. The original author left the company two years ago. The function is 400 lines long, takes 11 parameters, and the variable names are single letters. You have until tomorrow morning.
Sound familiar?
Michael Feathers defined legacy code as "code without tests." By that definition, roughly 70% of production codebases qualify. The textbook answer is "write characterization tests first, then refactor." But textbooks don't have stakeholders breathing down their neck about a revenue-impacting bug at 4 PM on a Friday.
The Strangler Fig Doesn't Care About Your Sprint
Martin Fowler's strangler fig pattern is elegant. Wrap the old system, gradually replace pieces, eventually decommission. Beautiful in conference talks. In practice, most teams attempt it, get 40% through, then abandon it when priorities shift. Now you have two systems doing the same thing, neither fully working. Congratulations.
The pattern works when leadership commits to finishing it. That happens maybe one in five times. Before proposing a strangler migration, ask one question: will this project still be funded in six months? If the answer involves the word "depends," pick a different strategy.
Scratch Refactoring: The Technique Nobody Talks About
Before touching anything, do a throwaway refactoring pass. Seriously. Open the file, start renaming things, extracting methods, moving stuff around. Don't commit any of it. The goal isn't to produce working code — it's to understand what the code actually does.
After 45 minutes of scratch refactoring, you'll understand more about that 400-line function than you would from reading it for three hours. Your brain learns by doing, not by staring.
Then git checkout . and throw it all away.
Now you know enough to do the real work.
The Seam Model (Actually Useful)
Feathers introduced the concept of "seams" — places where you can alter behavior without editing the code itself. In practice, there are three kinds that matter:
Object seams
Override behavior through inheritance or dependency injection. Most modern codebases have these accidentally — constructor parameters that could accept a different implementation.
// This 2019 monster function
function processPayment(amount, currency, userId, merchantId,
retryCount, timeout, logger, db, cache, queue, config) {
// 400 lines of pain
}
// The seam is right there — db, cache, queue, logger are all injectable
// Wrap it:
class PaymentProcessor {
constructor(
private db: Database,
private cache: CacheClient,
private queue: MessageQueue,
private logger: Logger
) {}
process(payment: PaymentRequest) {
// Same ugly logic, but now you can swap dependencies for testing
return processPayment(
payment.amount, payment.currency, payment.userId,
payment.merchantId, 3, 5000,
this.logger, this.db, this.cache, this.queue, getConfig()
);
}
}
You haven't refactored the internals yet. You've just created a point where you can start testing.
Preprocessing seams
Feature flags. Environment variables. Build-time switches. Put the old code and new code side by side, toggle between them. Not glamorous but it works and it's saved more production systems than any design pattern.
Link seams
Swap out entire modules. In Node.js, this means changing an import path. In .NET, swapping an assembly reference. The nuclear option, but sometimes the right one.
Characterization Tests in 20 Minutes, Not 2 Days
Full characterization tests take forever. Skip them. Write what matters: the golden path and the two most likely failure modes. That's it. Three to five tests.
// You don't need 100% coverage. You need confidence.
describe('processPayment - characterization', () => {
it('processes a standard USD payment', async () => {
// Record what it ACTUALLY returns, not what you think it should
const result = await processPayment(/* known good inputs */);
// Snapshot the result structure
expect(result).toMatchSnapshot();
});
it('handles insufficient funds', async () => {
// The most common failure case
const result = await processPayment(/* inputs that trigger decline */);
expect(result.status).toBe('declined'); // or whatever it actually does
});
it('survives null merchantId', async () => {
// That bug from production last week
// Does it throw? Return null? Silently succeed? FIND OUT.
expect(() => processPayment(100, 'USD', 'u1', null, ...))
.toThrow(); // or .not.toThrow() — document reality
});
});
These tests aren't asserting correctness. They're documenting current behavior. When you refactor and a test breaks, you know you changed something. Whether that change is good or bad is your call, but at least you know.
The Mikado Method for People Who Won't Read the Book
The Mikado Method boils down to this: try the change you want to make. When it breaks (it will), write down what broke. Revert. Fix the prerequisite. Try again. Repeat until it works.
Draw it as a graph. The change you want is the root. Prerequisites branch off it. It looks like a dependency tree because that's exactly what it is.
Real example from a .NET migration:
Goal: Replace Newtonsoft.Json with System.Text.Json
Attempt 1: Swap the package → Breaks because:
- Custom JsonConverter for DateTime formats
- [JsonProperty] attributes everywhere
- Dynamic JObject usage in 3 services
Prerequisite graph:
├── Replace JObject with JsonDocument (3 files)
├── Remove custom DateTime converter (use ISO 8601)
│ └── Update 12 API responses that use weird date format
│ └── Coordinate with mobile team on date parsing
└── Replace [JsonProperty] with [JsonPropertyName] (47 files)
└── Can be done mechanically with regex
Start from the leaves. Work up. Each step is small, testable, and independently deployable. The whole migration took three sprints instead of the "big bang swap" someone initially proposed (which would have broken everything).
When Refactoring Is Actually the Wrong Move
Controversial take: some legacy code should stay ugly.
That 400-line function processing payments? If it works, handles edge cases correctly, and processes $2M per day without errors — maybe don't touch it. Add monitoring. Add the three characterization tests. Put a comment at the top explaining what it does and why it's structured that way.
Refactoring has a cost. Every change introduces risk. The calculation isn't "is this code ugly?" — it's "will making this code prettier reduce bugs, speed up development, or prevent an incident?" Sometimes the answer is no.
Signs you should refactor:
- The same bug keeps appearing in this code (structural problem)
- Every feature request touching this area takes 5x longer than it should
- New team members can't understand it after a full day of reading
- The code has known security vulnerabilities baked into its structure
Signs you should leave it alone:
- It works and nobody needs to change it
- The "refactored" version would be equally complex (some problems are just complex)
- You're refactoring because it offends your aesthetic sensibility, not because it causes problems
Automated Analysis Catches What Eyes Miss
After 15 years, legacy codebases accumulate problems that no single developer can track mentally. Dead code paths. Dependencies with known CVEs that got vendored in 2020 and never updated. SQL queries built with string concatenation buried three abstraction layers deep. Configuration values hardcoded across 40 files.
Static analysis tools flag these mechanically. ScanMyCode.dev runs automated code reviews that catch structural issues, security anti-patterns, and dependency risks — the exact problems that multiply in legacy systems. The report gives you exact file and line numbers, so you know where to focus refactoring effort instead of guessing.
Run automated analysis before refactoring to prioritize. Run it after to verify you didn't introduce new problems. Costs less than the time a senior dev spends manually auditing a codebase, and catches patterns humans consistently miss.
The Practical Playbook
For the Friday afternoon emergency:
- Scratch refactor for 30 minutes to understand the code
- Write 3-5 characterization tests covering the golden path and known failure modes
- Make the minimum change needed. Not the change you want to make — the one you need to make.
- Deploy behind a feature flag if possible
- File a ticket for the real refactoring work with the Mikado graph attached
For the planned technical debt sprint:
- Run a code review audit to identify the highest-risk areas
- Build the Mikado graph for your target change
- Work from the leaves up, deploying each step independently
- Create seams before rewriting internals
- Add tests at the seams, not inside the legacy code
For the "we need to rewrite this whole service":
You probably don't. But if you do, run the strangler fig — and get explicit written commitment from leadership that they'll fund completion. Put it in the roadmap. Put it in the OKRs. Otherwise you'll end up with two half-working systems and a team that's lost trust in technical initiatives.
Stop Romanticizing Green-Field Projects
Every green-field project becomes legacy code. Usually faster than anyone expects. The codebase you're building right now, with all its beautiful architecture and 90% test coverage? Give it two years of feature pressure, three developer turnovers, and one "we need this by Monday" executive request. It'll look exactly like the legacy code you're complaining about today.
The skill isn't building perfect systems. It's maintaining imperfect ones safely. That means knowing when to refactor, when to wrap, when to rewrite, and when to leave things alone. Most of engineering is that last one, and nobody puts it on their resume.
If you're staring at legacy code that needs attention and want an objective assessment of where the real risks are, submit it for a code review. A 24-hour turnaround beats two weeks of archaeology.