Back to engineering notes
Architecture12 min read·

The 'Do Not Rewrite' Playbook: Modernising Legacy Systems Incrementally

A rewrite asks you to stop earning for a year and trust an estimate. The strangler fig pattern asks you to move one route behind a proxy this week. Both take about the same time; only one of them ships.

LegacyStrangler figArchitectureMigrationRefactoringRisk
A rewrite that ships nothing for eighteen months compared with an incremental migration that moves one route at a time behind a proxy and ships from week three

The rewrite is not late. It is forty percent done — and it has been forty percent done for seven months, because the other sixty percent is everything nobody remembered the old system did.

The request usually arrives well argued. The codebase is eight years old, the framework is two majors behind, nobody enjoys working in it, and the team is confident they could rebuild it properly in six months.

They are probably right about the code. They are almost always wrong about the six months, and the reason is not optimism about typing speed. It is that the old system's behaviour is not written down anywhere — it is in eight years of bug fixes, edge cases and quiet accommodations to how customers actually work. Rebuilding the features is the visible half; rediscovering the behaviour is the half that takes the other year.

The alternative is not “do nothing”. It is to replace the system one slice at a time, in production, while it keeps earning. The pattern has a name — the strangler fig — and the whole trick is a proxy in front of the old application.

01

Why rewrites stall at forty percent

Four forces act on every rewrite, and they compound. The first is the parity treadmill: the old system does not stop changing while you rebuild, so every bug fix and small feature shipped to it must also be built in the new one. The gap moves as you approach it.

The second is that the old system usually gets frozen to stop the treadmill — which means customers get nothing for a year, competitors do not pause, and the sales team starts promising the new thing before it exists. The third is that all the risk lands on one day: cutover. Nothing is proven under real traffic until everything is.

The fourth is the quietest. Behaviour nobody documented is discovered only when it breaks — the export that a major account's finance team depends on, the timezone handling that is wrong in a way three customers have built processes around, the retry that silently fixes a flaky vendor. Each of these is found in production, after cutover, at the worst possible time.

The honest pitch for incremental migration is not that it is faster. It is that it earns while it runs, and that stopping halfway leaves you better off rather than with nothing.
The two approaches, on the dimensions that matter to the business
Full rewriteIncremental (strangler)
First customer valueMonth 18, if the estimate holdsWeek 3
Old system during the workFrozen, or the parity treadmillKeeps shipping normally
Unit of rollbackEverythingOne route
When risk is discoveredAt cutover, all at onceContinuously, in small pieces
If it is cancelled halfwayNothing shippedEverything moved so far is live and working
Total elapsed timeComparableComparable — but earning throughout
02

The whole pattern is a proxy

Put something in front of the legacy application that can route by path, header or tenant — nginx, an ALB rule, Cloudflare, an API gateway, or a thin routing layer in the app itself. Point everything at the old system. Nothing has changed yet, and that is the point: this step is deployable on day one with zero behavioural difference.

Now move one route. The new service handles `/api/invoices`, everything else continues to the legacy application. If it misbehaves, change one line and traffic goes back. Customers see nothing either way.

Then repeat. Each slice is a week or two, each one is independently valuable and independently reversible, and the legacy system shrinks until what remains is either genuinely fine or small enough to finish in one go.

The routing layer, and the canary in front of it
nginx
upstream legacy   { server legacy.internal:8080; }
upstream invoices { server invoices-svc.internal:3000; }

split_clients "${remote_addr}${request_id}" $invoices_canary {
    5%      new;        # start at 5%, raise as confidence grows
    *       old;
}

