Guide · Backend
Strangler Fig Pattern: Replace a Monolith One Route at a Time
30 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The strangler fig pattern replaces an old system piece by piece instead of in one big rewrite. A facade, a routing layer every request passes through first, sends each moved route to new code.
- A route is one group of addresses, such as every URL starting with /documents/. Everything not moved yet still goes to the old system, so there is no risky switch-over night.
- Each route follows the same path. Build it, shadow it (copy real requests to the new code and compare answers), give it a small share of users, then everyone, then delete the old code.
- The data is usually harder than the code. Give every table one owner that accepts writes, and keep copies in sync with change data capture, which turns each committed row change into an event.
- Beginners can stop after the section on when not to use the pattern. Engineers then get a facade tested on nginx 1.24.0, a widely used reverse proxy. It shows a pattern-matching rule that steals moved paths, a rollback nginx refuses and a slow shadow delaying real users.
What goes wrong when you replace an old monolith in one go?
Nothing new reaches users until everything is finished, and on switch-over day every hidden bug arrives at once. The strangler fig pattern avoids that by replacing the old system one route at a time while it keeps running.
Picture a common setup. A cooperative insurer runs one member portal for about 2 lakh (200,000) farmers, dairy owners and small traders. The setup is made up, but the problem is real.
Members use the portal to see policies, download documents, get renewal quotes, pay premiums and file claims. The portal is a monolith: one big application, built and deployed as one unit, talking to one large database. It is about fifteen years old.
Years of changes live inside it. The receipt code and the claims code share helper classes nobody dares to touch. Every release needs a night of downtime, so the team ships once a month.
Last renewal season, a small fix to the premium receipt PDF broke the claims page for two days. About 1,400 members could not file claims. The board's first idea was to freeze features, rewrite the whole portal in 18 months and switch over in one night.
The tech lead lists what worries her about that plan.
- Nobody has a full list of what the old portal does. Some rules exist only in code, like a premium discount for members over 70.
- Members and the board would wait 18 months for any visible change.
- Insurance rules keep changing during the rewrite, so the target keeps moving.
- On switch-over night every new bug arrives at once, and the only way back is the entire old system.
Martin Fowler, who named the pattern, has seen the same thing. He writes that the plan to rebuild the same system on newer technology goes "down in flames most of the time". The team decides to keep the old portal running and replace it one route at a time.
Which words do you need before learning the strangler fig pattern?
You need six words. Each one comes back many times in this guide.
- Facade: the routing layer in front of everything. Every request reaches it first, and it decides whether the old monolith or a new service answers.
- Reverse proxy: a server that receives requests and forwards them to other servers. The team's facade is a reverse proxy called nginx.
- Route: one group of requests, named by the start of the URL path. Every request starting with /documents/ belongs to the documents route.
- Seam: a place where you can change behaviour without editing the code there. The facade is a seam, because changing one rule moves a route.
- Shadow traffic: a copy of real requests sent to the new code while users still get the old answers. You compare the two answers and log differences.
- Transitional architecture: temporary parts that exist only so old and new can run side by side, such as facade rules and sync jobs. You delete them when the move ends.
One everyday picture helps. Think of a wooden fishing boat that a boatyard repairs between trips instead of pulling it out of the water for a year. Each visit, carpenters replace a few rotten planks and send the boat back to sea.
The portal is the boat, each route is a plank, and members are the crew who keep fishing. Testing a new plank in shallow water before a sea trip is shadow traffic. The temporary braces that hold a plank while it sets are transitional architecture.
Where the picture breaks: a plank never has to agree with the old plank it replaced. A new route must give members the same answers as the old one, and proving that is most of the work.
What is the strangler fig pattern?
The strangler fig pattern is a way to replace an old system gradually. You put a facade in front, build new parts beside the old system, and move traffic over route by route until the old one can be switched off.
The name comes from a plant. Fowler writes that he saw strangler figs in the rainforests of Queensland, Australia, in 2001. The fig starts in a nook of a host tree, sends roots down to the soil, and can outlive the host.
Fowler first called the idea the Strangler Application. He later renamed it the Strangler Fig Application, because people shortened it to "strangler", forgot the plant and heard a violent word. His page on it was last updated on 22 August 2024.
The pattern says nothing about what you build. The new parts can be one new application, a cleaner modular monolith (one application with clearly separated parts) or several services. It is only about how you get there.
How does the facade decide where each request goes?
The facade compares each request's path with a list of rules and forwards it to the backend the matching rule names. Anything that matches no rule goes to the monolith, so features you have not moved keep working.
The portal halfway through the move
Drag to exploreMembers use the same addresses whichever side answers. Notice the quote service still reads the old database: that debt is paid in the data section.
Diagram connections:
- Members on web and app to Facade (nginx)
- Facade (nginx) to Old policy portal (Everything else)
- Facade (nginx) to Documents service (Documents paths)
- Facade (nginx) to Renewal quote service (Renewal quotes)
- Old policy portal to Old portal database
- Documents service to Document store
- Renewal quote service to Old portal database (Reads policies, for now)
- Facade (nginx) to Dashboards and alerts (Logs every request)
- Members on web and app: Same addresses as before the move
- Facade (nginx): Matches path rules, forwards each request
- Old policy portal: Answers every route not moved yet
- Old portal database: Policies, claims and payments
- Documents service: Policy PDFs and receipts, moved first
- Document store: Keeps generated PDFs
- Renewal quote service: Gets 10 percent of members today
- Dashboards and alerts: Errors and speed for each route
For example, /documents/receipt-7 matches the documents rule and goes to the new service. /payments/history matches no rule, so the monolith answers it.
Here is that decision as a short Python sketch you can run with Python 3. The call function stands in for real forwarding, and log_mismatch for whatever records a difference. If code is new to you, skip to What just happened.
import zlib
from collections import namedtuple
Request = namedtuple("Request", "method path member_id")
ROUTES = [
{"prefix": "/documents/", "new": "documents", "new_percent": 100},
{"prefix": "/policies/renewal-quote/", "new": "quotes", "new_percent": 10},
{"prefix": "/claims/status/", "new": "claims", "new_percent": 0, "shadow": True},
]
def find_route(path):
# Longest prefix first, so a specific rule beats a general one
for route in sorted(ROUTES, key=lambda r: -len(r["prefix"])):
if path.startswith(route["prefix"]):
return route
return None
def bucket(member_id):
# crc32 gives the same number in every process; hash() does not
return zlib.crc32(member_id.encode()) % 100
def handle(request, call, log_mismatch):
route = find_route(request.path)
if route is None:
return call("monolith", request)
if bucket(request.member_id) < route["new_percent"]:
return call(route["new"], request)
answer = call("monolith", request)
if route.get("shadow") and request.method == "GET":
if call(route["new"], request) != answer:
log_mismatch(route["prefix"], request)
return answer
if __name__ == "__main__":
def call(backend, request):
print(" called", backend)
return backend + " answer"
def log_mismatch(prefix, request):
print(" mismatch logged for", prefix)
for r in [Request("GET", "/payments/history", "M7"),
Request("GET", "/policies/renewal-quote/", "M7"),
Request("GET", "/claims/status/55", "M7"),
Request("POST", "/claims/status/55", "M7")]:
print(r.method, r.path)
handle(r, call, log_mismatch)
members = [f"M{i}" for i in range(1, 200001)]
share = sum(bucket(m) < 10 for m in members) / len(members)
print(f"Share on the new quote service: {share:.1%}")Running it prints this. We ran it on Python 3.13.
GET /payments/history
called monolith
GET /policies/renewal-quote/
called monolith
GET /claims/status/55
called monolith
called claims
mismatch logged for /claims/status/
POST /claims/status/55
called monolith
Share on the new quote service: 10.0%What just happened? The payment path matched no rule, so it went to the monolith. The quote request went to the monolith because member M7's bucket is 40, and only buckets 0 to 9 get the new service.
Claim status went to the monolith and, because it is a GET, a shadow copy went to the claims service. The answers differed, so a mismatch was logged. The POST got no shadow copy at all.
In this sketch the shadow call finishes before the member gets a reply. A real facade sends the copy in the background, and the engineer section shows that even that has a cost.
Why the bucket uses crc32 and not hash()
The bucket must stay the same for a member across requests and across servers, or members flip between old and new screens. crc32 is a fixed checksum, so member M7 gets bucket 40 on every machine.
Python's built-in hash() for strings is randomised per process by default. In our test, the same member id gave buckets 32, 19 and 31 in three fresh processes. Over 2 lakh member ids, the crc32 split gave 10.0 percent, as the output shows.
Which part of the monolith should you move first?
Move first a route that is small, mostly read-only, used often and loosely tied to the rest. A failure there should annoy members a little, never cost them money.
The team writes down every route in the portal and scores each one on four questions.
- Writes: does the route change data? Read-only routes are far easier to move and to undo.
- Coupling: how many shared tables and helper classes does it touch? Fewer is better.
- Traffic: is it used enough to show problems quickly, without being the busiest path in the portal?
- Value: does moving it fix a pain people feel, like last season's receipt bug?
Document downloads win easily. They only read data, they touch two tables, and the PDF code is exactly what broke the claims page. Once PDFs live in their own service, a PDF bug cannot take the claims page down with it.
Claims go last. They write money-related data, touch most tables and hide the most rules. By then the team will have learned the tricky parts on safer routes.
Ian Cartwright, Rob Horn and James Lewis wrote a catalogue called Patterns of Legacy Displacement. They say breaking the problem up involves finding the right seams in both the business and the technical design.
Domain-driven design, a way of shaping software around the business's own areas and words, helps with the business side. A bounded context there is a boundary inside which one consistent model of the business applies. For the portal, documents, quotes, payments and claims are four such areas, and each is a candidate route.
How do you move one route safely from old to new?
You move a route in small stages, each of which can be undone. Build the new service, shadow real traffic and compare answers, send a small share of members, then all of them, and finally delete the old code.
Moving one route from old to new
Drag to exploreFollow the question box: a mismatch sends the route back to be fixed, never forward to members. The percentages are the team's own steps (made up).
Diagram connections:
- Pick the next route to Build the new service
- Build the new service to Shadow real requests
- Shadow real requests to Answers match for a week?
- Answers match for a week? to Fix the differences (No)
- Fix the differences to Shadow real requests (Shadow again)
- Answers match for a week? to Send 5 percent of members (Yes)
- Send 5 percent of members to Raise to 100 percent (Healthy)
- Raise to 100 percent to Delete the old code (Old route idle)
- Pick the next route: Small, read-heavy, loosely coupled
- Build the new service: Must give the same answers as the old route
- Shadow real requests: Copies only, members still see old answers
- Answers match for a week?: Read from the mismatch log
- Fix the differences: A bug in new code, or a hidden old rule
- Send 5 percent of members: Watch errors and speed for this route
- Raise to 100 percent: In steps of 25, 50 and then 100
- Delete the old code: And remove its facade rule
Here is how the documents route went for the team. The weeks are made up, but each stage is the real sequence.
Weeks 1 to 3: build
The new documents service generates the same PDFs. It reads policy data from the old database through a read-only account, so it cannot damage anything.
Week 4: shadow
The facade copies every document request to the new service. A job compares the text of the two PDFs and logs any difference.
Week 5: find the hidden rule
The log shows that 3 percent of receipts differ. The old code prints the amount in Marathi words for members who chose Marathi. Nobody had ever written that down.
Week 6: 5 percent of members
After the fix, 5 percent of members get the new service. The team watches errors and download times for this route on the dashboard.
Weeks 7 and 8: everyone
The share rises to 25, 50 and then 100 percent. Each step is a one-line change in the facade config.
Week 10: delete
After two weeks with no traffic to the old PDF code, the team deletes it and its facade rule. The monolith gets smaller for the first time in years.
That Marathi receipt rule is the whole reason to shadow. Old systems are full of behaviour nobody remembers, and comparing real answers finds it before members do.
The legacy displacement catalogue has names for these stages. Dark launching means calling a new back end without using its results, to assess its performance impact. That is the shadow stage. A canary release exposes new software to a small group of users first, then to everyone once it proves stable. That is the 5 percent stage.
What happens to the database in a strangler fig migration?
Both systems need the same data while the move is in progress, so the database is usually the hardest part. You decide who owns each table, and you keep any copies in sync until the move ends.
Moving code is easy to undo, but two copies of data that drift apart are not. The team moves the data for renewal quotes in three stages.
Stage 1: share the old database
At first the quote service reads the old tables directly. It is quick, but both systems now depend on the same table layout, so neither can change it freely.
Stage 2: give the new service its own copy
Next the quote service gets its own tables. A backfill job copies the old records into them once. After that, every new change must keep flowing across.
Change data capture, or CDC, does that flowing. It reads the database's change log, which PostgreSQL calls the write-ahead log, and passes on only committed changes. Each row-level insert, update or delete becomes one event that the new service applies to its copy.
Keeping two copies of policy data in sync
Drag to exploreChanges flow one way, from the current owner to the copy. Notice the compare job reads both sides: it is how the team knows the copies still agree.
Diagram connections:
- Old policy portal to Old portal database
- Old portal database to Change capture
- Change capture to Change events
- Change events to Renewal quote service
- Renewal quote service to Quote service database
- Old portal database to One-time backfill job (Reads old records)
- One-time backfill job to Quote service database (Writes them once)
- Nightly compare job to Old portal database (Reads)
- Nightly compare job to Quote service database (Reads)
- Old policy portal: Still writes policy and renewal data
- Old portal database: Owner of policy records for now
- Change capture: Reads the database change log
- Change events: One event per changed row
- Renewal quote service: Applies each change to its copy
- Quote service database: Its own copy of policy data
- One-time backfill job: Copied all old policies once
- Nightly compare job: Counts rows that differ, alerts on any
Stage 3: switch the owner
Finally the quote service becomes the owner and takes all writes. If the monolith still needs the data, changes now flow the other way. The old table becomes read-only and is later dropped.
Why not just write to both places?
Writing every change to the old and new tables from the same code is called a dual write. Shopify described using it in 2020 to move one setting out of a Shop class of over 3,000 lines in its Ruby on Rails monolith.
The team wrapped the two writes in a transaction, backfilled old rows and compared both sources. Then it switched reads to the new table, stopped the old writes and dropped the old column.
That works when both tables sit in one database, because one transaction can cover both writes. An ordinary transaction cannot span two separate databases. PostgreSQL 16 even refuses a single query that names another database, as the engineer section shows.
Spanning two databases needs two-phase commit, a protocol where each database first promises it can commit. The PostgreSQL manual says its command for this is meant for external transaction managers, not applications. That is why this guide uses CDC once the quote service has its own database.
Is a strangler fig migration better than a full rewrite?
For most large systems people use every day, yes. A strangler fig migration delivers value early and keeps every step reversible, while a full rewrite holds all the risk until one switch-over day.
The legacy displacement authors saw this in real projects. They name the desire for feature parity, rebuilding everything the old system does, as a key factor in several failures. In those projects it led to plans for one big-bang cut-over release.
| Full rewrite | Strangler fig | |
|---|---|---|
| First change members see | After about 18 months | After about 2 months |
| Old portal during the work | Frozen, then switched off in one night | Keeps running and shrinks route by route |
| Hidden old rules | Found by members after switch-over | Found by shadow comparison, one route at a time |
| Undoing a mistake | Switch everything back, if that still works | Point one route back at the monolith |
| Extra work | Little temporary code | Facade rules, sync jobs and compare jobs, deleted later |
| Biggest risk | One huge failure on launch night | Stalling halfway with two systems to run |
| Good fit | Small apps, or apps few people use yet | Large live systems people depend on daily |
The extra work row is real. Fowler writes that teams balk at building transitional architecture they will throw away. He argues the reduced risk and earlier value of the gradual approach outweigh that cost.
What can go wrong with the strangler fig pattern?
The common failures are stalling halfway, never deleting old code, a facade that grows too clever and new services that still share old tables. Each one has a simple guard.
- Stalling. The easy routes move, the hard ones never do, and you run two systems for ever. Guard: a dated plan for every route, reviewed each quarter.
- Never deleting. New services ship, but the old code stays just in case. Guard: a route is not done until its old code and facade rule are gone.
- A clever facade. Business rules creep into the routing layer. Guard: the facade only routes and logs.
- A distributed monolith. New services still share the old tables, so they must change together. Guard: give each service its own data, using the three stages.
- Extra delay. Every request now makes one more network hop. Guard: measure the time the facade adds for each route.
- Logins that do not carry over. A member signed in on the monolith reaches a new service as a stranger. Guard: move sign-in to a shared service early.
Fowler's article also warns about people, not only code. It points to Conway's law: systems tend to copy the communication structure of the organisation that builds them. If the teams keep their old shape, the new services can repeat the old tangles.
When should you not use the strangler fig pattern?
Skip it when the old system is small enough to rewrite and switch over safely, or when nothing can sit in front of it to catch requests. Gradual replacement pays off for big systems still in daily use.
- The app is small with few users, so a short rewrite and a careful switch-over is cheaper.
- There is no place to route on, such as a desktop app that talks to its database directly.
- The old system is being retired with no replacement at all.
- The real problem is one slow query or one messy module, which an ordinary fix can solve.
For code inside one application, branch by abstraction does a similar job. You put an interface in front of the old module, move callers to it, build a new module behind it and delete the old one when nothing uses it.
Fowler credits the name to Paul Hammant. The team uses it for the premium calculation library, which many routes call and no facade can reach.
How do you build a strangler fig facade in nginx?
You write one nginx location block per moved route, a default block for the monolith, and a split for routes that are part-way across. We tested this config on nginx 1.24.0, the Ubuntu 24.04 package, on 19 October 2026, changing only the port numbers.
# facade.conf: include it inside the http block of nginx.conf
upstream monolith { server 127.0.0.1:9001; }
upstream documents { server 127.0.0.1:9002; }
upstream quotes { server 127.0.0.1:9003; }
upstream claims { server 127.0.0.1:9004; }
# The same member id always lands in the same bucket
split_clients "${http_x_member_id}" $quote_backend {
10% quotes;
* monolith;
}
# No member id: always the monolith
map $http_x_member_id $quote_target {
"" monolith;
default $quote_backend;
}
server {
listen 8080;
# Default: every route not moved yet
location / {
proxy_pass http://monolith;
add_header X-Facade-Backend monolith always;
}
# An old rule from years ago, still in the file
location ~* \.pdf$ {
proxy_pass http://monolith;
add_header X-Facade-Backend monolith-pdf always;
}
# Moved route; ^~ stops regex rules from taking its paths
location ^~ /documents/ {
proxy_pass http://documents;
add_header X-Facade-Backend documents always;
}
# Canary: 10 percent of members get the new quote service
location ^~ /policies/renewal-quote/ {
proxy_pass http://$quote_target;
add_header X-Facade-Backend $quote_target always;
}
# Shadow only: members get the monolith, GET copies go to claims
location ^~ /claims/status/ {
mirror /_shadow_claims;
proxy_pass http://monolith;
add_header X-Facade-Backend monolith always;
}
location = /_shadow_claims {
internal;
if ($request_method != GET) { return 204; }
proxy_connect_timeout 200ms;
proxy_read_timeout 300ms;
proxy_pass http://claims$request_uri;
}
}Each upstream line names a backend server, and each location block is a rule. nginx first finds the longest matching plain prefix, so /policies/renewal-quote/ beats / for a quote request. The split_clients block hashes the member id header and gives 10 percent of the hash range to the new quote service. Its hash is not the Python sketch's crc32, so a member can land in a different bucket here.
Requests with no member id header all hash the same empty string, so they share one bucket. In our test, all 50 such requests landed on the new service, which is why the map block sends an empty id to the monolith.
Browsers do not send X-Member-Id on their own, so with no header every member stays on the monolith. In a real portal, a layer you trust, such as sign-in, should set it from the member's session and drop any value the client sent. Otherwise anyone can choose a bucket by hand, as our curl check below does with M30. Our quote service received that hand-set id unchanged, so never let a backend treat it as proof of who is asking.
The add_header lines are for you, not for members. With the always flag, nginx adds the header even to error replies, so you can check the routing before the new services exist. Every client sees the header too, so remove those lines, or keep them to an internal port, before members use the facade.
How do you check which backend answered?
Ask for the headers only and look for X-Facade-Backend. These are the lines we got back.
curl -s -o /dev/null -D - localhost:8080/payments/history | grep X-Facade
# X-Facade-Backend: monolith
curl -s -o /dev/null -D - localhost:8080/documents/receipt-7.pdf | grep X-Facade
# X-Facade-Backend: documents
curl -s -o /dev/null -D - -H "X-Member-Id: M30" localhost:8080/policies/renewal-quote/ | grep X-Facade
# X-Facade-Backend: quotesThe same check is your rollback test. Delete the 10% quotes line so only the * line is left, run nginx -t, reload, and run the last command again. It must say monolith for every member id.
Do not write 0% instead. nginx 1.24.0 rejects it with "invalid percent value", the reload fails, and nginx keeps running the old config. In our test, 19 of 200 members still reached the new service afterwards.
Why a moved route can lose some of its paths
Pause and predict. The config still has an old rule for any path ending in .pdf. Without the ^~ in front of /documents/, which backend answers /documents/receipt-7.pdf?
The old rule wins. nginx remembers the longest prefix match, then checks regular expression rules (pattern-matching rules like the .pdf one) in file order, and the first regex match is used. The ^~ modifier tells nginx to skip the regex check when that prefix is the longest match. As of 19 October 2026, the docs add predicate locations (rules that match on a variable) from nginx 1.31.5, checked after the regex rules.
We removed the ^~ and ran the check again. The answer became X-Facade-Backend: monolith-pdf, so receipts quietly went back to the old code. Put ^~ on every moved prefix, or the move is only partly real. The canary and shadow routes have it too: without it, /claims/status/55.pdf skipped the shadow.
How does nginx shadow traffic?
The mirror directive sends a background copy of each request to another location, and nginx ignores the copy's response. So nginx does not compare answers for you. The new service, or a job reading both logs, has to do the comparing.
By default the mirror also copies the request body. We sent a POST to a mirrored route without the GET check, and the shadow service received the POST and its body. A copied write would repeat a claim withdrawal or a payment.
The one line "if ($request_method != GET) { return 204; }" inside the mirror location fixes it. With that line, our POST reached the monolith only, and the shadow service received nothing.
Can a slow shadow service slow down real users?
Yes, on a kept-open connection. In our nginx 1.24.0 test over HTTP/1.1, a slow shadow did not delay the reply it copied. It delayed the next request the same client sent on that connection.
We made the shadow service wait 2 seconds before answering. The first reply arrived in 0.008 s. A second request on the same keep-alive connection, one that stays open for several requests, took 2.001 s. With a 3-second shadow, it took 3.004 s.
Requests on new connections were not delayed. So clients that keep connections open, which HTTP/1.1 does by default, are the ones who feel it. We tested HTTP/1.1 only, not HTTP/2.
The mirror module page does not describe this, as of 19 October 2026. Treat it as behaviour we measured on one version, pin your version, and re-test after upgrades.
The cap is a short timeout on the mirror location. With proxy_connect_timeout 200ms and proxy_read_timeout 300ms, the second request took 0.27 to 0.30 s instead of 2 s. nginx logged each cut-off copy as an upstream timeout.
| Test | Result | What it means for the facade |
|---|---|---|
| PDF path under /documents/ with a plain prefix rule and an old .pdf regex rule | The regex rule answered | A moved route can silently lose some of its paths |
| Same path with ^~ on the /documents/ rule | The documents service answered | Use ^~ on every moved prefix |
| 2,000 member ids sent to a 10 percent split | 219 went to the new service (11.0 percent) | Shares are close to the target, not exact |
| One member id sent 5 times | The same backend all 5 times | Members do not flip between old and new |
| 50 requests with no member id header, before the map block | All 50 went to the new service | Anonymous traffic all shares one bucket |
| Rollback by writing 0% in split_clients | nginx -t failed: invalid percent value; the old split kept running | Delete the line instead, and always run nginx -t |
| POST sent to a mirrored route, default settings | The shadow service received the POST and its body | A mirror repeats writes unless you stop it |
| Same POST with the GET-only check in the mirror location | The shadow service received nothing | The one-line filter works |
| Shadow service delayed by 2 seconds, two requests on one connection | First reply in 0.008 s, second in 2.001 s | A slow shadow delays the next request on that connection |
| Same, with a 300 ms read timeout on the mirror location | Second reply in 0.27 to 0.30 s | A short timeout caps the damage |
How do you check that two copies of the data still agree?
Run the same fingerprint query on both databases and compare the two lines it prints. If the row count and the hash match, the copies almost certainly hold the same rows.
SELECT count(*) AS rows,
md5(coalesce(string_agg(p::text, '|' ORDER BY id), '')) AS fingerprint
FROM policy p;We ran it on PostgreSQL 16.15 with two rows in each copy. Both databases printed 2|2fee8835ef7b0caa9e09959ad4fabab2. Then we changed one premium in the copy, and its line became 2|5cd2733417e38ea204db85266ed30696.
The query only works when both tables have the same columns in the same order and types. Both servers must also print values the same way: with DateStyle, PostgreSQL's date display setting, set to SQL, DMY, the same rows gave a different fingerprint. It reads every row and builds one long text value, so on big tables run it per date range or per id range at a quiet hour.
What the change log actually contains
PostgreSQL's logical decoding reads committed changes out of the write-ahead log. It ships a sample plugin called test_decoding that prints them as text. Production tools such as the Debezium PostgreSQL connector build on the same mechanism.
One insert statement with two rows, then one update, gave three change lines in our test. The plugin also prints a BEGIN and a COMMIT line around each transaction, with a number that changes per run, so we left those four out. Each change is one row, which is why CDC events map so cleanly onto a copy.
table public.policy: INSERT: id[integer]:1 member_id[text]:'M1' premium_paise[bigint]:1250000 renewal_due[date]:'2026-11-01'
table public.policy: INSERT: id[integer]:2 member_id[text]:'M2' premium_paise[bigint]:980000 renewal_due[date]:'2026-12-15'
table public.policy: UPDATE: id[integer]:1 member_id[text]:'M1' premium_paise[bigint]:1300000 renewal_due[date]:'2026-11-01'The same server also showed why a dual write across two databases needs more than a transaction. A query in the portal database that named the quotes database failed with "cross-database references are not implemented".
How do you explain the strangler fig pattern in a system design interview?
Explain it as a facade plus a repeatable process for each route. Spend most of your time on data and rollback, because that is where real migrations get hard.
Describe the pain
One old system, slow releases, and one change breaking unrelated features. Say why a big-bang rewrite would be risky.
Draw the facade
Put a routing layer in front of the monolith. Paths with no rule go to the monolith by default.
Choose the first route
Pick something read-heavy and loosely coupled, like document downloads, and say why its failure would be cheap.
Walk one route through
Build it, shadow only its reads, compare answers, send 5 percent of users, raise to 100, then delete the old code.
Handle the data
Name one owner per table. Use a backfill, change data capture and a compare job while two copies exist.
Plan the finish
Explain how you avoid stalling: dated plans for each route, and a rule that done means the old code is deleted.
Questions people ask
- Why is it called the strangler fig pattern?
- It is named after strangler fig plants, which Martin Fowler saw in the rainforests of Queensland, Australia, in 2001. The fig starts on a host tree, grows roots down to the soil and can outlive the host. New code grows around an old system in the same way, until the old system can be removed.
- Is the strangler fig pattern only for moving to microservices?
- No. The new parts can be one new application, a cleaner modular monolith or a handful of services. The pattern is about replacing an old system gradually behind a facade. It says nothing about how many pieces you end up with.
- How long does a strangler fig migration take?
- There is no standard length. It depends on how big the system is, how tangled its data is and how many routes you move. The difference from a rewrite is that value arrives along the way. Each route goes live on its own, so members see improvements long before the last route moves.
- What is shadow traffic?
- Shadow traffic is a copy of real requests sent to the new system while users still get answers from the old one. You compare both answers and log every difference. Copy only read requests, because copying writes would repeat real actions such as payments. In nginx, the mirror module copies writes by default.
- Does nginx compare the shadow answers for you?
- No. The nginx mirror module sends the copy in the background and ignores its response. Comparing has to happen elsewhere, for example in the new service, which can call the old one, or in a job that reads both services' logs and reports differences.
- Can an API gateway act as the strangler facade?
- Yes, often it can. An API gateway is a server that receives all API calls and forwards them to the right backend, so it already routes by path. Keep the strangler rules in it simple and temporary, and remove each rule once its route has fully moved.
- What is the difference between strangler fig and branch by abstraction?
- Strangler fig replaces a system from the outside, by routing requests at the network edge. Branch by abstraction replaces a module from the inside, by putting an interface in front of it within one codebase. Both build the new part beside the old one and switch over gradually, and you can use both in one migration.
Sources and further reading
- Strangler Fig Application (Martin Fowler, updated 22 August 2024)
- Patterns of Legacy Displacement (Ian Cartwright, Rob Horn and James Lewis, 2021 to 2024)
- Refactoring Legacy Code with the Strangler Fig Pattern (Shopify Engineering, March 2020)
- Branch By Abstraction (Martin Fowler, 2014)
- Legacy Seam (Martin Fowler, 2024)
- Canary Release (Danilo Sato, 2014)
- Bounded Context (Martin Fowler, 2014)
- Conway's Law (Martin Fowler, 2022)
- nginx documentation: the location directive in the core HTTP module
- nginx documentation: the split_clients module
- nginx documentation: the mirror module
- nginx documentation: the map module
- nginx beginner's guide: what happens when a reload fails
- nginx documentation: add_header in the headers module
- RFC 9112, HTTP/1.1, section 9.3: persistent connections
- Python documentation: PYTHONHASHSEED and hash randomisation
- PostgreSQL 16 documentation: test_decoding
- PostgreSQL documentation: PREPARE TRANSACTION
- Debezium documentation: PostgreSQL connector