Strangler Fig Pattern
Incrementally replace legacy systems without big-bang migrations. Covers the strangler fig approach, facade routing, feature toggles for migration, data synchronization during transition, and the patterns that let you modernize production systems safely.
A big-bang rewrite is the riskiest project in software engineering. The strangler fig pattern offers a safer alternative: instead of replacing a legacy system all at once, you incrementally build a new system around it. New features go to the new system. Existing features are migrated one by one. The legacy system gradually shrinks until it can be decommissioned.
How the Strangler Fig Works
Phase 1: Facade (route all traffic through a proxy)
Users
│
▼
┌──────────┐
│ Facade │ Routes ALL requests
│ (Proxy) │
└─────┬─────┘
│
▼
┌──────────┐
│ Legacy │ Handles everything (status quo)
│ System │
└──────────┘
Phase 2: Strangle (migrate features one by one)
Users
│
▼
┌──────────┐
│ Facade │ Routes based on feature
│ (Proxy) │
└──┬────┬───┘
│ │
▼ ▼
┌──────┐ ┌──────────┐
│ New │ │ Legacy │ Still handles some features
│System│ │ System │
└──────┘ └──────────┘
Phase 3: Complete (legacy decommissioned)
Users
│
▼
┌──────────┐
│ Facade │ (Optional: may remove facade too)
│ (Proxy) │
└─────┬─────┘
│
▼
┌──────────┐
│ New │ Handles everything
│ System │
└──────────┘
Legacy system: powered off
Implementation
class StranglerFacade:
"""Route requests between legacy and new systems."""
def __init__(self):
self.feature_routes = {
# Feature → which system handles it
"user_profile": "new", # Migrated
"order_create": "new", # Migrated
"order_history": "new", # Migrated
"inventory": "legacy", # Not yet migrated
"reporting": "legacy", # Not yet migrated
"billing": "canary", # Being migrated (split traffic)
}
def route_request(self, request):
"""Route to legacy or new system based on feature."""
feature = self.extract_feature(request)
target = self.feature_routes.get(feature, "legacy")
if target == "new":
return self.new_system.handle(request)
elif target == "legacy":
return self.legacy_system.handle(request)
elif target == "canary":
# Split traffic during migration
if self.should_canary(request):
try:
new_response = self.new_system.handle(request)
legacy_response = self.legacy_system.handle(request)
# Compare responses (shadow mode)
if new_response != legacy_response:
self.log_discrepancy(feature, new_response, legacy_response)
return new_response # Serve from new system
except Exception:
# Fallback to legacy on error
return self.legacy_system.handle(request)
else:
return self.legacy_system.handle(request)
def migration_status(self):
"""Track overall migration progress."""
total = len(self.feature_routes)
migrated = sum(1 for v in self.feature_routes.values() if v == "new")
return {
"total_features": total,
"migrated": migrated,
"in_progress": sum(1 for v in self.feature_routes.values() if v == "canary"),
"remaining": sum(1 for v in self.feature_routes.values() if v == "legacy"),
"progress_pct": migrated / total * 100,
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| No facade at the start | Cannot route incrementally | Start with facade before writing any new code |
| Migrate database first | Riskiest migration as step 1 | Migrate application logic first, database last |
| No shadow/canary mode | Migration errors discovered in production | Shadow mode: run both systems, compare results |
| Feature migration too large | Risk accumulates, no feedback | Small feature migrations with quick feedback |
| Never turn off legacy | Two systems running forever | Set deadline, track progress, decommission |
The strangler fig pattern accepts that legacy modernization is risky and proposes the safest possible approach: small, incremental steps with the ability to fall back at any point. The legacy system is not replaced — it is gradually absorbed.