The strangler fig pattern replaces a legacy system gradually. A facade, such as a reverse proxy or an API gateway, goes in front of the old system. You rebuild one capability at a time as new code, route that capability's requests to it, then delete the old part. When nothing depends on the old system, you switch it off.
This guide covers the origin, the interception point, the first slice, the data and an NGINX routing example that we ran on NGINX 1.30.5. For the debt you are replacing, see our technical debt guide.
Where does the strangler fig pattern come from?
During a vacation in the rain forests of Queensland in 2001, Martin Fowler saw strangler figs: vines that start in a host tree, become self-sustaining and may leave "an echo of its shape" when the host dies. He saw an analogy to how colleagues modernized legacy systems and wrote a short post in 2004 proposing "to gradually create a new system around the edges of the old". In 2019 he retitled it "Strangler Fig Application", because "strangler" alone had taken on a violent connotation. His current text is dated 22 August 2024.
Fowler's case for going gradual is reduced risk. Replacements seem easy to specify, but "often it's hard to figure out the details of existing behavior", and much of that behaviour is "stuff that isn't really wanted, so building it is a waste". A strangler fig "can give value steadily", and its frequent releases "allow you to monitor its progress more carefully".
How does the strangler fig pattern work?
There is one moving part: an interception point in front of the legacy system. The Azure Architecture Center calls it a facade (proxy) that routes each request either to the legacy application or to the new services. AWS Prescriptive Guidance starts the same way: a proxy layer that, at the start, routes all traffic to the monolith.

Each iteration after that repeats four steps:
- Pick one capability with a clear edge, such as a URL prefix, a queue or a batch job.
- Build it as new code, together with the data it needs.
- Route its requests to the new code, first for a few users, then for all of them.
- Delete the old code and the old data.
When nothing depends on the legacy system any more, the facade routes every request to the new system. Azure says you typically remove the facade then, or keep it as an adapter for legacy clients.
Routing is only part of the work. Fowler calls the code that lets old and new coexist "transitional architecture" that "will go away once the modernization is complete", and judges that the reduced risk and earlier value "outweigh its costs". By that definition the facade, any job that keeps the two data stores in step and any adapter all count, so plan their removal when you add them.
Where do you put the interception point?
In Patterns of Legacy Displacement, published on Fowler's site, Ian Cartwright, Rob Horn and James Lewis call the technique event interception: intercept events passing between components and route some of them to new ones. They add that if you use the strangler fig pattern, "you will also be using some form of Event Interception". Use the interception point your system already has. Our system integration guide compares the wider approaches.
| Interception point | Routes by | Fits when | Watch for |
|---|---|---|---|
| Reverse proxy | Host, port, path, query parameters, headers | A web app or HTTP API reachable by URL | Routing on form data may need custom code |
| API gateway | The published endpoint, sometimes the payload | Clients already call a published API | A single point of failure or a bottleneck |
| Message router | Message headers or content | Systems already talk through queues or topics | A legacy consumer may need reconfiguring to read the new destination |
| Abstraction in the code | Which implementation a caller uses | Calls inside the monolith, with no network edge | You must be able to change the source code |
The routing layer can become a single point of failure or a performance bottleneck, so run more than one instance and watch its latency.
Which capability should you move first?
Look for seams: places where the system can be split. Fowler says a well-designed system would already have them, but "such systems are unicorns", so finding them is real work. AWS warns that premature decomposition "can be costly, especially when the domain isn't clear", and suggests domain-driven design and event storming to find the boundaries. In a 2019 study of 14 systems, based on 16 interviews with German-based software professionals, finding the right service cut "was a major technical challenge". One participant said, "Our service cut was not good, we sometimes needed to touch two or three services for a change."
The first slice should prove the route (routing, deployment, logging, data sync and rollback), so choose it for low risk. Later slices can deliver what the business is waiting for, and Azure notes the approach lets you do high-ROI replacements before low-ROI ones. Score candidates against this table:
| Criterion | A good first slice | A poor first slice |
|---|---|---|
| Entry point | One URL prefix, queue or API operation | Logic reached from dozens of screens |
| Data | Owns its tables, few joins to the rest | Tables that every module joins to |
| Safe to compare | Read-only, or writes you can replay in a copy | Payments, emails and other effects that cannot run twice |
| Consumers | Known callers | Reports, exports and batch jobs that read its tables |
| Value | Changes often, or costs a lot to run | Stable code that nobody touches |
In an order system, a read-only order status page scores well. Pricing and tax rules, which every order path calls, wait until the domain is clearer.
How do you move the data?
The data is what makes the pattern hard, because old and new code must agree on it while both run.
Share the database first
In Azure's database example, the new service first reads and writes the monolithic database for its domain tables, while the legacy system serves everything else. Azure's guidance is to make sure that "both systems can access these resources at the same time". That needs no sync, but it couples the new code to the legacy schema, so keep the access in one data layer. AWS lists it as a best practice that "the microservice should own its data."
Why not write to both databases?
The tempting shortcut is a dual write: the application updates the old store and the new one in the same code path. Martin Kleppmann describes two ways it fails. Two clients writing the same key can reach the two stores in different orders, so the stores "permanently remain inconsistent" until something overwrites the key, and no error is raised. And a process that fails between the two writes leaves one store with the change and the other without it.

