Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

Strangler Fig Pattern

Evolution over revolution - migrate safely and incrementally

Imagine you have an old tree house that needs to be replaced. Instead of tearing it down all at once (risky!), you:

  1. Build a new section next to the old one
  2. Move one room at a time to the new structure
  3. Keep the old parts working while building new ones
  4. Eventually remove the old parts when everything is moved

This is exactly how the Strangler Fig Pattern works for software!

Statistics:

  • 70% of large software rewrites fail
  • Average time for “complete rewrite”: 2-3 years
  • During rewrite: Features frozen, business suffers
  • Often abandoned mid-way due to scope creep

Famous Failures:

  • Netscape 6 (1998-2000): Complete rewrite, lost browser market to IE
  • Mozilla Thunderbird: Years of rewrites, never caught up
  • Twitter (2009-2011): Partial rewrites caused major outages

Diagram
  1. Facade/Proxy Layer

    • Routes requests to either legacy or new system
    • Gradually shifts traffic
    • Transparent to clients
  2. Legacy System

    • Continues to run
    • Gradually shrinks
    • Eventually decommissioned
  3. New System

    • Built incrementally
    • Runs alongside legacy
    • Gradually expands

Don’t extract randomly! Choose services with:

Leaf services (few dependencies)

NotificationService → Only receives events, doesn't depend on others
ReportingService → Read-only, doesn't affect core business

High-change areas (frequent updates)

Pricing Engine → Business rules change often
Recommendation → ML models updated frequently

Performance bottlenecks (need independent scaling)

Search Service → Needs 50 instances
Image Processing → CPU-intensive, needs GPU

Clear business capabilities (well-defined boundaries)

Payment Processing
User Authentication
Order Management

Core entities with many dependents

User Service → Everything depends on it (extract later!)
Product Service → Central to business logic

Tightly coupled modules

OrderLine → Part of Order entity (extract together)
PaymentDetails → Embedded in Payment

Incomplete business capabilities

"Order Validation" alone → Part of Order Management
"Email Sending" alone → Part of Notification Service

Before extracting, create clear interfaces in the monolith.

monolith/services/order_service.py
# BEFORE: Direct implementation
class OrderService:
def create_order(self, user_id: str, items: list) -> Order:
# Direct database access
user = db.query(User).get(user_id)
# Create order
order = Order(user_id=user_id, items=items)
db.add(order)
# Send notification (tightly coupled!)
email_sender.send(user.email, "Order created", ...)
sms_sender.send(user.phone, "Order created")
return order
# AFTER: Interface abstraction
from abc import ABC, abstractmethod
class NotificationService(ABC):
"""Abstract interface for notifications"""
@abstractmethod
def notify_order_created(self, order: Order):
pass
class LocalNotificationService(NotificationService):
"""Implementation using local email/SMS"""
def notify_order_created(self, order: Order):
email_sender.send(...)
sms_sender.send(...)
class RemoteNotificationService(NotificationService):
"""Implementation calling external service"""
def notify_order_created(self, order: Order):
# Call microservice
httpx.post(
"http://notification-service/notify",
json={"order_id": order.id, "type": "order_created"}
)
class OrderService:
def __init__(self, notification_service: NotificationService):
self._notifier = notification_service # Inject!
def create_order(self, user_id: str, items: list) -> Order:
user = db.query(User).get(user_id)
order = Order(user_id=user_id, items=items)
db.add(order)
# Use abstraction (doesn't know if local or remote!)
self._notifier.notify_order_created(order)
return order

Route traffic between legacy and new system.

