How the strangler fig pattern works and why the proxy matters most

The strangler fig pattern puts an interception layer in front of a legacy monolith. This layer can be a reverse proxy, an API gateway, or an edge router. At first it forwards every request unchanged. Then you move routes to new services one at a time: /invoices goes to the new billing service, and everything else still reaches the monolith. Each move is a routing change, not a deployment of the whole system. When the last route moves, the monolith receives no traffic and can be shut down.

Teams choose this over a big-bang rewrite for three reasons:

  • Incremental risk. A failure affects one slice of traffic, not the whole product.
  • Continuous value. New services reach production within weeks. They do not wait behind a rewrite that has to match years of accumulated behavior before anyone can use it.
  • Reversible steps. If a new service misbehaves, you point the route back at the monolith. That is faster and safer than rolling back a deployment.

All three depend on the proxy, so treat it as a production system in its own right, not migration scaffolding. It will probably run for as long as the migration does, which is often years. It needs redundant instances, its own dashboards and alerts, and a team that is on call for it. It also has costs. Every request takes an extra network hop, which adds latency. And the proxy is a new single point of failure: when it goes down, both the monolith and the new services become unreachable.

                      +-----------+
                      |  Clients  |
                      +-----+-----+
                            |
                            v   extra network hop on every request
  +-------------------------+-------------------------+
  | Interception layer: reverse proxy, API gateway,   |
  | or edge router                                    |
  | - redundant instances (high availability)         |
  | - own dashboards, alerts, on-call team            |
  | - single point of failure: down = all routes down |
  +-----------+---------------------------+-----------+
              |                           |
   /invoices  |                           |  all other routes
              v                           v
  +-----------+-----------+   +-----------+-----------+
  | New billing service   |   | Legacy monolith       |
  | (route moved)         |   | (shrinking)           |
  +-----------------------+   +-----------------------+

  Rollback: point /invoices back at the monolith (a routing change).
  End state: every route moved, monolith gets no traffic, shut it down.

The interception layer routes /invoices to the new billing service and all other traffic to the monolith, so it is a long-lived, highly available system that adds a network hop and is a single point of failure for every route.

When to use it, who it fits, and when to avoid it

