Breaking the monolith: the strangler fig pattern in practice
A platform team at a logistics company inherits a twelve-year-old Rails monolith that handles everything: order intake, route optimization, driver dispatch, customer notifications, billing. The codebase has 180,000 lines, 3,000 tests, and no service boundaries. The CEO wants the dispatch system extracted to support a mobile-first API before the next quarter ends.
The CTO has seen rewrites fail. She proposes a strangler fig migration. The team agrees. Eight months later, the dispatch domain is running as a separate service in production. The monolith still runs the other domains. Zero downtime. Zero data loss. Customers never noticed.
This is what a successful migration looks like. Most teams get it wrong — they understand the pattern conceptually but fail at the implementation details: where to put the routing layer, how to handle shared data, when to cut over, and how to avoid the common traps that turn a controlled migration into a six-month freeze.
What the strangler fig pattern actually is
The strangler fig plant grows around a host tree, starting from the top and working down. Over years, the fig grows to envelope the host. Eventually the host dies and the fig stands on its own, occupying the same space.
In software, the pattern works the same way. New code is written as a separate service. Traffic is routed: some requests go to the new service, others go to the monolith. Over time, more and more traffic moves to the new service. When all traffic for a domain is on the new service, the corresponding code in the monolith is deleted.
The monolith does not need to change for the migration to start. New features for the migrated domain are built in the new service, not the monolith. The monolith's version of the domain is maintained only as long as it is still serving traffic.
The three components: the routing layer that decides where each request goes; the new service; and the data strategy for handling the shared database.
Building the routing layer
The routing layer is where most implementations go wrong. Teams often implement routing inside the monolith — adding logic to the monolith's router to redirect certain requests to the new service. This creates a dependency: the monolith must be deployed to change routing, and the monolith's stability affects the migration.
A better approach is to put the router in front of the monolith, not inside it. A reverse proxy, API gateway, or edge service inspects the request and routes it to either the monolith or the new service. Changes to routing require a config change to the proxy, not a deploy to the monolith.
# Nginx configuration for strangler fig routing
# Routes dispatch domain to new service; everything else to monolith
upstream monolith {
server monolith-app:3000;
}
upstream dispatch-service {
server dispatch-svc:8080;
}
server {
listen 80;
# Dispatch domain — new service
location /api/v1/dispatch/ {
proxy_pass http://dispatch-service;
proxy_set_header X-Request-Source "dispatch-service";
}
location /api/v1/routes/ {
proxy_pass http://dispatch-service;
proxy_set_header X-Request-Source "dispatch-service";
}
location /api/v1/drivers/availability {
proxy_pass http://dispatch-service;
}
# Everything else — monolith
location / {
proxy_pass http://monolith;
}
}
This configuration is declarative and reviewable. Adding a new endpoint to the dispatch service means adding a location block. Reverting to the monolith means removing the block. The monolith is not involved in either operation.
The routing layer should also handle the shadow traffic phase. Before cutting over a path, run both the monolith and the new service in parallel for the same requests, compare responses, and alert on divergence:
# Shadow routing middleware — sends requests to both services
# Returns monolith response, but compares with new service in background
import asyncio
import httpx
import logging
logger = logging.getLogger("shadow_router")
async def shadow_route(request: Request, primary_url: str, shadow_url: str) -> Response:
primary_task = asyncio.create_task(forward_request(request, primary_url))
shadow_task = asyncio.create_task(forward_request(request, shadow_url))
primary_response = await primary_task
# Compare in background — do not block on shadow
asyncio.create_task(compare_responses(request, primary_response, shadow_task))
return primary_response # Always return primary response to caller
async def compare_responses(request: Request, primary: Response, shadow_task) -> None:
try:
shadow_response = await shadow_task
if primary.status_code != shadow_response.status_code:
logger.warning(
"Shadow divergence on %s %s: primary=%d shadow=%d",
request.method, request.url.path,
primary.status_code, shadow_response.status_code
)
elif normalize_response(primary.json()) != normalize_response(shadow_response.json()):
logger.warning(
"Shadow response divergence on %s %s",
request.method, request.url.path
)
except Exception as e:
logger.error("Shadow comparison error: %s", str(e))
Running shadow traffic before cutover catches behavioral differences before they affect users. The shadow phase is the most valuable part of the migration that most teams skip.
Handling the shared database
The monolith and the new service both need access to the dispatch-related data. There are three options, in order of increasing complexity and eventual benefit.
Option 1: Shared database (short-term only). Both services read and write the same database tables. This is the easiest to implement for the initial migration and the hardest to maintain long-term. Schema changes require coordination between teams. Either service can introduce a query that causes contention on the other. This is acceptable as a transitional step if there is a committed plan to move to option 3.
Option 2: Database per service, with synchronization. The new service has its own database. A synchronization process keeps the new database in sync with the monolith's database during the migration period. This requires careful handling of conflicts and is operationally complex. Preferable only when the monolith's database is a bottleneck.
Option 3: Database per service, with event-based propagation. The recommended long-term state. The dispatch service owns its data. Changes in the monolith that affect dispatch data are propagated via events (change data capture or the outbox pattern). Changes in the dispatch service are propagated back to the monolith during the migration period, until the monolith no longer reads dispatch data.
For the logistics team, the migration proceeded in stages:
-
Phase 1 (weeks 1–4): New service reads and writes the shared database. The monolith is the source of truth. The new service introduces no new schema.
-
Phase 2 (weeks 5–10): Shadow traffic running. New service divergence rate monitored. Schema changes negotiated between monolith and service teams.
-
Phase 3 (weeks 11–16): Traffic cut over incrementally, starting with read endpoints. The new service handles all
/api/v1/dispatch/reads. The monolith still handles writes. Write paths require both databases to be kept in sync. -
Phase 4 (weeks 17–22): Write cutover. The new service handles all reads and writes. The monolith's dispatch tables become read-only. The synchronization direction reverses: the service is the source of truth, the monolith reads from the service via API.
-
Phase 5 (weeks 23–30): The monolith's dispatch code is deleted. The service owns the schema. The shared database dependency is removed.
Incremental cutover mechanics
Cutting over all traffic at once is risky. Cutting over by percentage allows validation and quick rollback.
A feature-flag approach routes a percentage of traffic to the new service:
# Request routing based on feature flag percentage
import hashlib
def route_dispatch_request(request: Request) -> str:
# Use request ID for consistent routing of the same request
# Use user ID for consistent routing of the same user's requests
routing_key = request.headers.get("X-Request-ID", request.client.host)
hash_value = int(hashlib.md5(routing_key.encode()).hexdigest(), 16) % 100
cutover_percentage = feature_flags.get("dispatch_service_cutover_pct", 0)
if hash_value < cutover_percentage:
return "dispatch-service"
return "monolith"
Starting at 5%, then 10%, 25%, 50%, 75%, 100% — with monitoring between each step — gives the team confidence that each percentage increment behaves correctly before proceeding.
The key metrics to monitor at each percentage increment:
- Error rate on the new service vs the monolith for the same endpoint patterns
- Latency p50, p95, p99 on the new service vs the monolith
- Response divergence rate (comparing new service responses to monolith responses for the same requests)
- Downstream consumer error rates (downstream services consuming dispatch events)
Any metric regression triggers an immediate rollback to the previous percentage. The monolith is not going away — it runs in parallel throughout the migration and is always available as a fallback.
The failure modes that end migrations
The big bang rewrite temptation. Teams that start a strangler fig migration and then decide to rewrite the entire dispatch domain at once — "since we're already doing it" — transform a controlled migration into a risky rewrite. The strangler fig pattern works precisely because it moves small pieces of traffic at a time. Moving large pieces at once is a different risk profile.
Letting the monolith evolve the migrated domain. If the monolith team continues building new dispatch features in the monolith during the migration, the migration target moves. The new service must catch up. Set a hard date on which the monolith is frozen for the dispatch domain, and all new dispatch development goes into the new service.
Skipping the shadow phase. Teams that skip shadow traffic and go directly from monolith to new service will discover behavioral differences after cutover, in production, affecting real users. Shadow traffic catches differences cheaply. Skipping it makes the migration cheaper in the short term and expensive when divergence appears in production.
Shared database longer than planned. The shared database phase is a transitional step, not an endpoint. Teams that leave both services reading and writing the same database create a situation where neither team can evolve the schema without coordinating with the other. Set a committed timeline for database separation and treat it as a hard dependency of the migration.
What to measure during the migration
Beyond error rates and latency, the migration should track several indicators of progress and health.
Dispatch traffic percentage. The fraction of dispatch-path requests going to the new service should increase monotonically. Any decrease — other than an intentional rollback — indicates a configuration error.
Behavioral divergence rate. During shadow traffic, the percentage of requests where the new service's response differs from the monolith's response. A high divergence rate before cutover is a sign the new service is not yet ready. A divergence rate that drops to under 1% before the first real cutover indicates sufficient confidence.
Monolith dispatch code modification rate. If engineers are still touching the dispatch code in the monolith after the freeze date, the freeze is not holding. Track commits to those files.
Database separation timeline. The shared database phase has a committed end date. Track whether schema dependencies are being removed on schedule.
The strangler fig pattern works because it defers commitment. At every step, the monolith is still running, the migration is reversible, and the team learns what the new service needs before it has to stand alone. The teams that succeed with it are the ones who resist the temptation to accelerate and instead let the pattern work as designed — incrementally, reversibly, and with the monolith available as a safety net until the new service has proven itself.
Common mistakes in strangler fig migrations
Starting with the most complex domain. Teams that begin a strangler fig migration with the most tightly coupled, highest-traffic domain are starting at the hardest possible point. The first domain to migrate should be one with clear boundaries, moderate traffic, and minimal shared data dependencies. The logistics team that migrated dispatch successfully chose it because dispatch had well-defined API boundaries and the domain team was small enough to execute the migration without extensive coordination.
Not freezing the monolith's version of the migrated domain. If the monolith team continues adding features to dispatch while the new service is being built, the migration target moves continuously. Every week of delay produces more code that must be replicated in the new service. A hard freeze date — publicly committed and tracked — prevents this drift and forces new dispatch features to be built in the new service from the freeze date forward.
Underestimating the data synchronization problem. Teams often focus on routing and service logic while underestimating how long the shared database phase will last. A shared database that was intended to be temporary often becomes permanent because the data synchronization work is harder than expected. Setting a committed database separation timeline at the start of the project — not after the service is live — ensures the team plans for it.
Removing the routing layer before the migration is complete. Once the new service handles 100% of traffic, some teams remove the routing layer and route directly to the new service. If a problem is later discovered that requires falling back to the monolith, the routing layer must be rebuilt. Keep the routing layer in place until the monolith's domain code has been deleted and the team has confirmed that rollback to the monolith is no longer needed. Removing the routing layer is the last step, not the second-to-last.
Treating shadow traffic as optional. Shadow traffic is the phase where the monolith's responses and the new service's responses are compared for every request, without affecting users. Teams that skip this phase because it is complex to implement discover behavioral differences after real users are affected. The divergence rate during shadow traffic is the best predictor of success at cutover — a divergence rate above 5% before cutover indicates the new service is not ready, regardless of how good the service's own tests are.
Verifying service equivalence during shadow traffic
The shadow comparison logic must normalize responses before comparing them. Timestamps, generated IDs, and ordering may differ between the monolith and the new service without indicating behavioral divergence:
def normalize_response(response: dict) -> dict:
"""
Normalizes a response for comparison during shadow traffic.
Removes fields that are expected to differ between implementations.
"""
normalized = dict(response)
# Remove fields that legitimately differ between services
normalized.pop("response_time_ms", None)
normalized.pop("server_id", None)
normalized.pop("generated_at", None)
# Normalize IDs that may be in different formats
if "id" in normalized:
normalized["id"] = str(normalized["id"]).lower().replace("-", "")
# Sort list fields so ordering differences don't produce false divergences
for key, value in normalized.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
try:
normalized[key] = sorted(value, key=lambda x: json.dumps(x, sort_keys=True))
except TypeError:
pass
return normalized
def log_shadow_divergence(
request_path: str,
method: str,
monolith_response: dict,
service_response: dict,
correlation_id: str
) -> None:
"""Log divergences for analysis — these become the to-do list before cutover."""
diff = DeepDiff(
normalize_response(monolith_response),
normalize_response(service_response),
ignore_order=True
)
if diff:
shadow_divergence_log.append({
"path": request_path,
"method": method,
"diff": diff.to_dict(),
"correlation_id": correlation_id,
"timestamp": datetime.utcnow().isoformat()
})
Accumulating shadow divergences in a searchable log rather than just alerting on them enables the team to categorize divergences: known differences (the new service returns extra fields), unacceptable differences (the new service returns different data values), and bugs to fix before cutover (the new service returns incorrect status codes).
What comes after the migration
A completed strangler fig migration does not mean the monolith is gone. In most real migrations, the monolith continues to run the domains that have not yet been migrated. The logistics team's monolith still ran order intake, billing, and customer notifications after dispatch was extracted. Those domains were candidates for subsequent migrations, each following the same pattern.
The pattern scales: each successful domain migration makes the next one easier. The routing layer infrastructure is already in place. The data synchronization patterns are understood. The shadow traffic tooling is built. The team has experience with the phases. What was a novel eight-month project for the first domain becomes a practiced four-month project for the second, and faster still for the third. The strangler fig pattern is not just a migration technique — it is a sustainable approach to incrementally replacing any system that cannot be replaced all at once.