Migrating a Monolith to Microservices: The Strangler Fig Approach
How to decompose a legacy monolith incrementally using the strangler fig pattern — without a big-bang rewrite that puts your business at risk.
Why Big-Bang Rewrites Fail
The most common microservices migration mistake is drawing a new architecture diagram, freezing the monolith, and spending 18 months rebuilding everything from scratch in services. By the time the new system is ready, the business has changed, the monolith has received 200 new features, and the "new" system is already out of date.
The strangler fig pattern is the antidote: migrate incrementally, ship value continuously, and let the new architecture grow around the old one until the monolith can be safely decommissioned.
The Strangler Fig Pattern
Named after the strangler fig tree, which grows around a host tree and eventually replaces it, the pattern works in three phases:
- Identify — find a bounded capability in the monolith that can be extracted
- Redirect — route traffic for that capability to a new service via a proxy/facade
- Remove — once the new service is stable, delete the code from the monolith
The monolith continues running throughout. Users never experience a big cutover.
Step 1: Map Your Monolith's Seams
Before writing a single line of service code, map the monolith's domain model. Look for "seams" — areas of low coupling between business capabilities.
Monolith Capabilities:
┌─────────────────────────────────────────────────────┐
│ OrderService │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Inventory│ │ Pricing │ │ Notifications │ │
│ │ (complex)│ │ (coupled)│ │ (isolated!) │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────┘
Start with the most isolated capability — high cohesion, low coupling. In this example: Notifications.
Step 2: Build the Facade
Before extracting, introduce a routing layer (API Gateway, Nginx, or application-level facade) that can direct traffic to either the monolith or the new service.
# Facade Router (lives in front of the monolith)
class NotificationRouter:
def __init__(self, feature_flags: FeatureFlags):
self.flags = feature_flags
self.monolith_client = MonolithHTTPClient()
self.notification_service = NotificationServiceClient()
async def send_order_confirmation(self, order_id: str, user_id: str):
if self.flags.is_enabled("new_notification_service", user_id):
# Route to new service for enabled users
return await self.notification_service.send_order_confirmation(
order_id=order_id,
user_id=user_id
)
else:
# Fall back to monolith
return await self.monolith_client.post(
"/internal/notifications/order-confirmation",
json={"orderId": order_id, "userId": user_id}
)
Step 3: Run in Shadow Mode First
Before routing real traffic, run the new service in shadow mode: send every request to both systems, use the monolith's response, but log differences.
async def shadow_mode_send(self, payload: dict) -> dict:
# Use monolith response (source of truth)
monolith_result = await self.monolith_client.send(payload)
# Fire-and-forget shadow call
asyncio.create_task(
self._shadow_compare(payload, monolith_result)
)
return monolith_result
async def _shadow_compare(self, payload: dict, monolith_result: dict):
try:
new_result = await self.notification_service.send(payload)
if new_result != monolith_result:
logger.warning("Shadow divergence", extra={
"payload": payload,
"monolith": monolith_result,
"new_service": new_result,
})
except Exception as e:
logger.error("Shadow call failed", extra={"error": str(e)})
Run shadow mode for 2–4 weeks. Fix divergences. Only then start routing real traffic.
Step 4: Gradual Traffic Migration
# Feature flag: ramp from 0% to 100% over 2 weeks
NOTIFICATION_SERVICE_ROLLOUT = {
"enabled": True,
"rollout_percentage": 25, # Start at 25%
"sticky": True, # Same user always goes to same service
}
Rollout schedule:
- Week 1: 5% → monitor error rates
- Week 2: 25% → validate at scale
- Week 3: 50% → check performance under load
- Week 4: 100% → full migration
At each stage, error rate > 0.1% above baseline = roll back immediately.
Step 5: Shared Database — The Hard Part
The monolith and new service likely share a database. This is the hardest constraint to untangle.
Strategy:
- New service writes to its own schema, reads from monolith schema
- Add a synchronization job to keep both in sync
- Gradually shift reads to the new schema
- Eventually cut the write path
- Remove the sync job and the monolith tables
Never let two services write to the same table. This is the rule that prevents split-brain data corruption.
Extraction Priority Matrix
| Capability | Coupling | Business Value | Migrate? |
|---|---|---|---|
| Notifications | Low | Medium | ✅ First |
| User Auth | Medium | High | Later |
| Pricing Engine | High | High | Last |
| Order Processing | Very High | Critical | Maybe never |
Some parts of the monolith should stay as a monolith. Microservices are not the goal — reliability and velocity are.