What the strangler fig pattern is and why the proxy decides the outcome

The strangler fig pattern replaces a monolith one endpoint at a time. A routing facade sits in front of the old system and sends each request either to the monolith or to a new service. Over time, more routes point to new services until the monolith handles nothing and can be switched off. The name comes from a vine that grows around a host tree and eventually stands without it.

Teams choose this over a rewrite because a rewrite freezes delivery and has no partial credit: nothing is proven until the final cutover, when every behavioral difference surfaces at once. Incremental replacement ships value continuously. Each extracted endpoint is validated against real traffic, and if it fails, the blast radius is that endpoint.

                 +----------------------------+
 clients ------> |  proxy (versioned config)  |
                 +----------------------------+
                   |  default route   |  specific routes
                   v                  v
             +-----------+     +--------------+
             | monolith  |     | new services |
             +-----------+     +--------------+
   metrics: backend=monolith   backend=orders-svc, ...

This article's position is that the proxy decides whether the migration succeeds. Treat it as an owned, versioned system, not a one-time config edit. Every routing rule lives in source control, gets reviewed, and can be reverted on its own. Tag every metric with the backend that served the request, so you can compare the monolith and the new service on the same endpoint.

The pattern fits backend and platform teams whose monolith is live, earns revenue, and cannot take a migration outage.

  +---------+   +---------+   +---------+
  | web app |   | mobile  |   | partner |
  +---------+   +---------+   +---------+
       |             |             |
       +-------------+-------------+
                     |
                     v
  +--------------------------------------------+     +-----------------+
  |  routing proxy (owned, versioned)          |<----| git: route rules|
  |  /orders/*    -> orders-svc                |     | review / revert |
  |  /invoices/*  -> billing-svc               |     +-----------------+
  |  /*  default  -> monolith                  |
  |  one rule per endpoint; revert each alone  |
  +--------------------------------------------+
        |                |                 |
        | default        | /orders/*       | /invoices/*
        v                v                 v
  +-----------+    +------------+   +-------------+
  | monolith  |    | orders-svc |   | billing-svc |
  +-----------+    +------------+   +-------------+
        |                |                 |
        +----------------+--------+--------+
                                  |
                                  v
  +--------------------------------------------+
  | metrics per request, tagged by backend:    |
  |   backend=monolith|orders-svc|billing-svc  |
  | compare same endpoint across backends      |
  +--------------------------------------------+

Clients reach a single versioned proxy whose per-endpoint rules live in source control, route extracted paths to new services and everything else to the monolith by default, and emit metrics tagged with the backend that served each request.

When to use it and when to avoid it

Conditions that favor it

The pattern works when the monolith's functionality is reachable over HTTP at stable paths. The proxy can only redirect what it can address, so a clean URL space such as /orders/* or /invoices/{id} gives you natural seams. It also fits when the monolith is too large or too poorly understood to rewrite safely. A full rewrite has to reproduce years of undocumented behavior before it can ship anything. A strangler migration replaces one endpoint at a time and gets real traffic as feedback at every step. The third condition is that the business cannot pause feature work. Because the monolith keeps serving everything you haven't moved, teams can keep shipping on both sides of the boundary.

Conditions against it

Skip it for a small codebase. A focused rewrite or an in-place refactor costs less than running a routing tier. Avoid it if no team will own the proxy as production infrastructure. Once the proxy sits on every request, an outage there takes down the whole product. It also fails when business logic doesn't separate at the request boundary, for example when one endpoint triggers in-process side effects across many modules. A heavily shared database with no plan to split ownership is another warning sign. Routing traffic to a new service that still writes the monolith's tables moves the coupling without removing it.

Define the end state

Many migrations stall into a permanent hybrid of a half-extracted monolith plus a proxy nobody wants to retire. Write down what "done" means before you start. Name the components that may stay in the monolith indefinitely, and decide whether the proxy is temporary scaffolding or a permanent gateway.

Step 1: Put a pass-through proxy in front of the monolith and build an endpoint inventory

Deploy the proxy with exactly one rule: send everything to the monolith. Nothing moves yet. Run it this way long enough to see normal weekly peaks, and compare status-code rates and latency percentiles at the proxy against the monolith's own metrics. If they disagree, fix that before trusting the proxy with a real cutover. This phase also forces you to settle header forwarding, client IP preservation, timeouts and body size limits while the stakes are low.

The proxy adds a network hop, a new failure point and a component someone must own and patch. Run at least two instances behind health checks, keep its configuration in version control, and give it an on-call owner. If it goes down, the whole application goes down with it.

Build the endpoint inventory from the proxy's access logs. Use awk to extract method and path, and use sed to collapse numeric IDs and UUIDs into placeholders such as /orders/{id}. Then use sort | uniq -c | sort -rn to rank routes by volume. Add the remaining columns, owning code module and owning tables, by reading the monolith's router and data access layer. Logs cannot tell you these.

Criterion Good first candidate Poor first candidate
Coupling Calls few internal modules Shares session or transaction state
Data ownership Writes tables nothing else writes Joins across many domains
Traffic Steady, measurable volume Near zero, or the checkout path

A route with steady traffic gives you evidence that the extraction worked. A route with almost no traffic proves little, and the busiest route carries too much risk for a first attempt.

#!/usr/bin/env bash
# Build an endpoint inventory from the proxy's access logs (nginx/Apache
# combined format). Usage: ./inventory.sh access.log [more.log ...] > inventory.tsv
set -euo pipefail

# Field 2 when splitting on double quotes is the request line,
# e.g. GET /orders/123?expand=items HTTP/1.1. Keep only method and path.
awk -F'"' '{
  split($2, req, " ")
  if (req[1] != "" && req[2] != "") print req[1], req[2]
}' "$@" \
  | sed -E 's/\?[^ ]*$//' \
  | sed -E ':a; s#/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(/|$)#/{uuid}\1#; ta' \
  | sed -E ':b; s#/[0-9]+(/|$)#/{id}\1#; tb' \
  | sort | uniq -c | sort -rn \
  | awk 'BEGIN { OFS = "\t"; print "requests", "method", "route", "owning_module", "owning_tables" }
         { print $1, $2, $3, "", "" }'

# Line by line:
# - The first sed strips query strings so /search?q=a and /search?q=b count as one route.
# - The second sed collapses UUID segments into {uuid}. The :a/ta loop repeats the
#   substitution because adjacent ID segments share a slash. Without the loop, g would
#   skip the second segment.
# - The third sed does the same for numeric IDs, so /orders/123/items/9
#   becomes /orders/{id}/items/{id}.
# - The awk at the end adds the inventory columns.
#
# owning_module and owning_tables are left blank on purpose. Fill them in by reading
# the monolith's router and data access layer. Access logs cannot tell you these.

A shell pipeline that turns proxy access logs into a ranked method-and-route inventory, collapsing IDs and UUIDs into placeholders and leaving the code and table ownership columns to be filled in by hand.

Step 2: Write routing rules and keep behavior identical across the boundary

With a candidate route chosen, express it as a rule. In NGINX, give each extracted endpoint its own location block: an exact match (location =) or a specific prefix that forwards to the new service's upstream. Select by method with a map on $request_method, so GET /orders/{id} moves while POST /orders stays put. Use a map on a header value to send only internal or canary traffic. NGINX picks the longest matching prefix regardless of file order, but regex locations are evaluated in sequence, so keep regexes rare and list them from most to least specific. location / remains the catch-all and always points at the monolith. Anything you have not deliberately moved keeps working.

Store the config in the same repository discipline as application code. Every change goes through review. CI runs nginx -t before merge. Each rule carries a comment naming the owning team and the ticket that justified it, so an engineer handling an incident at 3 a.m. knows whom to call.

The boundary must be invisible to clients:

  • Auth and sessions: The new service must accept the same session cookies and tokens, with the same expiry and refresh behavior.
  • Tracing: Forward or generate X-Request-ID and pass traceparent unchanged, so one request produces one trace across both backends.
  • Errors: Match the monolith's error body schema and status codes exactly. A 404 must not become a 400.
  • Caching: Reproduce Cache-Control, ETag and Vary. A missing Vary header can serve one user's response to another.
# http {} context. Owner per rule; CI runs `nginx -t` before merge.
upstream monolith  { server monolith.internal:8080; }
upstream orders_svc { server orders-svc.internal:9000; }

# RULE orders-read | owner: team-orders | ticket: ORD-142
# Only GET with the canary header moves; POST /orders and all else stay.
map "$request_method:$http_x_canary" $orders_target {
    "GET:1"  orders_svc;
    default  monolith;
}

# Keep the client's X-Request-ID, or mint one if absent.
map $http_x_request_id $req_id {
    ""       $request_id;
    default  $http_x_request_id;
}

server {
    listen 80;
    server_name api.example.com;

    # Same headers to both backends. Cookie, Authorization and
    # traceparent are forwarded unchanged by default; do not strip them.
    proxy_set_header Host              $host;
    proxy_set_header X-Request-ID      $req_id;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    # Return backend status codes and error bodies as-is, and do not
    # hide Cache-Control, ETag or Vary from either backend.
    proxy_intercept_errors off;

    # RULE orders-read: only GET /orders/{numeric id}, not /orders.
    location ~ ^/orders/[0-9]+$ {
        proxy_pass
    }

    # Catch-all: anything not deliberately moved stays on the monolith.
    location / {
        proxy_pass
    }
}

An NGINX config that sends only canary-flagged GET /orders/{id} requests to the new service, keeps the catch-all and every other route on the monolith, forwards or generates X-Request-ID, and passes auth, tracing, error and caching headers through unchanged.

Step 3: Cut over progressively with shadowing, splits, and one-rule rollback

Once a route behaves identically on paper, prove it with traffic. There are three ways to do that, and each fits a different stage.

Technique What it proves Risk to users Constraint
Shadow (mirrored) traffic New service matches the monolith on real requests None; responses are discarded Read-only or idempotent endpoints only, unless the shadow target is isolated
Header- or cookie-based routing End-to-end behavior for internal users Limited to staff Needs a reliable way to tag internal sessions
Percentage split Behavior and capacity under production load Proportional to weight Requires sticky routing when sessions matter

Shadowing a POST /orders against a service wired to the shared database or payment provider creates duplicate side effects. Mirror writes only to an isolated target with its own datastore and stubbed downstream calls. When you diff the responses, normalize them first. Strip timestamps, request IDs and generated UUIDs, sort arrays whose order is not part of the contract, and compare numbers with a tolerance. If you skip normalization, the diff flags thousands of meaningless mismatches and hides the one that matters.

Tag every metric and log line with the backend that served the request, for example backend=monolith or backend=orders-svc. With that label, error rate and p99 latency for the same route sit side by side on one dashboard.

Rollback should be a single rule change: set the route's weight back to 0%. Define per-route triggers in advance, such as error rate or p99 latency rising above the monolith's baseline for that route. Rehearse the revert before the first split. If nobody has run it, it is not a rollback plan.

# Percentage split, hashed per client so a client stays on one backend.
# Rollback is this one rule: change 10% to 0% and run `nginx -s reload`.
split_clients "${remote_addr}${http_user_agent}" $orders_split {
    10%  orders_svc;
    *    monolith;
}

# Internal users with canary=1 always reach the new service.
map $cookie_canary $orders_backend {
    "1"      orders_svc;
    default  $orders_split;
}

upstream monolith      { server monolith.internal:8080; }
upstream orders_svc    { server orders-svc.internal:8080; }
# Isolated shadow: own datastore, stubbed payment and email calls.
upstream orders_shadow { server orders-shadow.internal:8080; }

# Tag every log line with the serving backend so routes compare directly.
log_format routed '$remote_addr "$request" $status $request_time '
                  'backend=$proxy_host';

server {
    listen 80;
    access_log /var/log/nginx/access.log routed;

    # Read-only endpoint: the user gets the monolith's response,
    # and a copy goes to the shadow. Mirror responses are discarded.
    location = /orders/search {
        mirror /_shadow;
        proxy_pass
    }
    location = /_shadow {
        internal;
        proxy_pass
    }

    # All other order routes follow the cookie override, then the split.
    location /orders {
        proxy_pass
    }
}

An nginx config that mirrors a read-only route to an isolated shadow, routes cookie-tagged internal users to the new service, sends 10% of other clients there, and logs which backend served each request, with rollback done by setting the split back to 0%.

Handling data ownership during extraction

Routing moves requests. It does not move data, and data is where most extractions stall. Decide up front which side owns writes for each entity. Until cutover, the monolith remains the source of truth. The new service reads a replica of that data and does not accept authoritative writes.

Keeping data in sync

Change data capture (CDC) streams committed changes from the monolith's database into the service's store. Because it reads from the transaction log, it reflects only writes that actually committed. Dual reads compare both stores at request time and log mismatches before you trust the new one.

Avoid dual writes from application code. If the second write fails or times out after the first commits, the two stores diverge. No transaction spans both stores, so nothing rolls back the first write.

Two traps that look like progress

A shared database makes extraction look easy because the new service just queries the same tables. Every schema change still requires coordination across both codebases, so the service remains coupled to the monolith. Similarly, a new service that makes chatty calls back into the monolith for every request becomes a distributed monolith. It adds network hops and failure modes but no independence.

Retiring the old path

After cutover, watch per-route metrics until the old handler shows zero traffic across a full business cycle, including batch jobs and month-end runs. Then delete the handler, its tests, and its routing rule in the same change. Track every routing exception with an owner and an expiry date so temporary carve-outs don't accumulate.

Alternatives and the impact on cost and engineering time

The strangler fig is not the only way off a monolith. It competes with three other approaches, and each one trades risk against speed differently.

Approach Risk Time to first value Rollback
Full rewrite High: behavior gaps surface at launch Long: nothing ships until parity Poor: reverting means re-launching the old system
Strangler fig Low per endpoint Short: the first extracted route ships alone One routing rule, no redeploy
Branch by abstraction Low to medium Short, but stays inside the monolith Feature flag or redeploy
Modularize, no extraction Low Medium: internal benefits only Ordinary code revert

The strangler fig has three ongoing costs:

  • Proxy operation. The proxy is another hop to run, monitor and keep highly available.
  • Doubled maintenance. While a route is shadowed or split, a bug fix lands twice.
  • Diff noise. Engineers spend time investigating response differences that turn out to be harmless, such as timestamps, key ordering or float formatting.

The savings come from the shape of the work. Cutovers are small, so each mistake is contained to one route. Production traffic exposes behavior gaps within days rather than at a big-bang launch. Rollback is a config change, not a deploy and incident call.

Illustrative estimate (assumed numbers, not a benchmark)

Overhead per endpoint = (overlap weeks × dual-maintenance hours/week) + diff-triage hours

Assume 3 weeks × 4 h + 10 h = 22 h. Across 40 endpoints that is 880 h.

Compare that with the cost of one failed big-bang launch: rollback effort plus the rework of every gap found at once. If you expect even two such events, each costing several hundred hours, the incremental overhead pays for itself. Plug in your own overlap and triage figures to see where that crossover falls for your system.

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 strangler fig pattern 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 .env by 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.