Copy changes with change data capture
Instead, let one system write and copy its changes to the other. Azure's example loads the domain tables' history with an extract, transform and load (ETL) process, runs change data capture (CDC) to sync changes from the monolithic database, and validates consistency before cutover. The PostgreSQL connector in Debezium 3.7 takes a consistent snapshot the first time it connects, then continuously captures row-level changes that were committed.

Two caveats matter. PostgreSQL logical decoding "does not support DDL changes", so the connector cannot report schema changes to consumers, and you must plan how a change to a synced table reaches the new database. And after a failure Debezium provides at-least-once delivery, so consumers "should always anticipate some duplicate events": write idempotently, for example as an upsert by primary key. Our change data capture guide covers the mechanics and the outbox pattern, and our dead letter queue guide covers idempotency keys.
Hand over each table in stages
Cartwright, Horn and Lewis describe a team that extracted one domain in four stages:
- Dark launch. The new services keep their data in sync with the legacy system but have no consumers, so the team can validate 100% parity with the legacy data store.
- Intercept reads. All reads for the domain go to the new services. Writes still go to the legacy store.
- Intercept writes. Writes move too. The new service becomes the system of record, and downstream users such as reports must move to it.
- Move the business rules into the new services.
At each stage only one system takes the writes for that data, which is the rule to copy.
Warning
Do not drop the legacy tables when traffic switches. Azure says you can roll back to the monolithic database while the domain tables and sync still exist. After you remove them, you must restore those objects and replay data changes, which "significantly increases effort and risk". Remove legacy objects as a deliberate final step for each domain, after the new system is validated.
How do you route one path to the new service?
This NGINX example keeps everything on the legacy system except /invoices, which it sends to a new billing service for 10% of customers. We ran this exact file on NGINX 1.30.5, with two throw-away servers standing in for the back ends. The hostnames, ports and the customer cookie are examples.
log_format strangler '$time_iso8601 $request_method $uri $status '
'upstream=$upstream_addr upstream_status=$upstream_status';
upstream legacy { server legacy.internal:8080; }
upstream billing { server billing.internal:3000; }
# Hash a stable customer ID, so each customer stays on one side.
split_clients "${cookie_customer_id}" $invoices_to {
10% billing;
* legacy;
}
server {
listen 80;
server_name app.example.com;
access_log /var/log/nginx/strangler.log strangler;
proxy_set_header Host $host;
location / {
proxy_pass http://legacy;
}
# Slice 1: invoices.
location = /invoices {
proxy_pass http://$invoices_to;
}
location ^~ /invoices/ {
proxy_pass http://$invoices_to;
}
}
Because the proxy_pass target is a variable holding an upstream group's name, NGINX looks the name up among the defined groups and needs no resolver. Four points come from NGINX's documentation, and our run reproduced each:
- Exact match for the bare path. A prefix location that ends in a slash and uses
proxy_passmakes NGINX answer/invoiceswith a 301 to/invoices/. Without thelocation = /invoicesblock, our run returned301 Moved Permanently. - The Host header. By default NGINX sends
Host: $proxy_host, the name inproxy_pass, not the client's. Withoutproxy_set_header Host $host;, our legacy stand-in sawhost=legacy. - Regular expressions beat plain prefixes. NGINX remembers the longest matching prefix, then tries regular expressions in order and uses the first match, unless the prefix has
^~. Without it, a legacy rule such aslocation ~ \.json$took/invoices/1.jsonback in our run. - Sticky buckets.
split_clientshashes the string with MurmurHash2, so a customer ID always lands on the same side. In our run, 10% sent 200 of 2,000 made-up customer IDs to billing, and 25% kept those 200 and added 286. Requests without the cookie all land on one side, which was billing in our run.
To roll back, delete the 10% billing; line, because NGINX rejects 0% as an invalid percent value, then run nginx -t and nginx -s reload. To measure the share moved, count the upstream= column that the log format writes:
awk '{print $5}' /var/log/nginx/strangler.log | sort | uniq -c
Which patterns make each step safer?
Four patterns recur around the routing layer. Treat each as transitional, with a retirement trigger written down when you add it.
Branch by abstraction works from inside the code. You create an abstraction layer between one section of client code and the current supplier, call the supplier only through it, build a new supplier behind the same layer, switch over, and delete the old supplier.
An anti-corruption layer (ACL) is an adapter that translates requests between systems that do not share the same semantics, so the legacy model does not leak into the new design. AWS suggests building it inside the monolith as a class specific to the migrated service, such as UserServiceFacade, and decommissioning it once all dependent services have moved. Azure notes it adds latency and an extra service to maintain.