The pattern fits best when the monolith already speaks HTTP and its URL space maps cleanly onto business capabilities. If /invoices/* is handled by one controller that reads from a handful of tables, you can carve it out and point the proxy at a new service without touching anything else. Backend and platform teams in this position also need capacity to run two production systems at once: two deploy pipelines, two sets of alerts, two on-call runbooks. That overlap is the price of moving incrementally, so a team that can't absorb it gets little benefit.

When to avoid or delay it

  • Tangled endpoints. If a single request passes through billing, inventory, and user modules that share objects in memory, no route boundary exists to cut along. Refactor inside the monolith first until each endpoint depends on a narrow set of modules. Routing comes after that.
  • Heavy in-process state. Sessions or caches held in the monolith's memory mean a user's next request may need state the new service can't see. Per-request routing is unsafe until that state moves to a shared store or is removed.
  • Small systems. A codebase that a few engineers understand end to end is often cheaper to rewrite or modularize directly. Running a proxy and two systems costs more than the risk you avoid.
  • Short staffing horizons. The parallel-run period can last a long time. If the organization will reassign the team in two quarters, don't start.

The most common failure is a stall. The easy routes move, the hard ones stay, and the hybrid becomes permanent, so you carry the costs of both systems indefinitely. Plan and fund the final routes before you cut the first one.

Choosing the first slice and the routing strategy

The first slice should be boring. Pick a capability with a clear boundary, few calls into the rest of the monolith, and one team that owns it end to end. Read-heavy endpoints such as a product catalogue lookup or an order-history view are good candidates, because a wrong answer is visible and fixable while no state is corrupted. Leave checkout, billing, and other core write workflows for later. The first slice is meant to prove the proxy, the deployment pipeline, and your observability under real traffic. It is not meant to deliver the most business value.

Routing strategies differ in how finely they let you control exposure:

Strategy Routes by Fits when
Path-based URL prefix, e.g. /catalog/* The capability maps cleanly to a set of paths
Host-based Hostname or subdomain A whole surface, such as an API or admin site, moves together
Header or cookie Flag set for internal or opted-in users Staff should exercise the new service before customers do
Percentage split Weighted share of requests Behaviour is verified and you need production load gradually

These strategies combine well. A typical sequence is path routing to scope the slice, a header to send staff traffic first, and then a percentage ramp for everyone else.

Treat the routing config as code. Keep it in version control, change it through review, and run a test in CI that sends representative requests through the rules and asserts which upstream each one reaches. A misordered prefix match can silently send traffic to the wrong system, and the test catches that before deploy.

# routes.py: routing rules live in version control and change through review.
# Rules are evaluated top to bottom; the first match wins, so order matters.
ROUTES = [
    # Staff carrying the opt-in header reach the new catalog service first.
    {"prefix": "/catalog/", "header": ("X-Canary", "staff"), "upstream": "catalog-svc"},
    # Everyone else on /catalog/ stays on the monolith for now.
    {"prefix": "/catalog/", "upstream": "monolith"},
    # Catch-all: anything not yet extracted goes to the legacy system.
    {"prefix": "/", "upstream": "monolith"},
]

def resolve(path, headers=None):
    headers = headers or {}
    for rule in ROUTES:
        if not path.startswith(rule["prefix"]):
            continue
        name, value = rule.get("header", (None, None))
        if name and headers.get(name) != value:
            continue
        return rule["upstream"]
    raise LookupError(f"no route for {path}")

# test_routes.py: runs in CI with pytest before any routing change deploys.
import pytest
from routes import resolve

@pytest.mark.parametrize("path,headers,expected", [
    ("/catalog/items/42", {"X-Canary": "staff"}, "catalog-svc"),
    ("/catalog/items/42", {}, "monolith"),
    ("/checkout/cart", {"X-Canary": "staff"}, "monolith"),  # core workflow untouched
    ("/catalog-legacy/x", {"X-Canary": "staff"}, "monolith"),  # prefix must not over-match
])
def test_request_reaches_expected_upstream(path, headers, expected):
    assert resolve(path, headers) == expected

A version-controlled, ordered routing table that combines path and header rules, plus a parametrized pytest that asserts which upstream each representative request reaches.

Data ownership during an incremental rewrite

Routing moves requests, but data decides whether a slice is truly extracted. For each entity, name exactly one system that is allowed to write it. Every other system reads a copy and accepts that the copy may lag behind. If two systems both believe they own Order, every bug report turns into an argument over which row is correct.

There are three common ways to give the new service access to data the monolith still owns:

Approach How it works Main trade-off
Read the monolith's database New service queries the legacy schema directly Keeps the coupling, so schema changes in the monolith break the new service
Dual writes Application writes to both stores in one request A partial failure leaves the stores diverged, with no single record of which is right
CDC or event-based sync Changes stream from the owner's log or events to the other store Adds replication lag and pipeline infrastructure to operate

Dual writes look cheap, but you cannot make two independent commits atomic without distributed transactions. CDC is usually the safer default because the owner commits once and the copy follows.

Cutting over write ownership

  1. Monolith owns, CDC feeds the new store. The new service serves reads only. To roll back, point reads at the monolith again.
  2. Shadow-compare. The new service computes writes but does not commit them, and you diff its results against the monolith's. To roll back, disable the comparison.
  3. Flip ownership. The new service commits writes, and CDC flows in reverse to keep the monolith's tables current. To roll back, reverse the sync direction and route writes back to the monolith.
  4. Retire the reverse sync only after nothing in the monolith reads those tables. This is the one step you cannot undo, so leave it until last.

Verifying parity and handling cross-cutting concerns at the seam

Before a route moves, run the new service in shadow. The proxy mirrors each request to it, discards its response, and logs a diff against the monolith's answer. Mirroring is only safe for calls with no side effects. A mirrored checkout charges the card twice, and a mirrored signup sends a second welcome email. Mirror only read-only endpoints, or point the shadow service at sandboxed payment, email, and storage backends.

Diffs from live traffic also give you contract tests. Record real request/response pairs, scrub personal data, and replay them in CI against the new service. Before you start, decide which differences are acceptable:

  • Nondeterministic fields such as timestamps, generated IDs, and cache headers are masked or matched by type.
  • Ordering is ignored only where the contract never promised it. Clients often depend on the order of paginated lists.
  • Error codes must match exactly. A client that retries on 503 but not 500 will break if you swap one for the other.

The seam itself

The proxy is the one place where both systems meet, so put shared behavior there:

  • Auth: validate the session once at the proxy and forward a verified identity. This avoids two services parsing the same cookie differently.
  • Errors: use one error body format, so clients never need to know which backend answered.
  • Correlation IDs: generate an ID at the proxy if the request has none, then pass it to the new service and the monolith and include it in every log line. Without it, you can't trace a request that crosses the seam.
  • Timeouts and retries: configure them at the proxy only. If the client, proxy, and service all retry, the number of attempts multiplies and can overload the monolith.

The example that follows shows mirroring and a correlation-ID header.

# http {} context. Requires ngx_http_mirror_module (built in by default).
log_format seam '$remote_addr "$request" $status cid=$correlation_id rt=$request_time';

# Reuse the caller's X-Request-ID if present, otherwise generate one.
map $http_x_request_id $correlation_id {
    default $http_x_request_id;
    ""      $request_id;
}

upstream monolith    { server monolith.internal:8080; }
upstream catalog_new { server catalog-svc.internal:9000; }

server {
    listen 80;
    access_log /var/log/nginx/seam.log seam;

    # Timeouts live here only. No retries at the proxy.
    proxy_connect_timeout 2s;
    proxy_read_timeout    5s;
    proxy_next_upstream   off;
    proxy_set_header X-Request-ID $correlation_id;
    add_header X-Request-ID $correlation_id always;

    # Read-only endpoint: the monolith still answers, and a copy goes to the shadow.
    location /api/catalog/ {
        mirror /_shadow;
        mirror_request_body off;   # GET-only route, so there is no body to copy
        proxy_pass
    }

    # nginx discards the shadow response. The new service logs its own result
    # under the same correlation ID so it can be diffed against the monolith.
    location = /_shadow {
        internal;
        proxy_set_header X-Request-ID $correlation_id;  # repeat it: a local proxy_set_header replaces inherited ones
        proxy_set_header X-Shadow-Request "1";
        proxy_read_timeout 1s;     # a slow shadow can hold up the client's connection
        proxy_pass
    }

    location / { proxy_pass  }
}

An nginx seam config that mirrors only a read-only catalog route to the new service, puts a single correlation ID (reused from the caller or generated) on the monolith, shadow, logs and response, and sets timeouts at the proxy with upstream retries turned off.

Safe cutover, rollback, and finishing the migration

Every route should be reversible through a configuration change. You should not need to deploy anything to undo it. If a rollback needs a build and deploy, it arrives too late, while users are already seeing errors. Keep the monolith's handler for a route deployable and warm until the new service has run at full traffic for a while.

Ramping and rollback triggers

Shift traffic in steps, using weighted routing at the proxy or a feature flag keyed on user or tenant ID. Keying on a stable ID keeps each user on one backend. That avoids session and caching inconsistencies, and it makes bug reports traceable. Before the ramp starts, write down the rollback triggers:

  • error rate above the monolith's baseline for that route
  • p99 latency regression
  • a business KPI, such as checkout completion or sign-up conversion, falling

The business metric matters because a service can return fast 200s and still be wrong. Agreeing on thresholds in advance removes the judgment call at 2 a.m. Automate the revert where you can.

Finishing

Track the remaining monolith routes and each one's share of traffic. This number tells you whether the migration is converging or stalling. Access logs show which endpoints get no requests. Confirm they are dead over a full business cycle, including month-end jobs, then delete them rather than porting them. Decommission the monolith's tables only after writes have stopped and you have taken a final archive. Last, decide on the proxy. If it now carries auth, rate limiting, and observability, keep it as a gateway. If not, remove the extra hop.

# Ramp /checkout to the new service in steps, keyed on a stable user ID.
# Rollback = edit the percentage (or the kill switch) and run `nginx -s reload`.
# No build or deploy is needed.

upstream legacy_monolith {
    server monolith.internal:8080;   # keep warm until the new service has run at 100% for a while
}

upstream checkout_service {
    server checkout.internal:9000;
}

# Hash the user ID cookie so each user consistently lands on one backend.
# This avoids session and cache mismatches and makes bug reports traceable.
split_clients "${cookie_user_id}" $checkout_ramp {
    10%     checkout_service;        # step: 1% -> 10% -> 50% -> 100%
    *       legacy_monolith;
}

# Kill switch: set to 1 to send all checkout traffic back to the monolith instantly.
map $host $checkout_backend {
    default $checkout_ramp;
    # default legacy_monolith;       # <- uncomment (and comment the line above) to roll back
}

log_format strangler '$remote_addr [$time_local] "$request" $status '
                     'backend=$checkout_backend rt=$request_time';

server {
    listen 80;
    access_log /var/log/nginx/strangler.log strangler;  # per-backend error rate, latency, dead routes

    location /checkout/ {
        proxy_pass
    }

    location / {
        proxy_pass    # everything not yet migrated
    }
}

An nginx config that ramps one route to a new service by hashing a stable user ID, provides a reload-only rollback switch, and logs which backend served each request so you can compare error rates and latency per backend and find unused endpoints.

Alternatives and the impact on cost and engineering time

The strangler fig pattern is rarely the cheapest option in raw spend. Its advantage is lower risk per change.

Approach Main cost Main risk Fits when
Big-bang rewrite Full team builds without production feedback Parity gaps surface at launch, rollback is all-or-nothing System is small and requirements are frozen
Branch by abstraction (in place) Refactoring inside one deploy unit Coupling and shared release cadence remain Pain is code structure, not team ownership or scaling
Strangler fig Two systems, proxy, data sync Migration stalls half-finished You need incremental delivery with cheap rollback
Leave it alone Ongoing maintenance Opportunity cost, shrinking expertise Monolith is stable and not blocking the roadmap

The costs compound with duration. You pay for both systems, the proxy's on-call burden and the sync pipeline until the last route moves. Engineers also carry two mental models of where each behaviour lives. Migrations often run past plan, so budget against the slipped date.

The savings come from smaller releases that reviewers can actually reason about. A bad route can be reverted with a config change instead of an emergency hotfix, and extracted capabilities ship to users months before the migration ends.

Illustrative estimate (assumed numbers, not a benchmark)

  • Overlap cost = (new infra + proxy + sync) × actual months
  • Assumed: 4,000 + 1,500 per month, 12 planned months, 50% slip → 5,500 × 18 = 99,000
  • Time saved = avoided incidents × engineer-days per incident
  • Assumed: 8 incidents × 3 days = 24 engineer-days

Plug in your own figures. If the time saved and the early value delivered do not outweigh the overlap cost, choose a different row in the table.

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, 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.