Leer en español · Lire en français · Auf Deutsch lesen
Section 11.6 of 11.8 · Advanced · 25 min read
Modernising legacy systems: strangler fig and incremental migration
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
Every successful system eventually becomes a legacy system. In this lesson you'll learn why big rewrites so often fail, and how to modernise piece by piece instead.
Imagine a town that needs a new bridge. Knocking down the old one first would cut the town off for years. So engineers build the new bridge alongside the old. They open it one lane at a time, and move traffic across gradually. If a new lane has a problem, traffic simply goes back to the old bridge for a while. And they demolish the old bridge only when nothing uses it any more. Modernising old software works best the same way.
A legacy system still does something valuable, but has become hard, risky or expensive to change. Most legacy systems are successful systems. The obvious response is to start again, and that's usually a mistake. In a big-bang rewrite, value arrives only at the end, so the business waits months or years, and loses patience. The old system keeps changing while the new one is built, so the target moves. Hidden behaviour is lost. Old code looks messy partly because it holds years of fixes for situations nobody remembers. Joel Spolsky made this point in 2000, writing about Netscape's rewrite. And moving the data, and switching over to the new system, come last, when they're hardest. In 2018, the UK bank TSB's platform migration went badly wrong, and regulators later fined it forty eight point six five million pounds.
The strangler fig, named by Martin Fowler, replaces a system piece by piece while it keeps running. First, intercept: put a routing facade in front of the legacy system. At first, it sends everything to the old system. Then rebuild one slice of behaviour, here order tracking, as a new component, and change a routing rule to send it there. If it misbehaves, switch the rule back. Repeat, slice by slice, with temporary data synchronisation, until only billing is left in the old system. Finally, the legacy system is switched off. The facade, adapters and sync jobs are transitional architecture, built to be thrown away. So measure progress by what's been switched off.
Data is the hardest part. At every stage, exactly one system must be the system of record. Early on, the legacy system owns orders, and change data capture copies its rows to the new store, through an anti-corruption layer that translates them. Later, the direction flips. The new order service owns orders, and its events are translated back into legacy rows for modules, such as billing, that haven't moved yet. Data flows one way at a time.
Here's how the platform moved its order module out of the monolith. First, set outcomes: daily, independent order releases, three times today's Friday peak, and no lost or duplicated orders. Recorded production requests became tests that capture current behaviour, quirks included. Second, put an API gateway in front of the monolith, routing everything to it. It changes no behaviour, and the team practises switching routes. Third, build the new service in the shadows, while the legacy system stays the system of record. In a parallel run, the mismatch rate starts at about two per cent, and is driven below nought point nought one per cent before going further. Fourth, move order tracking reads: five per cent of traffic, then twenty five, then all of it. Rollback is one routing rule. Fifth, move writes, one city at a time: a pilot city, then every city over six weeks. An adapter turns the new service's events into legacy rows, so billing and reporting keep working. Finally, migrate three years of history. When the legacy order tables have had no reads for thirty days, remove the adapter, the pipeline and the old code. Only then is the migration complete.
The strangler fig works at the edge, where requests can be routed. Branch by abstraction works deep inside one codebase. Put an interface in front of the old component, move callers to it, build the new implementation behind it, switch over gradually, then delete the old code. A parallel run proves the new code behaves like the old. Both get the same inputs, but the old result is still returned to the user, and differences are recorded. The new path must have no side effects, such as charging a card twice.
The full section covers the seven Rs, dark launching, and how to migrate data safely. Next, in Section eleven point seven, we look at leadership and influence. See you there.
What you need to know
- A legacy system still delivers value but is hard, risky or expensive to change; modernise for business reasons such as blocked change, lost support, scarce skills or cost, not for fashion.
- Big-bang rewrites fail because value arrives late, the target moves, hidden behaviour is lost and data migration and cutover come last; Spolsky's 2000 essay and TSB's 2018 migration are standard warnings.
- The seven Rs (retire, retain, rehost, relocate, repurchase, replatform, refactor or re-architect) give each application in a portfolio a deliberate strategy; rebuilding is only one option.
- The strangler fig (Fowler, 2004) routes traffic through a facade and moves behaviour slice by slice to new components until the legacy system can be switched off; branch by abstraction does the same inside one codebase.
- Parallel runs compare old and new results in production while the old result stays authoritative; dark launching exercises new paths under real load without users seeing them.
- Data needs a single system of record at every stage; change data capture, event interception and anti-corruption layers keep systems in step, and history is migrated in rehearsed, reconciled cohorts.
- Transitional architecture is a deliberate, temporary cost; a migration is finished only when the old system and the transitional code have been removed.
What makes a system legacy
A legacy system is not simply an old one. It is a system that still does something valuable, often something the organisation could not operate without, but that has become hard, risky or expensive to change. Michael Feathers, in Working Effectively with Legacy Code (2004), gave a deliberately provocative definition: legacy code is code without tests, because without tests every change is a gamble. Other common causes are scarce skills (a language or platform few people still know), unsupported technology (an operating system or database past its vendor's end of support), lost knowledge (the people who understood the design have left) and accumulated technical debt in the structure itself, the expensive kind described in Section 1.7.
It is worth saying plainly that most legacy systems are successful systems. They survived because they worked and earned money. That success is also why they are hard to replace: years of real-world use have embedded thousands of small decisions, fixes and business rules that are written down nowhere except in the code and in the habits of the people who use it.
Modernisation therefore needs a reason that can be stated in business terms. Good reasons include: the cost or risk of change is blocking important business goals; the platform is losing vendor support or security patches; skills to maintain it are becoming unavailable; running costs are out of proportion to value; or the system cannot meet new demands for scale, availability or regulation. Weak reasons include fashion, boredom and the belief that new technology is automatically better. Section 11.8 shows how to turn a good reason into a business case.
Why rewrites fail
The obvious response to a painful old system is to start again. In April 2000 Joel Spolsky published an essay, "Things You Should Never Do, Part I", arguing that the decision to rewrite from scratch is among the worst strategic mistakes a software company can make. His example was Netscape, whose browser went nearly three years between major releases while the code was rewritten, during which competitors took the market. His central point was about knowledge: old code looks messy partly because it contains years of bug fixes for situations nobody remembers, and throwing it away throws those fixes away too.
The reasons a big-bang rewrite fails are now well understood. Value arrives only at the end, so the business waits months or years and loses patience. The old system keeps changing while the new one is built, so the target moves and the organisation either freezes the old system (and the business) or pays to make every change twice. Hidden behaviour is missed, because nobody can list every rule the old system enforces. Estimates are optimistic, because the visible features are a fraction of the real work. The data migration and the cutover are left until the end, when they are hardest and riskiest. And the team splits into a prestigious new-system group and a demoralised old-system group. Fred Brooks's second-system effect, from The Mythical Man-Month (1975), adds a further risk: designers replacing a system they know tend to load the new one with every idea the old one could not accommodate.
Large migrations that switch over in one step can also fail very publicly. In April 2018 the UK bank TSB moved its customer data and services to a new IT platform; the platform immediately had serious problems, all its branches and a significant proportion of its 5.2 million customers were affected, and the bank did not return to normal operation until December 2018. In December 2022 the Financial Conduct Authority and the Prudential Regulation Authority fined TSB a total of £48.65 million, finding that it had not adequately planned, organised and controlled the migration or managed the risks from its outsourced supplier.
Practitioners do not all agree that rewrites are always wrong. A rewrite can be the right choice when the system is small, its behaviour is well specified and covered by tests, the platform is truly at a dead end, or the business itself has changed so much that the old behaviour is no longer wanted. Even then, the safest rewrites are delivered incrementally, with the old and new systems running side by side, which is the subject of the rest of this section.
Migration strategies: the Rs
When an organisation reviews a whole portfolio of applications, for example before leaving a data centre, it needs a vocabulary for deciding what to do with each one. Gartner described five such options in the early 2010s. In 2016 Stephen Orban of AWS published six strategies, and AWS later added a seventh, giving the seven Rs now used in its migration guidance. Other cloud providers and consultancies use similar lists with slightly different names. The list is most useful as a reminder that rebuilding is only one option among several, and usually the most expensive.
The seven Rs (after AWS Prescriptive Guidance)
| Strategy | What it means | Fits when | Architecture change |
|---|---|---|---|
| Retire | Switch the application off and archive its data | Nobody uses it, or its function is duplicated elsewhere | None; remove dependencies on it |
| Retain | Keep it where it is for now and revisit later | No pressing reason to move, or other work must come first | None for now |
| Rehost (lift and shift) | Move it unchanged to new infrastructure, such as cloud virtual machines | The main driver is leaving a data centre quickly | None; problems move with it |
| Relocate | Move whole platforms, such as a virtualised estate, without changing the applications | A compatible platform exists at the destination | None |
| Repurchase | Replace it with a product, often software as a service | The capability is generic and a good product exists | Integration and data migration; loss of customisation |
| Replatform | Move it with a few targeted optimisations, such as a managed database | Cheap gains are available without redesign | Small and local |
| Refactor or re-architect | Redesign it, often incrementally, to meet new quality or business needs | The capability is core, changes often and its structure blocks change | Large; use the incremental patterns below |
Decision guide
Decision guide: choosing a strategy for one application
Ask these questions for each application in the portfolio, in order. The answers come from usage data, the business owner and the architecture, not from technology preference.
- Does anyone still need what this application does?YesContinue to the next question.NoRetire it, after archiving any data you are legally required to keep.
- Is there a product that meets the need, for a capability that does not differentiate the business?YesConsider repurchasing, and budget for integration and data migration.NoContinue to the next question.
- Is there a pressing reason to act now, such as end of support, a data centre exit, cost or change that is blocked?YesContinue to the next question.NoRetain it and set a date to revisit the decision.
- Is the main problem where it runs rather than how it is built?YesRehost or relocate it, or replatform where a managed service gives cheap gains.NoContinue to the next question.
- Is it a core capability whose structure is blocking frequent, valuable change?YesRefactor or re-architect it incrementally, using a strangler fig and branch by abstraction.NoReplatform and contain it: add tests, fix the worst hotspots and avoid a large redesign.
The strangler fig
Martin Fowler named the most widely used incremental pattern after the strangler figs he saw in the rainforests of Queensland in 2001: vines that germinate in the branches of a host tree, grow down to the ground and gradually surround the host until it dies and the fig stands on its own. He first wrote it up as the strangler application in 2004, later renamed it the strangler fig application and substantially revised the article in 2024. The software version, usually just called the strangler fig, keeps the old system running while new components grow around it and take over its behaviour one piece at a time, until the old system does nothing and can be switched off.
The mechanism has three parts. First, an interception layer is placed in front of the legacy system: a reverse proxy, API gateway or routing facade through which all requests pass. Initially it sends everything to the old system and changes nothing. Second, a slice of behaviour is chosen along a seam (a place where behaviour can be redirected without editing much of the old code, in Feathers' terms), rebuilt as a new component and enabled by changing a routing rule, usually for a small share of traffic first. Third, the process repeats until the legacy system handles nothing, at which point it is retired. Throughout, the routing layer makes each step reversible: if the new component misbehaves, the rule is switched back.
The code that lets old and new coexist (the routing facade, adapters, data synchronisation jobs) is transitional architecture: software built to be thrown away. Teams often resist writing it because it feels wasteful. Fowler's argument, and the experience behind it, is that this cost buys reduced risk and much earlier value, which is almost always a good trade. The real danger is the opposite: a migration that stalls half way, leaving both systems and all the transitional code in production indefinitely. Each slice should therefore remove something from the old system, and progress should be measured by what has been switched off, not only by what has been built.
Ian Cartwright, Rob Horn and James Lewis of Thoughtworks have documented a family of related techniques as Patterns of Legacy Displacement on martinfowler.com (from 2021). They frame the work as four activities that are not a fixed sequence: understand the outcomes you want, break the problem into parts, deliver those parts incrementally, and change the organisation so that the new system does not decay the way the old one did. They also question the instinct to reproduce every feature: much of a legacy system's behaviour may no longer be wanted, and finding out is cheaper than rebuilding it.
A strangler fig migration in four stages
Branch by abstraction
The strangler fig works at the edge of a system, where requests can be routed. Some replacements must happen deep inside one codebase: swapping a persistence framework, a pricing engine or a home-grown messaging layer that hundreds of call sites use. Branch by abstraction, a name Paul Hammant introduced while advocating trunk-based development and that Martin Fowler described in 2014, does this without a long-lived version-control branch. The system stays releasable at every step, so the change can be spread over weeks or months while other work continues on the same main line.
Branch by abstraction, step by step
- Introduce an abstractionCreate an interface in front of the component to be replaced, with an implementation that simply delegates to the existing code. Nothing changes in behaviour.
- Move callers to the abstractionChange call sites, gradually, to use the interface instead of the old component directly. Each change is small and released normally.
- Build the new implementationWrite the replacement behind the same interface, testing it against the same tests and, ideally, against production behaviour in a parallel run.
- Switch over graduallyUse a feature flag or configuration to send some callers or some customers to the new implementation, increasing the share as confidence grows.
- Remove the old implementationOnce nothing uses the old code, delete it, and remove the abstraction too if it no longer earns its place.
Proving equivalence: parallel run and dark launching
The hardest question in any migration is whether the new implementation really behaves like the old one. A parallel run answers it with production evidence: both implementations receive the same inputs, the old one remains authoritative and its answer is returned to the user, and the two results are compared and any differences recorded. GitHub published its Scientist library in 2016 for exactly this purpose, running a control and a candidate side by side and reporting mismatches; similar tools exist for many languages, and the technique is simple to write by hand, as the sketch below shows.
Dark launching, which Fowler describes as calling new back-end behaviour in production without users seeing its effects, is closely related. It is mainly used to test the new path's performance and stability under real load before any user depends on it, while a parallel run concentrates on correctness. Both differ from a canary release, in which a small share of users really do receive the new behaviour.
Both techniques have sharp edges. The candidate must have no externally visible side effects: it must not send a second email, charge a card twice or write to the shared database, so parallel runs are easiest for reads and calculations, and writes need a sandbox or a separate store. Comparisons must ignore legitimate differences such as timestamps, ordering and floating-point formatting, or the mismatch report drowns in noise. And running both paths costs capacity, so the share of traffic sampled is often limited.
def quote_delivery_fee(order):
old = legacy_pricing.quote(order) # still authoritative
try:
new = new_pricing.quote(order) # must have no side effects
if new.amount_pence != old.amount_pence:
metrics.increment("pricing.mismatch")
log.info("pricing mismatch", order_id=order.id,
old=old.amount_pence, new=new.amount_pence)
else:
metrics.increment("pricing.match")
except Exception:
metrics.increment("pricing.candidate_error") # never hurts the user
return oldData: the hardest part
Routing requests is relatively easy; moving data and keeping it consistent while two systems share responsibility for it is where most migrations struggle. The first rule is the one from Chapter 7 on data ownership: for every kind of data, at every stage of the migration, the team must be able to say which system is the system of record, which copies exist and how stale each may be. The diagram below shows the typical flip: the legacy system owns the data at first and the new component reads a copy; later the new component owns it and the legacy system receives a copy for the modules that still need it.
Change data capture is the usual way to copy data out of a legacy database without changing the legacy code: a tool such as Debezium reads the database's transaction log and publishes each change as an event, the same mechanism Section 6.7 used to relay an outbox. Event interception, one of the legacy displacement patterns, works at the other end: it catches state-changing requests or messages on their way into the legacy system and forwards copies (or, later, the originals) to the new component. Either way, never let both systems accept writes to the same data independently with two-way synchronisation unless you have no alternative; conflicts are then almost guaranteed.
Legacy data rarely matches the new model. Status codes are integers with undocumented meanings, one table serves three purposes, and dates are stored in local time. An anti-corruption layer, the domain-driven design pattern for protecting a model from a neighbour's, translates between the two so that the legacy system's quirks do not leak into the new code. It belongs to the transitional architecture and should disappear with the legacy system.
The final data migration, moving history and cutting over, deserves its own plan. Profile the legacy data first to find duplicates, orphans and invalid values. Write the mapping explicitly and test it on a full, production-sized copy. Reconcile after every run with row counts, sums of money columns and checksums, not by spot checks. Rehearse the cutover, time it and write down the rollback. Where possible, migrate in cohorts (one region, one customer segment, one product line) rather than everything at once, so that a problem affects a fraction of customers and teaches you something before the next cohort.
The system of record flips during migration
Lessons from Kill It with Fire
Marianne Bellotti's Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones) (No Starch Press, 2021) draws on her experience modernising government and commercial systems, including mainframe estates. Several of its themes are worth carrying into any modernisation.
Modernisation should be justified by the value it delivers and the risk it removes, not by the age of the technology; old technology that is well understood and works may be the least risky part of the estate. Technology choices move in cycles, so a team that replaces a system only because its design is out of fashion is likely to build the next generation's legacy. Incremental improvement of the existing system is usually safer than replacement, and a full rewrite needs an unusually strong justification. Finally, modernisation is an organisational problem as much as a technical one: it needs visible early wins to keep momentum and morale, clear criteria for when it is finished, and attention to the incentives that made the old system decay, or the new one will follow it.
Worked example
Worked example: strangling the order module out of the monolith in six steps
This example looks back at how the food-delivery platform got the separate order service evaluated in Section 11.1. Earlier in its life, the platform's original monolith contained an order module that handled placing, changing, cancelling and tracking orders. Order changes had become the main bottleneck: they needed the whole monolith to be deployed, and Friday-evening peaks slowed every other feature. Fourteen other modules, including billing, reporting and customer support tools, read the order tables directly. The team planned an incremental migration to a separate order service.
| 1. Set outcomes and find the seam | Outcomes: order changes deployable daily and independently; three times today's Friday peak without affecting other features; no lost or duplicated orders. Analysis of query logs confirms that only the order module writes the order tables and that all order traffic enters through the /api/orders endpoints. The team records several weeks of production requests and turns them into characterisation tests that capture current behaviour, quirks included. |
|---|---|
| 2. Insert the routing facade | An API gateway is placed in front of the monolith and initially routes everything to it. Released on its own, it changes no behaviour; the team checks the added latency (a few milliseconds at the 95th percentile) and practises switching routes. Rollback: remove the gateway from the path. |
| 3. Build the new service in the shadows | The new order service has its own store. Legacy remains the system of record: change data capture copies order rows across, through an anti-corruption layer that maps integer status codes and local-time dates to the new model. Read endpoints run as a parallel run against legacy responses; the mismatch rate starts at about 2% (rounding of fees, time zones) and is driven below 0.01% before going further. |
| 4. Move reads | Order tracking reads are routed to the new service for 5%, then 25%, then all of traffic, watching error rates and latency at each stage. Rollback: one routing rule. |
| 5. Move writes, one city at a time | The new service becomes the system of record for orders placed in one pilot city, then for every city over six weeks. It publishes OrderPlaced and OrderCancelled events through a transactional outbox; a transitional adapter translates them into legacy-format rows so that billing and reporting keep working unchanged. A daily reconciliation compares counts and money totals between the two stores. Those modules then move to the events or the order API over the following quarters. |
| 6. Migrate history and retire | Three years of historical orders are copied in throttled batches and reconciled by counts and checksums. When database audit logs show no reads of the legacy order tables for 30 days, the team removes the adapter, the change data capture pipeline and the old routing rules, then deletes the order code and tables from the monolith. The migration is complete only at this point. |
Every step delivers something usable or removes risk, and every step except the last has a cheap rollback.
The system of record for orders is unambiguous at every stage, and data flows in one direction at a time.
The transitional architecture is planned, measured and deliberately removed; success is measured by what was switched off.
Check your understanding
Why does a big-bang rewrite tend to lose behaviour that users depend on?
Because years of bug fixes and business rules live only in the old code and in users' habits; nobody can list them all, so a rewrite from a specification misses some of them.
What does the routing facade contribute to a strangler fig migration?
It lets traffic be moved slice by slice, and moved back if something goes wrong, without editing the legacy system for each step.
What is transitional architecture, and why track its removal?
Temporary code such as routing rules, adapters and synchronisation jobs that lets old and new coexist; tracking its removal guards against a migration that stalls with both systems running indefinitely.
When might repurchase be a better choice than refactoring?
When the capability is generic rather than differentiating and a good product exists, so rebuilding it would spend effort where the business does not compete.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “Our core system is a 15-year-old monolith that only two people understand. How do I decide whether to rewrite it, and if not, where should an incremental migration start?”