A feature toggle is a switch that decides who gets the new code, and the percentage in the NGINX example works as one. Pete Hodgson writes that release toggles "are transitionary by nature" and "should generally not stick around much longer than a week or two", although product-centric toggles may need to remain longer. A migration switch can outlast that, so treat toggles as inventory with a carrying cost: add a removal task when you add the toggle, or give it an expiration date.
A parallel run executes old and new on the same input and compares the results while users keep receiving the old answer. GitHub's Scientist does it inside the code: it runs the original code (the control) and the rewrite (the candidate), returns the control's result, and records differences and timings. GitHub only uses it on read operations, because a candidate that writes to the same database is "dangerous and incorrect". At the proxy, NGINX's mirror directive sends a copy of each request to a second location and ignores the response. Add this inside the server block above:
# Dark launch: legacy answers; billing gets a copy and its answer is dropped.
location /reports/ {
mirror /mirror-reports;
proxy_pass http://legacy;
}
location = /mirror-reports {
internal;
proxy_pass http://billing$request_uri;
}
In our run, legacy answered the customer and the billing stand-in received a copy of both a GET and a POST. A mirrored write runs on the new service too, so mirror reads, or give the new service a copy of the data with its side effects switched off.

We used the same comparison in a legacy Java modernization assessment and proof of concept: a slice moved from Struts to Spring Boot was checked against a golden baseline of the old system's screens, database writes and report files.
How do you measure progress without fooling yourself?
Progress should look like the picture below: the legacy block shrinks slice by slice while the facade stays until the last one goes.