server {
    location /api/invoices {
        # Header or tenant-based opt-in for internal testing first
        if ($http_x_use_new = "1")        { proxy_pass http://invoices; }
        if ($invoices_canary = "new")     { proxy_pass http://invoices; }
        proxy_pass http://legacy;
    }

    # Everything not yet moved continues to the legacy application
    location / {
        proxy_pass http://legacy;
    }
}

# Rollback is this file, one line, no deploy of either application.
# That property is what makes the whole approach safe enough to do weekly.
03

Choosing the first slice

The first slice sets the tone for the whole programme, so choose it for learning rather than heroism. Not the hardest part — you will learn nothing except that it is hard. Not the trivial part either, or nobody will believe the approach generalises.

Score candidates on two axes: how much pain the slice causes now, and how loosely it is coupled to everything else. High pain and low coupling is the first one. A read-heavy endpoint that changes often and touches few tables is close to ideal, because you can shadow real traffic against it and compare outputs before routing anyone.

Two things to avoid at the start. Anything inside a transaction that spans several domains, because the data problem will dominate. And anything where the legacy behaviour is genuinely unknown — start where you can characterise the existing behaviour cheaply, and build the muscle before you need it.

  • High change rate, low coupling: Look at git history. The files that change most often, that few other things import, are where the pain is and where the seam already is.
  • Prefer read paths first: A read can be shadowed and compared without side effects. Move writes once you trust the pipeline.
  • Follow the data ownership: A slice that owns its tables is a slice you can extract. One that shares a transaction with three other domains is a later problem.
  • Avoid the piece nobody understands: That one needs characterisation work first. Doing it first turns a migration into an archaeology project and stalls momentum.
04

Characterise before you touch anything

The behaviour of the old system is the specification, whatever the documentation says. Before replacing a slice, capture what it currently does — including the parts that are arguably wrong, because someone is depending on them.

Characterisation tests are the cheap version: for each interesting input, record the current output and assert on it. They are not tests of correctness, they are tests of sameness, and that is exactly what you need. Write them against the legacy system while it is still the only implementation.

Shadow traffic is the thorough version and it is worth the effort for anything important. Send a copy of real production requests to the new implementation, discard its responses, and compare them with the legacy output. You get a real diff on real data, including the inputs nobody would have thought to write a test for. Run it for a week and the surprises arrive before customers see them, not after.

Shadow and compare, with no customer impact
typescript
// In the routing layer: serve legacy, compare new, never block on it
async function handleInvoices(req: Request) {
  const legacy = await callLegacy(req)          // the response users get

  if (shadowEnabled(req)) {
    // Fire and forget. A failure here must never affect the response.
    void (async () => {
      try {
        const candidate = await callNew(req, { timeout: 2_000 })
        const diff = compare(legacy.body, candidate.body, {
          ignore: ['generatedAt', 'requestId'],   // known-volatile fields
        })
        if (diff.length) {
          metrics.increment('shadow.mismatch', { route: 'invoices' })
          log.warn({ path: req.path, diff: diff.slice(0, 5) }, 'shadow mismatch')
        } else {
          metrics.increment('shadow.match', { route: 'invoices' })
        }
      } catch (e) {
        metrics.increment('shadow.error', { route: 'invoices' })
      }
    })()
  }

  return legacy
}

// Route real traffic when the match rate holds above your bar
// (99.9% on a week of production shapes) — not when the team feels ready.
05

The data problem, which is the actual hard part

Routing requests is easy. Deciding where the data lives while two systems are alive is where migrations genuinely get difficult, and there is no single right answer — only four options with different trade-offs, chosen per slice.

The simplest is to let the new service read and write the legacy database directly. It is unfashionable and it is frequently correct: you get the new code, the new tests and the new deployment story immediately, and you defer the data question to when it is the actual bottleneck. Treat the schema as a shared contract and move on.

When the new service needs its own store, the choices are dual-write, change data capture, or a hard ownership transfer. Dual-write through a transactional outbox is the most common: write to the old store and record an event in the same transaction, then project it into the new one. Change data capture achieves the same thing by reading the database's replication log, with no application change at all. A hard transfer — the new service owns the table, the legacy reads it through an API — is the end state, and it is easiest once the slice is already serving traffic.

Avoid distributed transactions across the old and new systems. Pick one system of record per slice, make the other eventually consistent, and design the UI so a second or two of lag is not a bug report.
Four ways to handle the data, and when each fits
ApproachFits whenWatch out for
New service reads the legacy databaseThe fastest start, and right more often than people admitSchema coupling — treat it as a contract and change it deliberately.
Dual-write via a transactional outboxThe new service needs its own store and you control the writesOutbox consumers must be idempotent; events arrive at least once.
Change data capture from the replication logYou cannot change the legacy application at allOne-way by default; schema changes in the legacy can break projections.
Hard ownership transferThe end state, once the slice serves live trafficNeeds a short, well-rehearsed cutover for that table. Do it last.
06

Keep the legacy system alive and shipping

The discipline that makes this work — and the one teams resent most — is that the old system keeps getting fixes and small features throughout. Freezing it is how you recreate the rewrite's worst property while still paying for the migration.

Budget for it explicitly. Something like seventy percent of engineering capacity on normal product work and thirty percent on migration is sustainable for a year; the reverse is not, and a hundred percent on migration means you have chosen a rewrite with extra steps.

Freeze only the slice currently being moved, and only for the week or two it takes. That is a narrow, explainable constraint, and it keeps the parity treadmill down to something you can carry.

  • Around 30% of capacity, sustained: Enough to make real progress, small enough that the product does not stall. Protect it — this is the line that erodes first.
  • Freeze the slice, not the system: A two-week freeze on one endpoint is a conversation. A twelve-month freeze on the product is a strategy nobody signed up for.
  • Ship the migration like a feature: Behind flags, with metrics, rolled out per tenant. Each slice is a release, not a milestone in a plan.
  • Keep a visible score: Percentage of traffic on the new path, per route. It is the only progress number that cannot be fudged.
07

Knowing when to stop

The goal is not zero legacy code. It is a system the team can change safely at the speed the business needs, and it is entirely normal to reach that with a meaningful amount of the old application still running.

After several slices, ask what remains. Code that is stable, rarely changed, well understood and cheap to run is not a problem — it is a solved part of the system that happens to be old. Migrating it because it is old is spending money to satisfy an aesthetic.

Judge with the same numbers that started this: lead time for a typical change, change failure rate, and the cost of running the thing. When those are where you want them, declare it finished and redirect the capacity. Leaving the last twenty percent of a legacy system in place, deliberately and with the reasoning written down, is a legitimate outcome — and a far better one than a migration that runs until everyone quietly stops talking about it.

What to measure before, during and after
MeasureWhy it mattersTarget
Median lead time, typical changeThe reason the work was fundedDown, and steady
Change failure rateWhether the new path is genuinely saferDown, per route
Traffic on the new path, by routeHonest progress, not story pointsUp, route by route
Shadow mismatch rateWhether behaviour is preservedUnder 0.1% before routing
Run cost of both systemsThe overlap period is real moneyFalls as slices retire

Frequently asked questions

What is the strangler fig pattern?

Put a routing layer in front of a legacy application, then move functionality to new code one slice at a time, changing which requests go where. The old system keeps serving everything not yet moved, so each slice is independently valuable, independently reversible, and live in production within days rather than at the end of a project.

Isn't a rewrite faster than migrating piece by piece?

It rarely is in practice, and the elapsed times are usually comparable. The difference is what happens meanwhile: a rewrite ships nothing until cutover and puts all the risk on one day, while an incremental migration delivers value from week three and discovers problems in small pieces. If a rewrite is cancelled at sixty percent you have nothing; if a migration stops at sixty percent, that sixty percent is live and working.

How do I choose the first part to migrate?

High pain, low coupling. Read your git history for the files that change most often and are imported by fewest others, and prefer read paths so you can shadow real traffic and compare outputs before routing anyone. Avoid anything inside a transaction spanning several domains, and avoid the part nobody understands — that one needs characterisation work first.

How do I handle the database during an incremental migration?

Choose per slice. Letting the new service read the legacy database directly is the fastest start and is right more often than people admit. When it needs its own store, use a transactional outbox to dual-write, or change data capture if you cannot modify the legacy application. Transfer ownership of the table last, once the slice is already serving traffic. Avoid distributed transactions entirely.

Should we freeze the old system during the migration?

No — that recreates the rewrite's worst property. Freeze only the slice being moved, for the week or two it takes. Budget roughly thirty percent of engineering capacity for the migration and keep shipping normal product work with the rest; a hundred percent on migration is a rewrite with extra steps.

How do we know when the migration is done?

When lead time, change failure rate and running cost are where you need them — not when the last line of old code is gone. Stable, rarely-changed, well-understood legacy code that runs cheaply is not a problem worth spending money on. Deciding to stop deliberately, with the reasoning written down, is a legitimate and common outcome.