How I Refactor Legacy Code Without Waking Up to a Pager Storm
I used to think refactoring was something you did on a quiet Friday afternoon when the sprint was light and nobody was watching. Then I spent four months rewriting the pricing engine at a company where a bad deploy meant customers got charged the wrong amount, and I learned the hard way that "just be careful" is not a strategy. What follows is roughly the playbook I've settled into after that project and a couple of similar ones since, including a checkout-flow rewrite I still think about at 2am sometimes.
The first thing I had to unlearn was the instinct to rewrite. Every engineer who inherits a gnarly module feels the pull to delete it and start clean. I felt it staring at a 4,000-line PricingCalculator class with fourteen constructors and comments like "DO NOT TOUCH - ask Dave." Dave had left the company two years earlier. The temptation to nuke it and write something elegant from scratch is real, and it is almost always the wrong call on anything customer-facing with real traffic. Big-bang rewrites fail not because engineers can't write good code, but because the old system encodes years of undocumented business logic that nobody remembers agreeing to. You don't find out what that logic was until you ship the replacement and support tickets start rolling in.
The strangler fig, in practice
The pattern that actually worked was the strangler fig — named after the vine that grows around a host tree and eventually replaces it entirely while the tree keeps standing. Applied to code, it means you build the new implementation alongside the old one, route a sliver of traffic to the new path, and grow that sliver over time until the old code has no callers left and you can delete it without ceremony.
Concretely, for the pricing engine, that meant introducing a thin routing layer in front of both implementations:
async function calculatePrice(order: Order): Promise {
if (await shouldUseNewEngine(order)) {
return newPricingEngine.calculate(order);
}
return legacyPricingEngine.calculate(order);
}
shouldUseNewEngine started out returning false for everyone except our internal test accounts. That's it. No behavior change for a single real customer, but the new code was live in production, running against production data shapes, which is the only place bugs in pricing logic actually reveal themselves. Staging environments lie to you. Nobody's staging environment has a customer with three stacked promo codes, a currency conversion edge case, and a subscription that started under a pricing scheme you deprecated eighteen months ago.
Characterization tests before you touch anything
Before I wrote a single line of the new engine, I wrote characterization tests against the old one. These aren't tests that assert what the code should do — they assert what it actually does, right now, warts and all. The distinction matters. I wasn't trying to validate correctness; I was trying to pin down current behavior so I'd know immediately if the new implementation diverged.
describe('legacy pricing engine - characterization', () => {
const fixtures = loadRealOrderSamples(); // anonymized production orders
it.each(fixtures)('matches recorded output for order %s', async (order) => {
const result = await legacyPricingEngine.calculate(order);
expect(result).toMatchSnapshot();
});
});
I pulled a few thousand anonymized real orders from the warehouse, ran them through the legacy engine, and snapshotted the results. Then, as I built the new engine, I ran the exact same fixtures through it and diffed the outputs. Every mismatch was either a legitimate bug in the old system I now had to decide whether to preserve or fix, or a bug in my new code. Either way, I learned about it in a test run instead of in a Slack thread from customer support.
This is slower than just writing the new thing and hoping. It is also the only way I've found to refactor pricing-adjacent code without a heart attack. Budget real time for it — for that project it was almost three weeks of just harvesting fixtures and reconciling diffs before I felt confident touching the routing percentage.
Feature flags as a dial, not a switch
Once the new engine was passing characterization tests, the rollout itself became boring, which is exactly what you want. I used a flag service (we were on LaunchDarkly at the time, though the same approach works fine with a homegrown flags table or something like Unleash) to control shouldUseNewEngine as a percentage rollout, not a binary toggle.
flag: new-pricing-engine
rules:
- if: user.internalTestAccount == true
serve: true
- percentage:
true: 1
false: 99
One percent for three days. Then five. Then twenty. At each step I watched two dashboards religiously: error rate on the pricing service, and a diff-alerting job that compared new-engine output against what the old engine would have produced for the same order, logged asynchronously so it didn't block the request. That shadow comparison caught two real discrepancies — one involving a rounding rule for Japanese yen that nobody had documented anywhere, and one involving a grandfathered enterprise contract with a pricing floor. Both would have been ugly if they'd hit real invoices.
The instinct when a rollout is going well is to accelerate. I'd push back on that instinct now. Going from 20% to 100% in one jump defeats the purpose of the whole exercise, because you lose the ability to attribute a spike in errors to the rollout versus something unrelated happening in the system that day. I stayed at each percentage for at least 48 hours to get a full weekday and weekend traffic pattern through it before increasing.
Incremental rollout is also an organizational tool
Something I didn't appreciate until I'd done this a few times: the gradual rollout isn't just a technical safety net, it's also how you keep the rest of the team calm. When you tell your manager "I'm rewriting the pricing engine," what they hear is risk. When you tell them "the new engine is live for 5% of traffic, shadow-diffed against the old one, zero discrepancies in the last 48 hours," what they hear is a controlled experiment. Those are very different conversations to have in a planning meeting, and the second one buys you the patience to keep going slowly instead of getting pressured into a faster cutover.
I also kept a rollback as close to a no-op as possible the entire time. Since the routing layer just checks a flag, rolling back a bad new-engine deploy was setting the percentage back to zero, not reverting a deploy or restoring a database. That's worth designing for explicitly — the strangler fig pattern only pays off if the "revert to old behavior" path is as fast as flipping a flag, not redeploying an artifact.
Cleaning up is its own project
The part everyone forgets to plan for is deleting the old code once the new path is at 100%. It's tempting to just leave the legacy engine sitting there behind a flag "just in case," and I've been guilty of this — the old pricing engine sat unused in our codebase for almost two months after full rollout because nobody wanted to be the one to delete a critical financial code path. Eventually I scheduled it as its own small piece of work with its own PR, its own careful review, and a week of monitoring afterward for anything that might have still been calling it through a code path I'd missed. Treat deletion with the same rigor as the original migration; it's not cleanup, it's still a change to a critical system.
If I had to compress all of this into one sentence for someone about to refactor something scary: make the new code observable before it's authoritative, and make the rollback a flag flip, not a deploy. Everything else — the strangler routing, the characterization tests, the percentage dials — exists in service of those two properties. Tools like Sentry or Datadog for catching the error-rate spikes, LaunchDarkly or an equivalent for the dial, and a solid CI pipeline in GitHub Actions to make sure your characterization suite runs on every commit are just the plumbing. The discipline is the actual thing.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.