A slice that is "migrated" but not deleted still costs you two systems to run, patch and keep in sync, and the risk in this pattern is a facade and two systems that live on for years. In the 2019 study, just three of the 14 systems had finished their migration activities, and the time frames ranged from 1.5 to over 3 years, "based on the already finished migrations or expected dates". Measure what is retired, not what is built:
| Measure | How to get it | Watch for |
|---|---|---|
| Share of requests on new code | Count the upstream column in the proxy log | At 100% a slice is not done until its old code is gone |
| Capabilities retired | Legacy endpoints, jobs and tables deleted, from a list kept in the repository | "Migrated" and "retired" are different states |
| Transitional parts still running | Sync jobs, adapters, mirror rules and toggles, each with an owner and a removal date | The count rises with a slice and must fall after it |
Strangler fig, rewrite, refactor or replace: which fits?
| Approach | What you do | Fits when | Main risk |
|---|---|---|---|
| Big-bang rewrite | Build the whole replacement, then cut over once | The system is small and its behaviour is fully known | Hidden behaviour surfaces at the cut-over, and new features wait |
| Refactor in place | Restructure the internals, same behaviour, same platform | The design is sound and tests exist or can be added | It does not change the platform you are stuck on |
| Replace with a product | Buy or subscribe, migrate the data, retire the module | The capability is not what sets your business apart | Process change and data migration become the work |
| Strangler fig | Route by capability, move each slice, delete the old | The system works, cannot stop, and has seams | Running two systems, and a migration that drags on |
AWS notes that while an application is being refactored, adding features is "extremely hard or even impossible". In the 2019 study, most companies preferred a rewrite over splitting up existing code bases, often because they had no suitable decomposition approach, and the Strangler pattern was used in seven cases. Our cloud migration strategy guide places refactoring among its seven migration treatments.
The pattern is the wrong tool in some situations. Azure's page, last updated 2 June 2026, lists four: requests to the back end cannot be intercepted, you cannot access the legacy source code, you are migrating a small system and replacing it is simple, or you need to fully decommission the original solution quickly. AWS adds that for small applications, rewriting may be more efficient than migrating. Two more situations make it hard rather than impossible. Azure notes that a centralized database serving many domains becomes difficult to manage and improve because of its cross-domain dependencies, so untangle ownership of the data first. And Fowler warns that without a change in organizational culture and leadership, the new systems "will end up in a similar mess".
What goes wrong, and what stops it?
| Symptom | Likely cause | Fix |
|---|---|---|
| Everything is down when the proxy is | The facade is a single point of failure or a bottleneck | Run several instances, load test the routing layer and keep its configuration in version control |
| Old and new disagree about a customer | Dual writes, or two systems writing the same table | One writer per table, CDC for the copy and a scheduled comparison query |
| A report breaks after a slice moves | A hidden consumer read the legacy table or file | List every consumer before the move; in the staged example, downstream reports had to move |
| One change touches two or three services | The slice boundary is wrong | Cut by business capability, and merge slices that always change together |
| Legacy keeps running past its end date | Slices were migrated but never deleted | Make deletion part of done, track retired capabilities and put a date on the facade |
A migration plan you can start this month
- Write the finish line. Define what "legacy switched off" means, who owns it, and what the business gets from each slice.
- Map the seams and the consumers. List every entry point (URLs, queues, batch files, tables, reports) and who calls or reads each.
- Put the facade in with no behaviour change. Route 100% to legacy, deploy the proxy through your CI/CD pipeline, and measure latency and errors through it. Decide how a signed-in user's session reaches the new service.
- Choose the first slice and build it with its data. Share the database first, and add CDC when the slice needs its own.
- Dark launch. Mirror or compare read traffic, fix every difference, and move on only when the differences stay at zero.
- Switch a small share, raise it in steps, and keep rollback to one configuration change.
- Retire. Delete the old code, tables, jobs, toggles and access, then take the next slice. Each slice goes through the same software development life cycle as any other change.
- Remove the facade after the last slice, or keep it as an adapter if legacy clients still need it.
Our cloud transformation work moves workloads in rehearsed waves, each with entry criteria, a cutover plan and a tested rollback, and our custom software development work starts with one deployable application and splits out services only where load or teams need them.