gateway/router.py
from fastapi import FastAPI, Request
import httpx
app = FastAPI()
class StranglerRouter:
def __init__(self):
self._legacy_client = httpx.AsyncClient(
base_url="http://legacy-monolith:8080"
)
self._new_client = httpx.AsyncClient(
base_url="http://notification-service:8080"
)
# Feature flags control routing
self._migrated_endpoints = {
"/api/notifications": 100, # 100% to new service
"/api/orders": 50, # 50% to new service
"/api/users": 0, # 0% to new service (still legacy)
}
async def route_request(self, request: Request):
path = request.url.path
# Check if endpoint is migrated
migration_percentage = self._migrated_endpoints.get(path, 0)
# Random routing based on percentage
import random
route_to_new = random.random() * 100 < migration_percentage
client = self._new_client if route_to_new else self._legacy_client
# Forward request
response = await client.request(
method=request.method,
url=path,
headers=dict(request.headers),
content=await request.body()
)
return response
@app.api_route("/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(request: Request):
router = StranglerRouter()
return await router.route_request(request)

Build the new service with its own database.

notification-service/main.py
from fastapi import FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
app = FastAPI()
# Own database!
engine = create_engine("postgresql://localhost/notification_db")
@app.post("/notify")
async def notify(request: NotifyRequest):
"""New microservice implementation"""
with Session(engine) as session:
# Store notification in OWN database
notification = Notification(
order_id=request.order_id,
type=request.type,
status="pending"
)
session.add(notification)
session.commit()
# Send via external services
await email_service.send(notification)
await sms_service.send(notification)
notification.status = "sent"
session.commit()
return {"status": "sent"}
@app.get("/notifications/{order_id}")
async def get_notifications(order_id: str):
"""Query OWN database"""
with Session(engine) as session:
notifications = session.query(Notification)\
.filter_by(order_id=order_id)\
.all()
return notifications

Challenge: Legacy and new service need the same data during transition!

# In monolith during transition
def create_order(user_id, items):
# Write to legacy DB
order = Order(user_id=user_id, items=items)
legacy_db.add(order)
# ALSO write to new service
try:
httpx.post(
"http://order-service/orders",
json=order.to_dict()
)
except Exception as e:
# Log error but don't fail
# New service will eventually sync
logger.error(f"Failed to sync order: {e}")
return order

Pros: Simple Cons: Can get inconsistent if one write fails

# Monolith publishes events
def create_order(user_id, items):
order = Order(user_id=user_id, items=items)
legacy_db.add(order)
# Publish event
event_bus.publish(OrderCreatedEvent(order))
return order
# New service subscribes to events
@event_handler(OrderCreatedEvent)
def sync_order(event: OrderCreatedEvent):
# New service builds its own view
order = Order(
id=event.order_id,
user_id=event.user_id,
...
)
new_db.add(order)

Pros: Eventual consistency, reliable Cons: More complex

Use tools like Debezium to capture database changes:

# Debezium connector config
{
"name": "legacy-db-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "legacy-db",
"database.dbname": "ecommerce",
"table.include.list": "public.orders,public.users",
"transforms": "route",
"transforms.route.type": "org.apache.kafka.connect.transforms.RegexRouter",
"transforms.route.regex": ".*",
"transforms.route.replacement": "order-events"
}
}

Pros: Zero code changes in legacy system Cons: Requires CDC infrastructure

Use feature flags to control traffic:

# Start with 5% traffic to new service
NOTIFICATION_SERVICE_PERCENTAGE = 5
# Week 1: Monitor metrics
NOTIFICATION_SERVICE_PERCENTAGE = 5
# Week 2: No issues, increase
NOTIFICATION_SERVICE_PERCENTAGE = 20
# Week 3: Looking good
NOTIFICATION_SERVICE_PERCENTAGE = 50
# Week 4: Almost there
NOTIFICATION_SERVICE_PERCENTAGE = 90
# Week 5: Full migration!
NOTIFICATION_SERVICE_PERCENTAGE = 100
# Week 6: Remove legacy code
# Delete old notification code from monolith

Once 100% migrated:

  1. Verify no traffic to legacy endpoint

    -- Check logs for any legacy calls
    SELECT COUNT(*) FROM access_logs
    WHERE endpoint = '/legacy/notifications'
    AND timestamp > NOW() - INTERVAL '7 days';
  2. Remove code from monolith

    monolith/services/notification_service.py
    # Delete old notification code
    # ❌ DELETE: monolith/models/notification.py
    # ❌ DELETE: monolith/controllers/notification_controller.py
  3. Drop legacy tables

    -- Archive data first!
    CREATE TABLE notifications_archive AS
    SELECT * FROM notifications;
    -- Then drop
    DROP TABLE notifications;
  4. Update documentation

    • Update architecture diagrams
    • Update API documentation
    • Update team ownership

Extract complete features (vertical slices):

✅ Extract: Notification Service
- Send email
- Send SMS
- Send push notification
- Query notification history
- Manage notification preferences
❌ Don't extract: Email sender only
(incomplete feature)

Extract by technical layer (less common):

