What the strangler fig pattern is and why the first extraction is different

The strangler fig pattern replaces a monolith incrementally rather than all at once. You put a facade in front of the existing system, usually a routing proxy. It sends each request either to the monolith or to a newly extracted service. Over time, more routes point to new services, and once the monolith no longer serves a given path, you delete that code. The name comes from vines that grow around a host tree until the tree can be removed.

Teams choose this over a rewrite because a rewrite has to reach feature parity before it delivers anything. Meanwhile the monolith keeps changing underneath it. Incremental migration ships value with each extraction, keeps production traffic flowing through code that already works, and lets you stop or reverse at any point without losing what you've built.

The first extraction is different from the rest. Most of its effort goes into infrastructure you'll reuse:

  • The routing proxy and how you configure it
  • A clear rule for which side owns which data
  • A rollback path the team has actually practiced

This is why business importance is the wrong way to pick the first service. If you choose your most critical domain, you are learning these habits where mistakes cost the most.

The pattern suits teams whose monolith causes real pain, such as components that need to scale differently or teams blocking each other on deploys. It also requires the platform capacity to run, monitor and page on more services. Tell stakeholders upfront that extraction one is an investment in the migration path. Its payoff shows up in extractions two through ten.

                  +-----------+
                  |  Clients  |
                  +-----------+
                        |
                        v
      +----------------------------------+
      | Facade (routing proxy)           |
      | route table (example):           |
      |   /billing/*      -> service #1  |
      |   everything else -> monolith    |
      +----------------------------------+
              |                   |
  most routes |                   | extracted route
              v                   v
     +-----------------+  +-----------------+
     |    Monolith     |  | Service #1      |
     |  legacy paths   |  | (new, extracted)|
     |  deleted as     |  |                 |
     |  routes move    |  |                 |
     +-----------------+  +-----------------+
              |                   |
              v                   v
     +-----------------+  +-----------------+
     | Monolith DB     |  | Service DB      |
     | owns its tables |  | owns its tables |
     +-----------------+  +-----------------+

  Ownership: each side writes only the data it owns.
  Rollback: point /billing/* back at the monolith in the route table.

In the strangler fig pattern, a routing facade sends each path to either the monolith or an extracted service, and the first extraction establishes the routing, data-ownership and rollback habits that later extractions reuse.

Choosing the first service: score seams on routability, coupling and rollback

Score each candidate seam from 1 (poor) to 3 (good) on four criteria, and pick from the top of the list rather than by instinct.

  • Routability. A clear request boundary, such as a URL prefix (/reports/*) or a distinct message type, lets the proxy split traffic with one rule. If the capability is reached through calls deep inside other request handlers, you have to refactor those call sites before you can route anything.
  • Coupling to shared tables. Count the tables the capability reads and writes that other modules also touch. Each shared table is a consistency problem you will have to solve during the data move.
  • Write volume. Reads can be shadowed and compared without side effects. Writes need idempotency, dual-write handling and reconciliation. Fewer writes means a cheaper rollback.
  • Ownership. One team that owns the capability end to end can make decisions and carry the pager without negotiating with other teams.

Read-heavy or self-contained capabilities score well because a mistake there produces a wrong page or a stale result, not corrupted orders. The core domain scores poorly on almost every criterion, which is why it should come later, once routing and data migration have been proven.

Illustrative example: hypothetical candidates with assumed scores.

Candidate Routability Coupling Write volume Ownership Total
PDF invoice rendering 3 3 3 2 11
Product search 3 2 3 3 11
Notification preferences 2 2 2 3 9
Checkout 2 1 1 1 5

The trade-off is that the top-scoring pick may change little that users or stakeholders can see. If two candidates tie, choose the one with more visible value. Product search would beat invoice rendering here.

How to implement it: build the seam in code, then put a routing proxy in front

The order of work matters more than the tooling. Draw the boundary inside the monolith first, then add the network hop.

Step 1: Build the seam in code

Find every place the monolith calls into the capability you chose, such as product search, and route those calls through one internal interface. Expose a narrow method like search(query, filters) that returns a type the monolith owns, not the ORM entities the search code uses today. If the old code leaks internal models, put an anti-corruption layer at the interface so callers stop depending on them. This change ships as an ordinary refactor with no network involved. Once it is done, you can swap the implementation behind the interface for an HTTP client later without touching any caller. Any coupling you missed shows up here as a compile error or a failing test, which is cheaper to fix than an outage.

Step 2: Ship the proxy as a no-op

Put a reverse proxy, API gateway or in-app router in front of the monolith. Configure a prefix match for the search path and one default upstream: the monolith. Deploy it and compare error rates and latency percentiles against the baseline. This deploy should change nothing for users. If it does, the proxy is the cause, and you have found that out before any extracted service is involved.

The proxy has ongoing costs. It adds a network hop to every request and becomes a new single point of failure, so run it in a highly available setup. Give it a named owning team, and harden it like any other edge component: TLS configuration, header sanitization and access to its admin interface.

# Step 2: no-op routing proxy in front of the monolith.
# Fragment for the http {} context of nginx.conf.

upstream monolith {
    # Single default upstream: every request still goes to the monolith.
    server monolith.internal.example:8080;
    keepalive 32;  # reuse connections to reduce the cost of the extra hop
}

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

    # Overwrite forwarding headers instead of trusting client-supplied values.
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Connection "";  # required for upstream keepalive

    # Prefix match for the capability being extracted.
    # For now it points at the monolith, so this deploy changes nothing.
    # Later, only this upstream changes to the new search service.
    location /api/search/ {
        proxy_pass
    }

    # Default route: everything else stays on the monolith.
    location / {
        proxy_pass
    }
}

A minimal nginx config with a prefix match for the search path and one default upstream, so the proxy can ship as a no-op before any service is extracted.

Shifting traffic: shadowing, percentage routing and a single revert switch

Start with shadowing. The proxy serves every request from the monolith and sends a copy to the new service, whose response is discarded after a diff against the monolith's. This tests the service under real traffic shapes without exposing users. Shadow only reads unless writes are isolated. A mirrored write that sends an email, charges a card or publishes an event will happen twice, so stub those side effects or point the shadow at sandbox dependencies.

Once diffs are clean, route real traffic. Start with internal users or a handful of named tenants selected by header, then move to a small weighted percentage and ramp from there. The example below combines a header match with a weighted split and sends everything else to the monolith. Each step should be reversible with one change, such as setting the weight back to zero or flipping a flag. A revert that needs a deploy is too slow during an incident.

Base ramp decisions on data:

  • Correlation IDs. The proxy generates or forwards a request ID, and the monolith and service both log it, so a single request can be traced across all three.
  • Per-route comparison. Show error rate and latency percentiles for the monolith and the service side by side on the same route.
  • Diff rate. During shadowing, track the share of mismatched responses and the categories of mismatch.

What used to be an in-process function call is now a network hop, and it can be slow, fail partway or run twice. Set explicit timeouts and add a circuit breaker that falls back to the monolith. Make writes idempotent with request keys so that retries cannot duplicate work.

# Snippet for the http {} block: header canary, weighted split, monolith fallback.
upstream monolith       { server monolith.internal:8080; }
upstream orders_service { server orders.internal:8080; }

# Reuse an incoming request ID, or mint one, so proxy, monolith and service log the same ID.
map $http_x_request_id $req_id { "" $request_id; default $http_x_request_id; }

# Weighted split, sticky per user. REVERT: delete the 5% line and run `nginx -s reload`.
split_clients "${http_x_user_id}${remote_addr}" $orders_split {
    5%  orders_service;
    *   monolith;
}

# Named internal users or tenants opt in by header. Everyone else gets the weighted split.
map $http_x_route_orders $orders_backend {
    "service" orders_service;
    default   $orders_split;
}

# Log which backend served each request so dashboards can compare error rate and latency per route.
log_format cutover '$req_id $uri $status $upstream_addr $upstream_response_time';

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

    location /orders {
        proxy_set_header X-Request-ID $req_id;
        proxy_connect_timeout 1s;   # The call is now remote, so set explicit timeouts
        proxy_read_timeout    2s;
        proxy_intercept_errors on;  # On 502/503/504 or a timeout, serve from the monolith
        error_page 502 503 504 = @monolith;  # Replays writes too: needs idempotency keys
        proxy_pass
    }

    location @monolith {
        proxy_set_header X-Request-ID $req_id;
        proxy_pass
    }
}

An nginx config that sends opted-in users by header and a sticky 5% of other traffic to the new service, forwards one request ID to both backends, fails over to the monolith on errors or timeouts, and reverts by deleting one line and reloading.

Moving data ownership without breaking consistency

Data moves in stages. At first the new service owns nothing. It reads through the monolith's API or from a read-only replica of the relevant tables, so the seam can ship without a migration. Once traffic is stable, the service takes ownership of its own store, and the monolith switches from writing that data to being notified of changes.

Option Failure mode Ordering Operational cost Rollback
Dual writes in app code Silent divergence Not guaranteed Low upfront Hard: unknown drift
Change data capture Connector lag or schema drift Per-table log order High: connector infrastructure Moderate
Transactional outbox Duplicate publishes Per-outbox order Moderate: relay plus table Easy: replay events

Dual writes are the riskiest because two systems are written with no shared transaction. A crash between the writes, or a timeout on the second one, leaves the stores out of sync with no record of the gap. Concurrent requests can also land in different orders on each side.

The outbox avoids this. The business write and an insert into an outbox table happen in one local transaction, so either both commit or neither does. A separate relay reads unsent rows in order, publishes them and marks them sent. A crash after publishing but before marking means the relay sends that event again, so consumers deduplicate by event ID.

Splitting a shared schema gives up cross-table atomicity. Joint invariants become eventual consistency, compensating actions or a saga. Ledgers and hard-limit inventory reservations handle this poorly. Keep those domains together.

import json
import uuid
import psycopg2

conn = psycopg2.connect("dbname=orders user=orders_svc host=db.internal")

def place_order(customer_id, total_cents):
    # The business write and the outbox insert share one local transaction,
    # so both commit or neither does. `with conn` commits on success, rolls back on error.
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO orders (customer_id, total_cents) VALUES (%s, %s) RETURNING id",
                (customer_id, total_cents))
            order_id = cur.fetchone()[0]
            cur.execute(
                "INSERT INTO outbox (event_id, topic, payload) VALUES (%s, %s, %s)",
                (str(uuid.uuid4()), "order.placed",
                 json.dumps({"order_id": order_id, "total_cents": total_cents})))

def relay_once(publish, batch=100):
    # Run one relay instance so events leave in outbox (id) order.
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, event_id, topic, payload FROM outbox "
                "WHERE sent_at IS NULL ORDER BY id LIMIT %s", (batch,))
            for row_id, event_id, topic, payload in cur.fetchall():
                # If the relay crashes after publish but before commit, this event
                # is sent again on the next run; consumers deduplicate on event_id.
                publish(topic, key=event_id, value=payload)
                cur.execute("UPDATE outbox SET sent_at = now() WHERE id = %s", (row_id,))

A PostgreSQL transactional outbox in Python: the order insert and its event row commit atomically, and a single relay publishes unsent rows in order with at-least-once delivery that consumers deduplicate by event ID.

Defining done and making the second extraction cheaper

An extraction is finished when the monolith can no longer serve the capability, not when the new service handles most of the traffic. Use three exit criteria:

  • The old code path is retired. No route, job or internal call reaches it.
  • The monolith's copy is deleted. That includes the logic, its tests and the tables it owned, after a final archive of the data.
  • The routing rule's fallback is removed. The proxy sends traffic only to the service.

Teams often stop at about 80 percent. The facade stays in place, both implementations stay live, and the fallback stays "just in case." The result is a distributed monolith: you pay for network hops, separate deployments and extra observability, and you still carry the monolith's coupling. Every bug fix and rule change has to land in two codebases. Over time one copy gets patched and the other doesn't, so the two implementations drift and rolling back becomes unsafe. Put a removal date on the fallback and treat missing it as a defect.

Checklist to reuse on the next extraction

  • Proxy configuration conventions: route naming, header-based overrides, where percentage weights live and who can change them.
  • Service template: health checks, structured logging, tracing propagation, auth middleware and the deploy pipeline.
  • Data sync approach: the method you chose (dual writes, change data capture or backfill plus cutover), with its known failure modes.
  • Rollback runbook: the single revert switch, who can flip it, and how to verify the revert worked.

The first extraction makes these decisions under uncertainty. The checklist turns them into defaults. Later extractions then spend engineering time on the domain itself instead of re-debating infrastructure, which lowers development cost and keeps review effort predictable.

When to avoid it and what to use instead

The strangler fig pattern assumes that a network boundary is worth paying for. Often it is not:

  • Small team. If the same five people own both sides of the seam, a service boundary adds deploys and on-call load without reducing coordination.
  • Already well-modularized monolith. When the boundaries are clean, the remaining problem is usually deployment or scaling. Extraction does not solve either one.
  • Strong cross-entity transactions. Splitting a ledger from its balances replaces one atomic commit with sagas and reconciliation jobs.
  • No capacity to run more services. A service without alerting, runbooks and owners becomes a new outage source.
Option Risk Cost Time to value Reversibility
Strangler fig extraction Medium High Weeks to months High while the proxy switch exists
Modular monolith with enforced boundaries Low Low Days to weeks High
Branch by abstraction, no network split Low Low to medium Weeks High
Full rewrite High Very high Late, all at once Low
Leave as is Low now, rising later Zero up front None N/A

Impact on development cost

Most of the spend comes before the first request moves. You build the routing proxy, add tracing across the new hop, and run data synchronization during the transition. After that, every extracted service carries recurring costs: pipelines, dashboards, dependency upgrades and on-call rotation. You get that money back only if the extracted domain changes often or needs to scale independently. If it does neither, enforce the boundary inside the monolith and keep the network out of it.

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.