The Strangler Fig Pattern: The Safest Way to Leave Your Monolith Behind
by Arif Ikhsanudin, Backend Developer
Why this pattern exists and what it's named after
Martin Fowler named the Strangler Fig pattern after the strangler fig tree — a plant that grows around a host tree, gradually taking over its structure while the host tree dies inside the fig's canopy. The analogy for software: you build new service functionality that wraps the monolith at the traffic layer. Over time, as new services handle more routes, the monolith handles less, until it can be shut down entirely. The monolith is never replaced in a single migration event — it is gradually strangled.
The name is evocative but the mechanism is straightforward: put a proxy (your API gateway or a dedicated routing layer) in front of the monolith. Route some requests to new services, route the rest to the monolith. Expand the portion going to new services over time. When a route is fully migrated and the monolith path is unused, delete the monolith code for that route.
This is the safest decomposition approach because it provides incremental production validation, fine-grained rollback capability, and no big-bang cutover event.
The routing layer as the control plane
The most important architectural decision in a Strangler Fig migration is where routing lives. The routing layer is the switchboard that determines which requests go to the monolith versus new services. It needs to be:
- Independently deployable (you can change routing without deploying either the monolith or the new services)
- Observable (you can see how much traffic goes to each target)
- Capable of weighted routing (send 10% to new service, 90% to monolith)
Your API gateway (Kong, NGINX, AWS API Gateway) is the natural fit if it supports weighted routing or traffic splitting. If it doesn't, a lightweight proxy service (a simple HTTP router in Go or Node.js) deployed in front of the monolith serves the purpose.
# NGINX: strangler fig routing configuration
upstream monolith {
server monolith.internal:8080;
}
upstream order-service {
server order-service.internal:8080;
}
server {
# Orders endpoint: currently migrated, goes to new service
location /api/orders {
proxy_pass http://order-service;
}
# All other requests: still handled by monolith
location / {
proxy_pass http://monolith;
}
}
Changing which routes go to the new service is a routing configuration change, not an application deployment. This is the key operational property: migration decisions are decoupled from both the monolith and the new service.
The migration sequence per endpoint
For each endpoint you're migrating, the sequence should be:
1. Build the new service endpoint — implement the same behavior in the new service. Test it with unit tests and integration tests. Don't route production traffic yet.
2. Shadow mode — route production traffic to both the monolith (authoritative) and the new service (shadow). Compare results. Fix discrepancies. Run this until discrepancy rate is negligible.
3. Canary — route 1–5% of production traffic to the new service authoritatively. Monitor error rates and latency against the monolith baseline. Fix any issues that only appear under production conditions.
4. Gradual rollout — increase traffic weight: 10%, 25%, 50%, 100%. Validate at each step. The increment size and dwell time depend on your risk tolerance and the endpoint's criticality.
5. Decommission the monolith path — once 100% of traffic routes to the new service and you've operated it successfully for a sufficient stabilization period (typically two to four weeks), remove the monolith code for that endpoint. Update the routing configuration to remove the monolith path.
# Simple shadow comparison (can be added to monolith temporarily)
def create_order_with_shadow(request):
# Authoritative: monolith result
monolith_result = monolith_create_order(request)
# Shadow: compare without affecting user
try:
service_result = order_service_client.create_order(request)
if not results_equivalent(monolith_result, service_result):
logger.warning(
"Shadow divergence for order %s: monolith=%s, service=%s",
request.order_id, monolith_result, service_result
)
except Exception as e:
logger.warning("Shadow call failed for order %s: %s", request.order_id, e)
return monolith_result
Handling shared state: the hardest part
The Strangler Fig pattern handles routing cleanly. Data is harder. The new service needs its own data, but during the migration period, the monolith's database is authoritative.
Two patterns for managing shared state during migration:
Anti-corruption layer: the new service calls the monolith's API (not the database directly) for data it doesn't yet own. This keeps the monolith as the source of truth during migration. The new service gradually takes over data ownership as migration progresses.
Dual write with event sourcing: when the monolith writes data, it also publishes an event. The new service consumes the event and maintains its own data store. During migration, the service can serve reads from its own store while writes still go through the monolith. When ready, flip write ownership to the service.
Neither is simple. Both are safer than a one-time data migration at cutover time.
Knowing when you're done
The Strangler Fig migration is complete for a given bounded context when:
- 100% of traffic routes to the new service
- The monolith code for that context has been deleted (not just disabled)
- The new service owns its own data store with no dependency on the monolith's database
- The team has operated the new service independently through at least one incident
"Deleted" is load-bearing. Disabled code comes back. Unused monolith paths accumulate. Code that is actually deleted cannot drift back into use. When the migration sequence ends in deletion, you know it's genuinely complete — not just phase-shifted to a later date.
The strangler fig eventually becomes the only tree standing. That outcome is achievable only through the incremental discipline of migrating one endpoint, one route, one bounded context at a time.