Phase 1: Extract presentation layer → New API Gateway
Phase 2: Extract business logic → New services
Phase 3: Extract data access → New databases

Usually not recommended - features are better boundaries.

Create abstraction, switch implementation:

Diagram

Low Risk

No big-bang rewrite. If migration fails, roll back easily. Legacy system keeps working.

Incremental Value

Deliver value continuously. Each extracted service brings immediate benefits.

Learning Opportunity

Learn microservices gradually. Mistakes in first service don’t affect the whole system.

Business Continuity

No feature freeze. Continue delivering features while migrating.


Problem: Migration takes too long, loses momentum

Solution:

  • Set clear deadlines for each service
  • Celebrate milestones
  • Measure progress (% of traffic migrated)
  • Dedicate resources

Problem: Legacy and new system out of sync

Solution:

  • Use event-driven architecture
  • Implement reconciliation jobs
  • Monitor data quality metrics

Problem: Running both systems is complex

Solution:

  • Good observability (tracing across both systems)
  • Clear ownership (team owns migration end-to-end)
  • Time-boxed migration (6-12 months max per service)

Problem: “Leaf service” turns out to have hidden dependencies

Solution:

  • Thorough dependency analysis before starting
  • Visualize dependency graph
  • Extract dependencies first

Soundcloud: Monolith to Microservices (2014-2018)

Section titled “Soundcloud: Monolith to Microservices (2014-2018)”

Starting Point:

  • Ruby on Rails monolith
  • 3M+ users
  • Growing team (50+ engineers)

Strategy:

  • Identified bounded contexts (User, Track, Playlist)
  • Extracted services gradually over 4 years
  • Used event sourcing for data sync

Results:

  • Successfully migrated to 100+ microservices
  • Improved deployment frequency (5x)
  • Reduced time-to-market for features

Key Lesson:

“We didn’t try to do it all at once. We extracted services as we needed to scale or change them.” - Soundcloud Engineering

Starting Point:

  • Monolithic DVD rental application
  • Database couldn’t scale

Strategy:

  • Started by extracting video streaming service
  • Gradually migrated over 7 years
  • Built extensive tooling for microservices

Results:

  • 700+ microservices
  • Global scale (200M+ subscribers)
  • Industry leader in microservices practices

Key Lesson:

“The strangler pattern was essential. We couldn’t pause the business for a rewrite.” - Netflix Engineering

The Mandate:

“All teams will expose their data and functionality through service interfaces. No other form of communication allowed.” - Jeff Bezos, 2002

Strategy:

  • Top-down mandate
  • Each team migrated their module
  • Built AWS tools to support migration

Results:

  • Full SOA by 2006
  • Foundation for AWS
  • Enabled massive innovation

from unleash import UnleashClient
client = UnleashClient(url="http://unleash:4242")
def notify_user(order):
if client.is_enabled("use_notification_service"):
# Call new service
notification_service.notify(order)
else:
# Use legacy code
email_sender.send(order)
# Kong API Gateway configuration
routes:
- name: notifications-new
paths: ["/api/notifications"]
service: notification-service
plugins:
- name: rate-limiting
- name: request-transformer
config:
add:
headers: ["X-Source:gateway"]
- name: notifications-legacy
paths: ["/api/notifications"]
service: legacy-monolith
plugins:
- name: canary
config:
percentage: 10 # 10% to new service
-- Replicate legacy database to new service
CREATE PUBLICATION legacy_pub FOR TABLE orders, users;
-- New service subscribes
CREATE SUBSCRIPTION new_service_sub
CONNECTION 'host=legacy-db dbname=ecommerce'
PUBLICATION legacy_pub;

Don't Rewrite!

Big rewrites fail 70% of the time. Strangler Fig pattern migrates gradually and safely.

Start with Leaves

Extract services with few dependencies first. Learn from early migrations.

Dual-Run is Key

Run old and new systems in parallel. Route traffic gradually. Validate correctness.

Set Time Limits

Migrations can drag on forever. Set clear deadlines and celebrate milestones.



  • “Monolith to Microservices” by Sam Newman (entire book on this topic!)
  • “Working Effectively with Legacy Code” by Michael Feathers
  • Martin Fowler’s Strangler Fig Article - The original
  • “Refactoring Databases” by Scott Ambler
  • Netflix Tech Blog - Real-world migration stories