Every organisation past a certain age has one: the system nobody fully understands, that no vendor will touch cheaply, and that nobody dares switch off because it turns out to be doing something important. The instinct is to scrap it and build something clean. That instinct, more often than not, is what wrecks the project. Legacy modernisation is the discipline of replacing that old software without the business noticing, and the central move is to resist the rewrite.

The quick version

  • "Legacy" doesn't mean bad. It means old, in production, and load-bearing, and usually full of hard-won knowledge about edge cases nobody remembers. The crusty code is often crusty because the real world is messy.
  • The big-bang rewrite is the default trap. Stopping the old system to build a shiny replacement freezes your product for years while competitors keep moving. Large software projects fail far more often than small ones.
  • The safer route is incremental. The strangler fig pattern grows a new system around the old one and shifts traffic slice by slice, so you can stop, ship, and prove value at every step.
  • Tests come first, not last. Before you change legacy code, you pin its current behaviour with characterization tests so you can tell whether you've improved it or just broken it differently.

Why "legacy" is a compliment in disguise

Legacy software is simply code that is old enough to still be running the business. That is not a flaw; it is a track record. The mess that makes engineers wince, the special cases, the odd conditionals, the comment that says // do not remove, breaks billing, is frequently the residue of real problems the system already solved. Joel Spolsky made this point bluntly in his 2000 essay "Things You Should Never Do, Part I", written as Netscape rewrote its browser from scratch: those ugly lines are "bug fixes… each one… hard-won knowledge about the real world," and throwing the code away throws the knowledge away with it. Netscape's rewrite delayed its next major release for roughly three years while its market share collapsed.

So the move is to start with respect, not contempt. Before anyone proposes a replacement, the first task is to find out what the old system actually does, including the parts nobody documented, because every undocumented behaviour is a requirement you will otherwise rediscover in production. Treat the legacy system as the most accurate specification you have, because it is.

It helps to name why the system got this way. Ward Cunningham coined the phrase technical debt in 1992 to explain shipping decisions to non-technical stakeholders: rushing not-quite-right code out the door is like taking on debt, fine if you repay it promptly, dangerous when the "interest" compounds for years (his framing is preserved by the Agile Alliance and Wikipedia). Most legacy systems are debt that was never repaid, which reframes modernisation as repaying principal rather than a vanity project to make the code pretty.

An honest limitation. Not every old system deserves saving. Some are genuinely obsolete, a platform with no security patches, no one left who can maintain it, or a need that no longer exists. "Respect the legacy" is a discipline against reflexive rewrites, not a rule against ever retiring anything. If a system still earns its keep, modernise it incrementally; if it truly doesn't, decommission it deliberately rather than rebuild it out of habit.

Why the big-bang rewrite keeps losing

The seductive plan is always the same: freeze the old system, build a clean replacement in parallel, then flip everyone over on a single glorious launch day. It rarely survives contact with reality. The new system has to reach feature parity with years of accumulated behaviour before it can replace anything, so it delivers nothing for a very long time, and meanwhile the old system still needs patching, so you are paying for two systems and shipping with neither.

The risk also scales badly with size. The Standish Group's long-running CHAOS research has consistently found that large software projects succeed far less often than small ones, its 2015 report, breaking results down by project size, put the success rate for the biggest ("grand") projects at 6% against 61% for the smallest (a much-cited finding; the precise percentages move year to year and the methodology has critics, so read it as a strong directional signal rather than a fixed law). A big-bang rewrite is, by construction, one enormous project. Cutting it into many small ones is not just tidier; on this evidence it is the difference between a near-hopeless bet and a roughly even one.

A rewrite delivers nothing until the day it delivers everything, which is exactly the day it tends to go wrong.

The fix is to make the unit of work small and shippable. Instead of "replace the system," the goal becomes "move one slice of behaviour to the new world, in production, this month, and be able to stop afterwards with the business better off." That single reframing is what turns a multi-year bet into a series of survivable steps.

The strangler fig: grow the new around the old

The canonical pattern for doing this has a memorable name. Martin Fowler watched strangler fig vines in the Queensland rainforest, they germinate in the branches of a host tree, send roots down around it, and slowly take over until the original tree is gone, and saw the metaphor for software in his article "Strangler Fig Application". You "begin with small additions… built on top of, yet separate to the legacy code base," and grow the new system around the old one until the old one can be quietly removed.

Mechanically, you put a façade, a routing layer or proxy, in front of the legacy system. Every request goes through it. At first the façade sends everything to the old system. Then you rebuild one capability in the new system, point the façade's traffic for that capability at the new code, and leave everything else untouched. Repeat, capability by capability, until nothing is routed to the legacy system and you can switch it off.

