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.

A laptop and a phone send arrows toward a gate. Behind it a large grey legacy block receives most of the traffic, while one arrow curves off to a small, separate service drawn in orange.
Fig. 1 The gate never moves; what sits behind it does.

Each iteration after that repeats four steps:

  1. Pick one capability with a clear edge, such as a URL prefix, a queue or a batch job.
  2. Build it as new code, together with the data it needs.
  3. Route its requests to the new code, first for a few users, then for all of them.
  4. 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 pointRoutes byFits whenWatch for
Reverse proxyHost, port, path, query parameters, headersA web app or HTTP API reachable by URLRouting on form data may need custom code
API gatewayThe published endpoint, sometimes the payloadClients already call a published APIA single point of failure or a bottleneck
Message routerMessage headers or contentSystems already talk through queues or topicsA legacy consumer may need reconfiguring to read the new destination
Abstraction in the codeWhich implementation a caller usesCalls inside the monolith, with no network edgeYou 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:

CriterionA good first sliceA poor first slice
Entry pointOne URL prefix, queue or API operationLogic reached from dozens of screens
DataOwns its tables, few joins to the restTables that every module joins to
Safe to compareRead-only, or writes you can replay in a copyPayments, emails and other effects that cannot run twice
ConsumersKnown callersReports, exports and batch jobs that read its tables
ValueChanges often, or costs a lot to runStable 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.

An application box writes to two databases. The upper write arrives and its database holds three documents; the lower arrow breaks short of its database, which is orange and holds only two.
Fig. 2 Two writes are not one transaction, so one store can end up a record behind the other.

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.

A legacy database pushes a strip of log tiles out of its side. An orange reader clipped over the strip sends envelopes along one arrow to the separate database of a new service.
Fig. 3 The new database is fed from the log, so the legacy application never has to know it exists.

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:

  1. 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.
  2. Intercept reads. All reads for the domain go to the new services. Writes still go to the legacy store.
  3. Intercept writes. Writes move too. The new service becomes the system of record, and downstream users such as reports must move to it.
  4. 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_pass makes NGINX answer /invoices with a 301 to /invoices/. Without the location = /invoices block, our run returned 301 Moved Permanently.
  • The Host header. By default NGINX sends Host: $proxy_host, the name in proxy_pass, not the client's. Without proxy_set_header Host $host;, our legacy stand-in saw host=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 as location ~ \.json$ took /invoices/1.json back in our run.
  • Sticky buckets. split_clients hashes 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 large grey legacy block with a square plug and a small new service with a round plug are joined by an orange adapter that has a square socket on one end and a round socket on the other.
Fig. 4 The adapter takes the shape difference, so neither side has to change its plug.

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.

A laptop's request is copied by a splitter. The legacy block answers the laptop, while the new service's copy of the answer goes only to an orange balance scale that weighs it against the legacy answer.
Fig. 5 The customer only ever sees the old answer; the new one is weighed in private.

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.

Three panels show the same gate in front of a legacy block. Across the panels the block shrinks from six parts to a dashed outline while new service boxes multiply, above one orange arrow for time.
Fig. 6 Progress is the old block getting smaller, not the new boxes getting more numerous.

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:

MeasureHow to get itWatch for
Share of requests on new codeCount the upstream column in the proxy logAt 100% a slice is not done until its old code is gone
Capabilities retiredLegacy endpoints, jobs and tables deleted, from a list kept in the repository"Migrated" and "retired" are different states
Transitional parts still runningSync jobs, adapters, mirror rules and toggles, each with an owner and a removal dateThe count rises with a slice and must fall after it

Strangler fig, rewrite, refactor or replace: which fits?

ApproachWhat you doFits whenMain risk
Big-bang rewriteBuild the whole replacement, then cut over onceThe system is small and its behaviour is fully knownHidden behaviour surfaces at the cut-over, and new features wait
Refactor in placeRestructure the internals, same behaviour, same platformThe design is sound and tests exist or can be addedIt does not change the platform you are stuck on
Replace with a productBuy or subscribe, migrate the data, retire the moduleThe capability is not what sets your business apartProcess change and data migration become the work
Strangler figRoute by capability, move each slice, delete the oldThe system works, cannot stop, and has seamsRunning 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?

SymptomLikely causeFix
Everything is down when the proxy isThe facade is a single point of failure or a bottleneckRun several instances, load test the routing layer and keep its configuration in version control
Old and new disagree about a customerDual writes, or two systems writing the same tableOne writer per table, CDC for the copy and a scheduled comparison query
A report breaks after a slice movesA hidden consumer read the legacy table or fileList every consumer before the move; in the staged example, downstream reports had to move
One change touches two or three servicesThe slice boundary is wrongCut by business capability, and merge slices that always change together
Legacy keeps running past its end dateSlices were migrated but never deletedMake deletion part of done, track retired capabilities and put a date on the facade

A migration plan you can start this month

  1. Write the finish line. Define what "legacy switched off" means, who owns it, and what the business gets from each slice.
  2. Map the seams and the consumers. List every entry point (URLs, queues, batch files, tables, reports) and who calls or reads each.
  3. 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.
  4. Choose the first slice and build it with its data. Share the database first, and add CDC when the slice needs its own.
  5. Dark launch. Mirror or compare read traffic, fix every difference, and move on only when the differences stay at zero.
  6. Switch a small share, raise it in steps, and keep rollback to one configuration change.
  7. 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.
  8. 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.