Low Risk
No big-bang rewrite. If migration fails, roll back easily. Legacy system keeps working.
Imagine you have an old tree house that needs to be replaced. Instead of tearing it down all at once (risky!), you:
This is exactly how the Strangler Fig Pattern works for software!
Statistics:
Famous Failures:
Facade/Proxy Layer
Legacy System
New System
Don’t extract randomly! Choose services with:
✅ Leaf services (few dependencies)
NotificationService → Only receives events, doesn't depend on othersReportingService → Read-only, doesn't affect core business✅ High-change areas (frequent updates)
Pricing Engine → Business rules change oftenRecommendation → ML models updated frequently✅ Performance bottlenecks (need independent scaling)
Search Service → Needs 50 instancesImage Processing → CPU-intensive, needs GPU✅ Clear business capabilities (well-defined boundaries)
Payment ProcessingUser AuthenticationOrder 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 ServiceBefore extracting, create clear interfaces in the monolith.
# BEFORE: Direct implementationclass 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 abstractionfrom 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// BEFORE: Direct implementationpublic class OrderService { public Order createOrder(String userId, List<Item> items) { User user = userRepository.findById(userId);
Order order = new Order(userId, items); orderRepository.save(order);
// Tightly coupled! emailSender.send(user.getEmail(), "Order created"); smsSender.send(user.getPhone(), "Order created");
return order; }}
// AFTER: Interface abstractionpublic interface NotificationService { void notifyOrderCreated(Order order);}
public class LocalNotificationService implements NotificationService { @Override public void notifyOrderCreated(Order order) { emailSender.send(...); smsSender.send(...); }}
public class RemoteNotificationService implements NotificationService { private final RestTemplate restTemplate;
@Override public void notifyOrderCreated(Order order) { // Call microservice restTemplate.postForObject( "http://notification-service/notify", new NotifyRequest(order.getId(), "order_created"), Void.class ); }}
public class OrderService { private final NotificationService notificationService;
public OrderService(NotificationService notificationService) { this.notificationService = notificationService; }
public Order createOrder(String userId, List<Item> items) { User user = userRepository.findById(userId); Order order = new Order(userId, items); orderRepository.save(order);
// Use abstraction (doesn't know if local or remote!) notificationService.notifyOrderCreated(order);
return order; }}Route traffic between legacy and new system.
from fastapi import FastAPI, Requestimport 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)@RestControllerpublic class StranglerRouter {
private final RestTemplate legacyClient; private final RestTemplate newClient; private final Map<String, Integer> migratedEndpoints;
public StranglerRouter() { this.legacyClient = new RestTemplate(); this.legacyClient.setUriTemplateHandler( new DefaultUriBuilderFactory("http://legacy-monolith:8080") );
this.newClient = new RestTemplate(); this.newClient.setUriTemplateHandler( new DefaultUriBuilderFactory("http://notification-service:8080") );
// Feature flags control routing this.migratedEndpoints = Map.of( "/api/notifications", 100, // 100% to new service "/api/orders", 50, // 50% to new service "/api/users", 0 // 0% to new service (still legacy) ); }
@RequestMapping("/**") public ResponseEntity<String> proxy( HttpServletRequest request, @RequestBody(required = false) String body ) { String path = request.getRequestURI();
// Check if endpoint is migrated int migrationPercentage = migratedEndpoints.getOrDefault(path, 0);
// Random routing based on percentage boolean routeToNew = Math.random() * 100 < migrationPercentage;
RestTemplate client = routeToNew ? newClient : legacyClient;
// Forward request HttpEntity<String> entity = new HttpEntity<>(body); return client.exchange( path, HttpMethod.valueOf(request.getMethod()), entity, String.class ); }}Build the new service with its own database.
from fastapi import FastAPIfrom sqlalchemy import create_enginefrom 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@RestController@RequestMapping("/")public class NotificationController {
// Own database! @Autowired private NotificationRepository notificationRepo;
@Autowired private EmailService emailService;
@Autowired private SmsService smsService;
@PostMapping("/notify") public NotifyResponse notify(@RequestBody NotifyRequest request) { // Store notification in OWN database Notification notification = new Notification( request.getOrderId(), request.getType(), "pending" ); notificationRepo.save(notification);
// Send via external services emailService.send(notification); smsService.send(notification);
notification.setStatus("sent"); notificationRepo.save(notification);
return new NotifyResponse("sent"); }
@GetMapping("/notifications/{orderId}") public List<Notification> getNotifications(@PathVariable String orderId) { // Query OWN database return notificationRepo.findByOrderId(orderId); }}Challenge: Legacy and new service need the same data during transition!
# In monolith during transitiondef 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 orderPros: Simple Cons: Can get inconsistent if one write fails
# Monolith publishes eventsdef 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 serviceNOTIFICATION_SERVICE_PERCENTAGE = 5
# Week 1: Monitor metricsNOTIFICATION_SERVICE_PERCENTAGE = 5
# Week 2: No issues, increaseNOTIFICATION_SERVICE_PERCENTAGE = 20
# Week 3: Looking goodNOTIFICATION_SERVICE_PERCENTAGE = 50
# Week 4: Almost thereNOTIFICATION_SERVICE_PERCENTAGE = 90
# Week 5: Full migration!NOTIFICATION_SERVICE_PERCENTAGE = 100
# Week 6: Remove legacy code# Delete old notification code from monolithOnce 100% migrated:
Verify no traffic to legacy endpoint
-- Check logs for any legacy callsSELECT COUNT(*) FROM access_logsWHERE endpoint = '/legacy/notifications'AND timestamp > NOW() - INTERVAL '7 days';Remove code from monolith
# Delete old notification code# ❌ DELETE: monolith/models/notification.py# ❌ DELETE: monolith/controllers/notification_controller.pyDrop legacy tables
-- Archive data first!CREATE TABLE notifications_archive ASSELECT * FROM notifications;
-- Then dropDROP TABLE notifications;Update documentation
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 GatewayPhase 2: Extract business logic → New servicesPhase 3: Extract data access → New databasesUsually not recommended - features are better boundaries.
Create abstraction, switch implementation:
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:
Problem: Legacy and new system out of sync
Solution:
Problem: Running both systems is complex
Solution:
Problem: “Leaf service” turns out to have hidden dependencies
Solution:
Starting Point:
Strategy:
Results:
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:
Strategy:
Results:
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:
Results:
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 configurationroutes: - 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 serviceCREATE PUBLICATION legacy_pub FOR TABLE orders, users;
-- New service subscribesCREATE SUBSCRIPTION new_service_subCONNECTION '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.