Skip to content

Solution

How to move off a legacy system without a big bang

The rewrite that replaces everything on a chosen weekend is the most reliably disastrous plan in software, and it keeps being chosen because the alternative sounds slower.

The short answer

Put a seam in front of the old system, build the new path behind it, and run both for every request while comparing the results without changing behaviour. Move reads across one surface at a time, then writes, then retire the old path only when nothing calls it.

Why the rewrite fails

A replacement built in isolation and switched over on a date requires you to understand the entire old system before any of the new one has met reality.

That is impossible for a system of any age, because it contains years of behaviour that nobody documented and several people have forgotten. Special cases for particular customers. A rounding rule added after a complaint. A field that means something different when it is null.

None of that is in the specification. It surfaces under real traffic, and in a big bang the first real traffic arrives after the old system is gone.

The alternative is not slower. It is the same work, sequenced so that you learn continuously instead of all at once at the worst moment.

Step 1. Put a seam in front of the old system

Before writing any replacement, get to a place where every call to the legacy behaviour goes through one point you control.

callers -> facade -> legacy implementation

That facade can be a function, a module boundary, a service, or a proxy in front of the whole application. It does not require modifying the legacy code, which matters because often you cannot.

This step alone is worth doing even if the migration is cancelled, because it makes the boundary visible and tells you exactly how many callers there are, which is almost always more than anybody guessed.

Step 2. Build the new path behind the same seam

The new implementation goes behind the same facade. Nothing calls it yet.

callers -> facade -> legacy   (still serving)
                  -> new      (built, dark)

Being able to build without switching anything is what makes the whole approach safe, and it is why the seam comes first rather than second.

Step 3. Run both and compare, in production, changing nothing

The most important step, and the one people skip because it feels like doing the work twice.

For every request, run both implementations. Serve the legacy result. Log the comparison.

facade:
  legacy_result = legacy(input)
  try:
      new_result = new(input)
      if new_result != legacy_result:
          log_difference(input, legacy_result, new_result)
  except:
      log_error()          # never let the new path affect the response
  return legacy_result     # always

Two rules, both absolute. The new path must never affect the response, so wrap it and swallow everything. And the comparison must run on real production traffic, because synthetic tests only contain the cases you already thought of, and the entire point is finding the ones you did not.

That difference log is the most valuable artefact in the project. Every entry is either a bug in the new path or a behaviour of the old one that nobody documented. In the four calculations project I did, the log revealed two edge cases that turned out to be deliberate behaviour nobody had written down. Without running both, I would have “fixed” them and broken something real.

Watch the cost. Running both doubles the work per request, so sample rather than comparing everything if the operation is expensive. Ten percent of real traffic still finds things.

Step 4. Explain every difference before moving anything

Not reduce the differences. Explain them.

Each entry in the log gets one of three verdicts:

  • The new path is wrong. Fix it.
  • The old path is wrong and it has been for years. Decide deliberately whether to preserve the bug, because somebody may depend on it.
  • The behaviours differ legitimately and the change is intended. Record the decision.

The temptation is to move once the differences are rare. Rare is not explained. A difference occurring in one in ten thousand requests is still ten a day at reasonable volume, and those ten are people.

Step 5. Move reads first, one surface at a time

Reads are safe because they can be reverted instantly with no data consequences.

Move one surface at a time, watching each before continuing. Least critical first so any surprise is cheap.

week 1   internal reporting reads new
week 2   the admin interface
week 3   the customer facing list
week 4   the customer facing detail

Keep the comparison running throughout, because moving a surface changes traffic shape and can surface differences that the old distribution never hit.

Step 6. Move writes, with the old path still recording

Writes are harder because they cannot be reverted by flipping a flag.

Move the write, keep the legacy path recording what it would have done, and compare after the fact. Then, if something is wrong, you know precisely what should have happened, which is the difference between a repair and an investigation.

Do this in one place at a time and give each one a real soak period. Writes are where a migration actually goes wrong.

Step 7. Retire, last, and slowly

Only when nothing calls the legacy path. Verify that from the seam by counting calls rather than by searching the codebase, because the caller you did not find is exactly the one that matters.

Then leave it in place, unused, for a month before deleting it. The cost of leaving dead code for a month is zero. The cost of deleting it the week before somebody discovers a quarterly job that used it is a bad afternoon.

What this costs

Longer overall. Two implementations to maintain during the transition. Real complexity in the facade. And it demands patience precisely when the pressure to declare completion is highest.

What it buys is that at no point are you exposed. Every step is revertible, every difference is found by production rather than by a customer, and there is never a weekend where the whole company waits to see if it worked.

What good looks like

The old system is retired without anybody outside noticing that anything happened. That is what success in this work looks like, and it is why it rarely gets credit.

Questions people actually ask

Why is the big bang rewrite so unreliable?
Because it requires understanding the whole of the old system before any of the new one is validated, and the old system contains years of undocumented behaviour that only reveals itself under real traffic. You find out what you missed at the worst possible moment.
What if the old system cannot be instrumented?
Then the seam goes in front of it. A proxy, a facade, a wrapper. You do not need to modify the legacy code to route around it, and often you cannot.
How long should both systems run in parallel?
Until the comparison log is quiet and every difference has been explained rather than merely reduced. Weeks, usually. The urge to stop early is the main risk in the whole method.

Published July 2026, updated July 2026.

Hiring someone to own work like this?

I am open to senior backend and full stack roles, remote and permanent.

Get in touch / Read the CV