flowchart LR
  U(["User / client"]) --> F(["Façade
routes each request"]) F -->|"not migrated yet"| L(["Legacy system
still doing the work"]) F -->|"migrated slice"| N(["New service
this capability only"]) N -.->|"over time, more routes move"| L
The façade routes each capability to the old or new system, and migrated slices grow until the legacy system is dark. Leaders Loop

The payoff is that risk is now bounded per slice. If a migrated capability misbehaves, you route it back to the legacy system and you are no worse off, the old code is still there, still working. You ship continuously, you learn from real traffic, and you can pause the whole programme between slices without leaving the business stranded mid-rewrite. This is the practical heart of incremental modernisation, and it pairs naturally with how teams ship safely overall, see CI/CD pipelines, which give you the small, frequent, reversible deployments the pattern depends on.

An honest limitation. The strangler fig is not free. Running a façade and two systems side by side adds operational complexity, and "migrate one slice at a time" is only easy when the slices are genuinely separable, a tangled data model where everything reads and writes the same tables can make a clean cut very hard. Sometimes the real first project is untangling the data so that slices can be carved at all. The pattern reduces risk; it does not abolish the work.

Change nothing until you can prove what it does

Incremental replacement still means editing code that no one fully understands, and that is where most damage happens. Michael Feathers' Working Effectively with Legacy Code (2004) gives the discipline its working definition, to Feathers, "legacy code" simply means code without tests, and two tools that matter here.

The first is the characterization test: a test that records what the system currently does, bugs and all, before you touch it. You are not asserting what the code should do; you are pinning what it does, so that when you refactor, any change in behaviour shows up as a failing test rather than a surprised customer. The second is the seam, a place where you can change behaviour without editing the code in that spot, which is how you get untestable legacy code into a test harness in the first place. The order is the whole point: find a seam, write characterization tests through it, then refactor or extract the slice, never the other way round.

flowchart TD
  A(["Pick the next slice
to modernise"]) --> B(["Find a seam
to get it under test"]) B --> C(["Write characterization tests
pin current behaviour"]) C --> D(["Extract / rebuild the slice
route it via the façade"]) D --> E{"Tests still green?"} E -->|"Yes"| F(["Ship it, move to next slice"]) E -->|"No, behaviour changed"| C
Tests before changes: pin the behaviour, then move the slice, and let the tests catch anything you altered by accident. Leaders Loop

This is the unglamorous part, and it is the part that separates a modernisation that holds from one that quietly introduces a hundred small regressions. A reader who takes one thing from this piece should take this: in legacy work, the test is not how you finish, it is how you start.

A worked example

Picture a mid-sized insurer, call it Meridian, whose policy-pricing engine is a fifteen-year-old monolith. (Illustrative throughout; this is a teaching example, not a real company.) It is slow, only two people understand it, and the business wants to launch new products it can't support. The board's first instinct is a clean rewrite, budgeted at, say, an illustrative two years.

The modernisation lead pushes back and reframes the work. Rather than replace the engine, the team puts a façade in front of it: every pricing request now passes through a thin routing layer that, for now, calls the old monolith and returns its answer unchanged. Nothing has visibly changed for customers, and that is the point. The risky move (inserting the façade) is decoupled from any change in behaviour.

Then they pick the smallest, most-wanted slice: pricing for a single new product line. They find a seam where pricing inputs enter the monolith, write characterization tests that capture what the old engine returns for a battery of real quotes, and only then build that one product's pricing in a new service. The façade routes just that product to the new code; the tests confirm the old products still price identically, and if anything looks wrong in production, one config change routes it back. Slice by slice, a product line here, a discount rule there, traffic moves to the new world, and the monolith goes dark not on a launch day but on an ordinary Tuesday when the last route is switched. The two-year bet became eighteen survivable months, each one delivering something.

Frequently asked questions

Isn't it cheaper to just rewrite from scratch?

It almost never is, once you count the years the rewrite delivers nothing while you maintain the old system in parallel, plus the rediscovery of every undocumented behaviour the original quietly handled. Incremental modernisation feels slower because it has no dramatic launch day, but it ships value continuously and lets you stop early if priorities change. The big-bang rewrite only looks cheaper because its largest cost, delay and risk, is invisible on the plan.

What actually counts as a "legacy" system?

The blunt definitions are the useful ones: old, in production, and load-bearing; or, in Michael Feathers' framing, simply code without tests. The label is not about the language or the age, a two-year-old system with no tests and no one who understands it is legacy, while a twenty-year-old system that is well-tested and well-understood may not need rescuing at all.

Where do we start if no one understands the old code?

Start by making its behaviour observable, not by changing it. Put a façade in front so all traffic is visible and routable, and write characterization tests that capture what it currently returns for real inputs. That gives you a safety net and a map at the same time. Only once a slice is under test do you touch it, understanding follows from pinning behaviour, not from reading code in the dark.

Does the strangler fig pattern only apply to microservices?

No. It is most discussed in the context of breaking a monolith into services, but the idea, route through a façade, replace behind it one capability at a time, applies to replacing a database, a vendor package, a desktop app, or a single screen. The shape that matters is "incremental replacement behind a stable interface," whatever the technology on either side.

How do we know when a slice is safe to move?

When its current behaviour is captured by tests, its inputs and outputs flow through the façade so traffic can be routed (and rolled back), and the new implementation produces the same results the old one did for real cases. If you can't yet pin the behaviour or can't cleanly separate the slice's data, that is a signal the groundwork, usually untangling the data model, comes first.

Related in the Toolkit

Modernisation is easier to reason about once you know what is actually running where, the server-side picture of databases, APIs and services is what you are re-routing, and the small, reversible deployments the strangler fig depends on come straight out of CI/CD pipelines.

Where to go next