BackEngineering

Refactoring Legacy Code: A Survival Guide

Sep 202510 min read

Refactoring is not a rewrite. It’s controlled change with a safety net. A rewrite bets the roadmap on code that has never run in production; a refactor is a sequence of small, reversible bets that keep the system shipping the whole time. I’ve inherited enough legacy systems to treat that distinction as a survival rule, not a style preference.

Start with the right definition. Legacy code is not old code — it’s code you’re afraid to change. The fear comes from missing tests, missing documentation, and missing the people who wrote it. Courage doesn’t fix that. A safety net does, and you build it in a specific order.

Make it observable first

Add logging, metrics, and tests around the parts you’re about to change. You can’t improve what you can’t see — and in a legacy system, most of what matters stays invisible until you instrument it. Before I touch a line, I want three numbers on a dashboard: error rate, latency, and one business metric the module actually drives, like completed orders or generated invoices. Those numbers become the referee for every change that follows.

Then pin down current behavior with characterization tests. They don’t assert what the code should do — nobody knows anymore. They assert what it does today, bugs included, because somewhere a customer probably depends on that bug. Feed the module recorded production inputs, snapshot the outputs, and fail loudly on any difference:

test('pricing matches current production behavior', () => {
  const result = calculatePrice(recordedOrder);
  expect(result).toMatchSnapshot(); // pin today's output, quirks included
});

Twenty of these tests written in a day protect you better than a week of reading the code and hoping you understood it.

Refactor behind seams

Create boundaries, add adapters, and move code gradually. A seam is a place where you can change behavior without editing the code that produces it: an interface in front of the pricing engine, a repository in front of raw SQL, a queue between two modules that secretly share state. Find the seam — or cut one — and put the old implementation behind it. The new implementation then lives behind the same contract, and a feature flag decides which one answers each request.

Then migrate traffic the way a strangler fig grows: route one endpoint, one tenant, one percent of requests to the new path, compare outputs against the old one, and widen the split as confidence grows. The legacy code dies from disuse, not from a risky delete. Big-bang refactors are where projects go to die — six months on a long-lived branch, zero shipped value, and a merge day the whole team dreads. I’ve watched that movie; it doesn’t end well.

Principles that keep you safe

  • Small PRs with clear intent. One refactoring per pull request. A reviewer should confirm “behavior unchanged” in minutes, not in archaeology sessions.
  • Feature flags where risk is high. A flag turns rollback from an emergency deploy into a config change — seconds instead of an incident call.
  • Measure performance before and after. “Cleaner” code that doubles latency isn’t cleaner in any way users notice. Let the dashboard settle the argument.
  • Keep a rollback path. Every step must be reversible. If I can’t undo a change in minutes, the step is too big — split it.

None of this is glamorous, and that’s the point. Refactoring legacy code is compound interest: every seam you cut, every test you pin, every metric you add makes the next change cheaper. The goal was never beautiful code. The goal is a system you can change on a Tuesday afternoon without holding your breath — while it keeps serving customers the entire time.

Up next

Back to overview