What the strangler fig pattern is and how it works
The strangler fig pattern takes its name from a vine that grows around a host tree until it can stand on its own. Applied to software, you place an interception layer in front of the monolith and route every request through it. That layer can be a reverse proxy, an API gateway, or an in-process router inside the monolith. At first it forwards everything unchanged. Then you build one capability as a separate service and point the matching routes at it, while all other traffic still goes to the monolith.
You repeat this one capability at a time. The monolith handles less and less, and once nothing routes to it, you can retire it.
The rule that makes this work is that every step must be deployable and reversible on its own. Each route change is a configuration change in the interception layer. If the new service misbehaves, you point the route back at the monolith, which still has the old code path. Production traffic tests each extraction, and a failure costs you one rollback.
A planned rewrite works differently. You design a target architecture, build it alongside the old system, and switch over in one cutover. Until that cutover, the new system delivers no value. Meanwhile the monolith keeps changing, so the rewrite is chasing a moving target, and you only find gaps in your understanding of its behaviour when everything goes live at once.
The strangler fig approach replaces that single high-stakes event with many small ones. Each step is small enough to understand, observe and undo, and each one moves you toward independently deployable services.
+-----------+
| Clients |
+-----+-----+
|
v
+-----------------------+-----------------------+
| Interception layer |
| reverse proxy / API gateway / in-proc router |
| |
| route table, one config change per step: |
| /orders/* -> Orders service |
| /billing/* -> Billing service |
| /* -> Monolith (default) |
+------+----------------+---------------------+-+
| | |
v v v
+-------------+ +-------------+ +-----------------------+
| Orders | | Billing | | Monolith |
| service | | service | | shrinking; keeps |
| (extracted) | | (extracted) | | old code paths for |
+-------------+ +-------------+ | rollback until |
| retired |
+-----------------------+
Rollback: point /billing/* back to Monolith (one config change).
Retire: when no route targets Monolith, remove it.
All requests pass through an interception layer whose route table sends each extracted capability to its new service and everything else to the monolith, so each step is a config change you can reverse until no route reaches the monolith and it can be retired.
Should you migrate at all? Who it suits and when to avoid it
Lewis and Fowler describe microservices as services "built around business capabilities and independently deployable by fully automated deployment machinery." That definition tells you what the architecture actually fixes. It lets you deploy one capability without releasing everything else, lets teams own a capability from end to end, and lets you scale each part separately. If none of these is a real problem for you, migrating gives you no benefit and still costs you the overhead of a distributed system.
Who benefits
The case is strongest when several teams wait on a single shared release train, or when a merge conflict in one module delays work in another. It also helps when some parts of the system need different scaling. One example is a CPU-heavy pricing engine running in the same process as a mostly idle admin UI.
When to avoid it
Avoid it if you have a single small team. There's no coordination cost to remove, but you would still take on network failures and distributed debugging. Avoid it if the domain boundaries are still changing, because a seam drawn in the wrong place costs much more to move between services than between modules. Avoid it if you can't yet build, test and deploy the monolith automatically, because many services will multiply that problem.
Team structure has to change too. Moving services to new hosts while every change still needs sign-off from every team gives you a distributed monolith.
| Option | What it fixes | Main risk |
|---|---|---|
| Strangler fig | Deployment, autonomy, scaling, one step at a time | Running two systems side by side for a long time |
| Modular monolith | Boundaries and code ownership | Still a single deploy and a single scaling unit |
| Full rewrite | Everything, in theory | Chasing a moving target, and a big-bang cutover |
| Leave it | Nothing | Delivery keeps slowing as coupling grows |
Choosing the first seam and the operational prerequisites
The first extraction sets the pattern for every one that follows, so pick it for low risk and clear ownership rather than for business importance. A good candidate:
- Has a clear business boundary. It maps to a capability the business already names, such as notifications, invoicing, or search indexing.
- Is loosely coupled to the core data model. It reads a few tables and writes to fewer still.
- Changes at a steady rate. Frequent enough that independent deploys pay off. Stable enough that you are not extracting a moving target.
- Has a team ready to run it in production, including on-call.
| Candidate | Good first seam? | Why |
|---|---|---|
| Notification sending | Yes | Narrow interface, few writes, easy to verify |
| Customer or order core | No | Every module reads and writes it, so extraction touches everything |
| "Data access layer" | No | A technical layer, not a capability. Every feature still spans every service |
| One service per entity | No | Too fine-grained. Chatty calls and lockstep deploys produce a distributed monolith |
Starting with the most central, most shared domain means solving the hardest data-ownership problem before you have any tooling or experience. Cutting along business capabilities, not technical layers, is what keeps services independently deployable (Lewis and Fowler).
Prerequisites per service
Before a service takes real traffic, it needs:
- An automated build and deploy pipeline. Manual releases do not scale past two or three services.
- Logs and metrics tagged with a correlation ID that is propagated from the proxy onward.
- Distributed tracing across the proxy, the monolith, and the new service. Without it, a latency regression has no obvious home.
- A named owning team.
Infrastructure automation comes before service count. Each extraction should make the next one cheaper, not add another hand-maintained system.
Building the interception layer and shifting traffic gradually
Deploy the routing layer first, while it still sends every request to the monolith. Its first job is to prove it adds no errors and negligible latency. Extracting a service and introducing a new hop in the same release leaves you with two unknowns when something breaks. Shipping the router alone isolates the risk.
Once it is stable, choose a routing key that matches the seam:
- Path (for example
/invoices/*) for capability-aligned extractions. - Header for internal testers or specific client versions.
- Tenant when you want blast radius measured in customers rather than requests.
Move traffic in stages:
- Shadow. Mirror requests to the new service and discard its responses. The user only ever sees the monolith's answer. Shadow only reads or idempotent calls, because a mirrored write duplicates side effects.
- Small percentage. Use a weighted or hash-based split. Hash on a stable key such as user or tenant ID so each caller gets consistent behaviour across requests.
- Full. Keep the monolith upstream defined and warm. Rollback should be a config reload, not a deploy.
In the accompanying config, a prefix match selects the extracted path and an upstream pool holds the monolith as a fallback target. A weighted split assigns the percentage, and a mirror directive handles shadowing.
Treat the router as tier-zero infrastructure. Every request crosses it, so run it with at least two instances. Give it an explicit latency budget that the team tracks, and assign an owning team that approves route changes. Otherwise each service team edits routing rules and nobody holds the whole map.
# http {} context. Every route keeps a path back to the monolith.
upstream monolith {
server monolith-1.internal:8080;
server monolith-2.internal:8080;
}
# Extracted service. The backup server is used only when the primary fails.
upstream invoices_svc {
server invoices-1.internal:9000;
server monolith-1.internal:8080 backup;
}
upstream reports_svc {
server reports-1.internal:9100;
}
# Hash on tenant ID so each tenant always lands on the same side of the split.
split_clients "${http_x_tenant_id}" $invoices_target {
5% invoices_svc;
* monolith;
}
server {
listen 80;
# Stage 2: path-based route with a 5% tenant-hashed split.
location /invoices/ {
proxy_pass
proxy_next_upstream error timeout; # retry on failure; falls to backup
proxy_connect_timeout 1s; # keep the router inside its latency budget
}
# Stage 1: shadow. Users get the monolith response; the mirror reply is discarded.
# Read-only path, so duplicated requests have no side effects.
location /reports/ {
mirror /_shadow_reports;
proxy_pass
}
location = /_shadow_reports {
internal;
proxy_pass
}
# Default: everything not yet extracted stays on the monolith.
location / {
proxy_pass
}
}
An nginx config that keeps the monolith as the default and backup upstream, shadows a read-only path with mirror, and sends 5% of tenants to an extracted service via a split_clients hash, so rollback is a config reload.
Decomposing the data: ownership, outbox and change data capture
Routing moves requests; it does not move state. The service is not independent until it owns its data, and that is usually most of the migration effort.
Establish ownership before moving storage
List every table the capability reads and writes, then find every other module that writes to those tables. Each of those writers is an undeclared dependency. Route those writes through the new service's API or through events the service consumes. Split storage only after nothing outside the service writes to its tables. If you split earlier, the old writers still need the data, and you end up synchronizing two copies with no clear owner.
Avoid ad hoc dual writes
Writing to the database and then publishing to a broker in the same request handler fails in a specific way. If the process crashes between the two calls, one side has the change and the other never will, and nothing records that they diverged. A transactional outbox fixes this. The service inserts an outbox row containing id, aggregate_id, event_type, payload and created_at in the same local transaction as the business change. A relay then polls for unpublished rows, publishes each one, and marks it sent. Delivery is at least once, so consumers must deduplicate on id. Change data capture reaches the same result by tailing the database's transaction log instead of polling a table.
Give up cross-service ACID
Each service now commits separately, so consistency becomes eventual. A workflow that spans several services becomes a saga, a sequence of local transactions where each step has a compensating action, such as "refund payment" for "charge payment". Compensations are business operations that you have to design and test. They are not rollbacks.
Choose boundaries carefully. Once data is split, merging two services back together means another migration, along with backfills and reconciliation work.
-- PostgreSQL. The outbox lives in the service's own database, next to its business tables.
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL until the relay has published the row
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
-- Write path: the business change and its event commit or roll back together.
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 'ord-1042';
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES ('6f1c2a4e-8b3d-4c1a-9e2f-0a7b5d3c9e11', 'ord-1042', 'OrderPaid',
'{"orderId": "ord-1042", "amountCents": 4999}');
COMMIT;
-- Relay (a worker loop): claim a batch; SKIP LOCKED lets several relays run without blocking each other.
BEGIN;
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- The worker publishes each claimed row to the broker here, using id as the message id.
-- If it crashes after publishing but before COMMIT, the rows are sent again,
-- so delivery is at least once and consumers must deduplicate on id.
UPDATE outbox SET published_at = now()
WHERE id = ANY('{6f1c2a4e-8b3d-4c1a-9e2f-0a7b5d3c9e11}'::uuid[]);
COMMIT;
A PostgreSQL transactional outbox: the order update and its OrderPaid event commit in one local transaction, and a polling relay claims unpublished rows, publishes them, and marks them sent with at-least-once delivery.
Handling cross-boundary calls and verifying parity before cutover
Until the split is complete, the new service will still need data or behaviour that lives in the monolith. Route those calls through an anti-corruption layer. This is a translator that maps the monolith's tables, DTOs and status codes into the service's own domain types. Without it, legacy field names and quirks leak into the new code, and you end up rebuilding the monolith in miniature.
Every synchronous call across the boundary needs three protections:
- A timeout shorter than the caller's own deadline.
- Retries only for idempotent operations, using exponential backoff with jitter so clients don't retry in lockstep and overload a recovering dependency.
- A circuit breaker that fails fast while the dependency is unhealthy.
These matter because call chains compound. Availability multiplies along the chain, latency adds up, and the slowest hop dominates the tail. As an illustrative estimate, four services in series at 99.9% each give roughly 99.6% for the whole request.
Verifying parity
Send identical requests to the old and new paths and diff the responses. Ignore fields that are expected to differ, such as timestamps and generated IDs. Before each traffic shift, write down the gates: mismatch rate, error rate and p99 latency, each measured against the monolith baseline. Keep the old path deployable and routable until the new one has carried full production load through a peak period. Some defects only appear at volume.
Never shadow writes that have external side effects. Duplicated traffic sends duplicate emails and charges cards twice. Point shadowed calls at sandboxed providers, or attach idempotency keys so the downstream system discards repeats.
import json, random, time, urllib.request
class CircuitOpen(Exception): pass
class Breaker:
"""Opens after N consecutive failures; allows a trial call after reset_after seconds."""
def __init__(self, threshold=5, reset_after=30.0):
self.threshold, self.reset_after = threshold, reset_after
self.failures, self.opened_at = 0, None
def call(self, fn):
if self.opened_at and time.monotonic() - self.opened_at < self.reset_after:
raise CircuitOpen("monolith unhealthy; failing fast")
try:
result = fn()
except Exception:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.monotonic() # (re)open the circuit
raise
self.failures, self.opened_at = 0, None # success closes it
return result
breaker = Breaker()
def _get(url):
# 2s timeout is deliberately shorter than the caller's own deadline.
with urllib.request.urlopen(url, timeout=2) as resp:
return json.load(resp)
def fetch_customer(customer_id, attempts=3, base=0.1, cap=1.0):
url = f""
for attempt in range(attempts): # GET is idempotent, so retrying is safe
try:
raw = breaker.call(lambda: _get(url))
break
except CircuitOpen:
raise # don't retry into an open circuit
except Exception:
if attempt == attempts - 1:
raise
# Full jitter: random sleep in [0, min(cap, base * 2^attempt)] avoids lockstep retries.
time.sleep(random.uniform(0, min(cap, base * 2 ** attempt)))
# Anti-corruption layer: legacy column names and status codes stop here.
return {"id": raw["CUST_ID"], "email": raw["EMAIL_ADDR"], "active": raw["STAT_CD"] == "A"}
A minimal standard-library client for calling the monolith from a new service: it applies a timeout, retries an idempotent GET with exponential backoff and full jitter, fails fast through a simple circuit breaker, and translates the legacy payload into the service's own domain shape.
Impact on cost and engineering time, and knowing when to stop
Extraction makes a system more expensive to run before it makes it cheaper to change. Each new service brings its own deployable, pipeline, dashboards and on-call rotation, and turns in-process calls into network hops that can fail partway through. The savings arrive later and depend on what you chose to extract.
Illustrative estimate (assumed numbers, not a benchmark). Net hours/week = coordination saved − (services × upkeep per service). Assume a coordinated monolith release ties up 8 engineers for 2 hours, three times a month. That is 8 × 2 × 3 = 48 hours a month, or about 11 hours a week. Assume 3 extracted services at 3 hours of upkeep each per week. Net = 11 − (3 × 3) = 2 hours a week. That margin is thin, so extract only the modules that actually block teams.
| Cost added | Time saved |
|---|---|
| More deployables and pipelines | Reversible routing replaces big-bang rollback |
| More on-call surfaces | Independent deploys cut release coordination |
| Network latency and partial failure | Only bottlenecked modules get extracted |
Finishing each extraction
An extraction is done when the monolith's dead code, routes and tables are deleted. Until then, both copies drift and someone eventually writes to the wrong one. Keep a short ledger of what still lives in the monolith and who owns it.
The worst outcome is a stalled hybrid: proxy rules, sync jobs and duplicate code paths that nobody removes, so you pay the costs of both architectures. Budget the cleanup as part of each extraction, not as later work. When the remaining core is stable and rarely blocks anyone, stop. A residual monolith is a valid end state.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith to microservices migration, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.