- Introduction
- Strangler Fig Pattern Core Concepts
- Migration Strategy Comparison: Strangler Fig vs Big Bang vs Parallel Run
- Architecture Design and Implementation
- Operational Considerations and Troubleshooting
- Failure Cases and Recovery Strategies
- Production Migration Checklist
- Step-by-step Migration Roadmap
- Core Principles for Applying the Strangler Fig Pattern
- Conclusion
- References

Introduction
Migrating monolithic applications to microservices is one of the most frequently encountered challenges in modern software engineering. In an environment where years of accumulated business logic, intricately intertwined data models, and 24/7 availability SLA requirements coexist, you must answer the question: "How do we transition architecture without service disruption?"
In 2004, after observing a strangler fig on a trip to Australia, Martin Fowler drew on that natural phenomenon to propose a software migration pattern. A strangler fig germinates from a seed at the top of a host tree, gradually sends roots downward, wraps around the host, and ultimately replaces it. The Strangler Fig pattern works on exactly the same principle: it gradually builds new microservices around the existing monolith and replaces the existing system step by step.
Large companies such as Netflix, Google, Amazon and Microsoft have adopted this pattern and succeeded in migrating legacy systems. The concept is simple, but applying it in a real production environment involves a great many detailed decisions: Facade design, the Anti-Corruption Layer, data synchronization, Feature Flag management, rollback strategy and more.
This article covers the whole picture: the core principles of the Strangler Fig pattern, architecture design, Facade and routing implementation, Feature Flag integration, data migration strategy, failure cases and recovery approaches, and a checklist for running it in production.
Strangler Fig Pattern Core Concepts
Pattern Principles
The Strangler Fig pattern consists of three core phases.
- Transform: Identify specific functionality in the monolith and reimplement it as a new microservice.
- Coexist: The existing monolith and new microservice operate simultaneously, with the Facade layer routing traffic appropriately.
- Eliminate: Once the new service stabilizes, remove the functionality from the monolith and completely switch traffic to the new service.
By repeating this process and migrating monolith functionality one by one to microservices, the monolith eventually disappears and only the microservices architecture remains.
Phase 1: migration begins
┌──────────────┐ ┌──────────────┐
│ Facade │────▶│ Monolith │
│ (Proxy) │ │ ┌────────┐ │
│ │ │ │Service │ │
│ │ │ │ A │ │
│ │ │ ├────────┤ │
│ │ │ │Service │ │
│ │ │ │ B │ │
│ │ │ ├────────┤ │
│ │ │ │Service │ │
│ │ │ │ C │ │
│ │ │ └────────┘ │
└──────────────┘ └──────────────┘
Phase 2: incremental extraction
┌──────────────┐ ┌──────────────┐
│ Facade │────▶│ Monolith │
│ (Proxy) │ │ ┌────────┐ │
│ │ │ │Service │ │
│ │─┐ │ │ B │ │
│ │ │ │ ├────────┤ │
│ │ │ │ │Service │ │
│ │ │ │ │ C │ │
└──────────────┘ │ └──────────────┘
│
│ ┌──────────────┐
└──▶│ Microservice │
│ A │
└──────────────┘
Phase 3: full transition
┌──────────────┐ ┌──────────────┐
│ Facade │────▶│ Microservice │
│ (Proxy) │ │ A │
│ │ └──────────────┘
│ │ ┌──────────────┐
│ │────▶│ Microservice │
│ │ │ B │
│ │ └──────────────┘
│ │ ┌──────────────┐
│ │────▶│ Microservice │
│ │ │ C │
└──────────────┘ └──────────────┘
Role of the Facade Layer
The Facade (or Proxy) is the core infrastructure component of the Strangler Fig pattern. It receives all client requests and transparently routes them to the legacy monolith or new microservices. From the client's perspective, requests are sent to the same endpoint, so they are unaware of backend architecture changes.
The main functions handled by the Facade are as follows.
- Request Routing: Distributing requests to legacy or new services based on URL paths, HTTP headers, query parameters, etc.
- Protocol Translation: Converting legacy system SOAP/XML interfaces to new service REST/JSON
- Traffic Distribution: Ratio-based traffic distribution for canary deployments
- Failure Isolation: Automatic fallback to legacy when new service fails
In practice, API Gateways (Kong, AWS API Gateway, NGINX), Service Meshes (Istio, Linkerd), or custom Reverse Proxies serve as the Facade.
Anti-Corruption Layer (ACL)
The Anti-Corruption Layer is a pattern that originates in Domain-Driven Design (DDD). It performs translation between the domain model of the legacy system and the domain model of the new microservice. It forms a barrier so that the monolith's legacy API or data model does not corrupt the design of the new service.
The ACL is implemented as a combination of the following three sub-patterns.
- Facade pattern: simplifies the legacy system's complex interface
- Adapter pattern: converts legacy data formats into the new domain model
- Translator pattern: defines the bidirectional data mapping rules
The core principle of an ACL is that it must be temporary (tactical). When the migration completes, the ACL has to be removed along with it. If the ACL stays as a permanent layer, system complexity grows and maintenance costs accumulate.
Migration Strategy Comparison: Strangler Fig vs Big Bang vs Parallel Run
Before choosing a migration strategy you have to understand clearly what each approach is like and how risky it is. The table below compares the three representative strategies.
| Category | Strangler Fig | Big Bang | Parallel Run |
|---|---|---|---|
| Transition | Incremental by feature | Full system switch | Switch after dual operation |
| Downtime | Zero downtime | Planned downtime required | Zero downtime |
| Risk Level | Low (per feature) | Very high (entire system) | Medium (data consistency) |
| Rollback Ease | Instant rollback (routing change) | Very difficult (full restore) | Easy (traffic switching) |
| Duration | Months to years | Weeks to months | Months |
| Cost | Gradual increase | Upfront concentrated investment | Dual infrastructure cost |
| ROI Timeline | Right after first service deployment | After full transition | After verification |
| Suitable For | Large-scale legacy systems | Small simple systems | High-reliability (finance/healthcare) |
| Team Load | Distributed (parallel by feature) | Concentrated (all teams) | High (dual operation) |
| Data Sync | Individual strategy per feature | Batch migration | Real-time sync required |
The Big Bang strategy rebuilds the entire system at once and switches over in a single step, so the switch is only possible once everything is perfectly ready. A delay in one module delays the whole Go-Live, and a failed switch requires a full rollback - a high-risk strategy.
The Parallel Run strategy can also be seen as one phase of Strangler Fig. It runs the legacy and the new system at the same time, compares and verifies the results, and only then makes the final switch. It is preferred in environments where data accuracy is absolutely critical, such as finance or healthcare systems.
The Strangler Fig strategy spreads the risk through an incremental approach, and ROI starts the moment the first microservice is deployed. The migration order can be adjusted dynamically according to business priorities, which lets you respond quickly to changes in the competitive landscape.
Architecture Design and Implementation
Facade (Proxy) Routing Implementation
The first implementation step of the Strangler Fig pattern is building the Facade routing layer. Below is API Gateway routing logic implemented in TypeScript.
// strangler-fig-router.ts
// Strangler Fig pattern - API Gateway routing handler
interface RouteConfig {
path: string
target: 'legacy' | 'microservice'
serviceUrl: string
migrationStatus: 'not_started' | 'canary' | 'partial' | 'complete'
canaryPercentage?: number // percentage of traffic to send to the new service (0-100)
fallbackToLegacy: boolean
healthCheckUrl: string
}
interface ServiceHealth {
isHealthy: boolean
lastChecked: Date
consecutiveFailures: number
}
const routeConfigs: RouteConfig[] = [
{
path: '/api/orders/*',
target: 'microservice',
serviceUrl: 'http://order-service:8080',
migrationStatus: 'complete',
fallbackToLegacy: true,
healthCheckUrl: 'http://order-service:8080/health',
},
{
path: '/api/products/*',
target: 'microservice',
serviceUrl: 'http://product-service:8081',
migrationStatus: 'canary',
canaryPercentage: 20,
fallbackToLegacy: true,
healthCheckUrl: 'http://product-service:8081/health',
},
{
path: '/api/users/*',
target: 'legacy',
serviceUrl: 'http://monolith:3000',
migrationStatus: 'not_started',
fallbackToLegacy: false,
healthCheckUrl: 'http://monolith:3000/health',
},
]
const LEGACY_BASE_URL = 'http://monolith:3000'
const healthCache = new Map<string, ServiceHealth>()
async function checkServiceHealth(config: RouteConfig): Promise<boolean> {
const cached = healthCache.get(config.path)
if (cached && Date.now() - cached.lastChecked.getTime() < 5000) {
return cached.isHealthy
}
try {
const response = await fetch(config.healthCheckUrl, {
signal: AbortSignal.timeout(2000),
})
const isHealthy = response.ok
healthCache.set(config.path, {
isHealthy,
lastChecked: new Date(),
consecutiveFailures: isHealthy ? 0 : (cached?.consecutiveFailures ?? 0) + 1,
})
return isHealthy
} catch {
const failures = (cached?.consecutiveFailures ?? 0) + 1
healthCache.set(config.path, {
isHealthy: false,
lastChecked: new Date(),
consecutiveFailures: failures,
})
return false
}
}
function shouldRouteToMicroservice(config: RouteConfig, requestId: string): boolean {
if (config.migrationStatus === 'complete') return true
if (config.migrationStatus === 'not_started') return false
if (config.migrationStatus === 'canary' && config.canaryPercentage) {
// deterministic routing by request ID (the same user always hits the same service)
const hash = simpleHash(requestId)
return hash % 100 < config.canaryPercentage
}
return false
}
function simpleHash(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // convert to a 32-bit integer
}
return Math.abs(hash)
}
async function routeRequest(path: string, requestId: string, request: Request): Promise<Response> {
const config = routeConfigs.find((r) => path.startsWith(r.path.replace('/*', '')))
if (!config) {
// no matching route - forward to legacy
return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
}
const useNewService = shouldRouteToMicroservice(config, requestId)
if (useNewService) {
const isHealthy = await checkServiceHealth(config)
if (isHealthy) {
try {
const response = await fetch(`${config.serviceUrl}${path}`, { ...request })
// record the success metric
recordMetric('route_to_microservice', config.path, 'success')
return response
} catch (error) {
recordMetric('route_to_microservice', config.path, 'error')
if (config.fallbackToLegacy) {
console.warn(`Microservice failed, falling back to legacy: ${config.path}`)
recordMetric('fallback_to_legacy', config.path, 'triggered')
return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
}
throw error
}
} else if (config.fallbackToLegacy) {
recordMetric('fallback_to_legacy', config.path, 'health_check_failed')
return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
}
}
return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
}
function recordMetric(type: string, path: string, status: string): void {
// send to a metrics system such as Prometheus or Datadog
console.log(`[METRIC] ${type} | path=${path} | status=${status} | ts=${Date.now()}`)
}
There are three key points in this implementation. First, canaryPercentage lets you adjust the traffic ratio incrementally. Second, deterministic routing based on the request ID means the same user is always routed to the same service. Third, the fallbackToLegacy option falls back to legacy automatically when the microservice fails.
Anti-Corruption Layer Implementation
The ACL resolves the domain model mismatch between the legacy monolith and the new microservice. Below is an example ACL for an order service implemented in Python.
# anti_corruption_layer.py
# Order service Anti-Corruption Layer
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
import httpx
# === New microservice domain model ===
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: Decimal
total_price: Decimal
@dataclass
class Order:
order_id: str
customer_id: str
items: list[OrderItem]
status: OrderStatus
total_amount: Decimal
currency: str
created_at: datetime
updated_at: datetime
# === ACL: translating legacy monolith data ===
class LegacyOrderACL:
"""
Anti-Corruption Layer that converts the legacy monolith's order data
model into the new microservice's domain model.
The legacy system has the following problems:
- order status is a numeric code (0, 1, 2, 3, 4)
- amounts are integers (in cents)
- dates are Unix timestamps
- product details are stored as a JSON string in the same table as the order
"""
# legacy numeric code -> new domain status mapping
STATUS_MAP: dict[int, OrderStatus] = {
0: OrderStatus.PENDING,
1: OrderStatus.CONFIRMED,
2: OrderStatus.SHIPPED,
3: OrderStatus.DELIVERED,
4: OrderStatus.CANCELLED,
}
def __init__(self, legacy_api_url: str):
self.legacy_api_url = legacy_api_url
self.client = httpx.AsyncClient(
base_url=legacy_api_url,
timeout=10.0,
)
async def get_order(self, order_id: str) -> Optional[Order]:
"""Fetch the order from the legacy API and convert it to the new domain model."""
response = await self.client.get(f"/legacy/orders/{order_id}")
if response.status_code == 404:
return None
legacy_data = response.json()
return self._translate_order(legacy_data)
def _translate_order(self, legacy: dict) -> Order:
"""Convert legacy order data into a new Order domain object."""
items = self._translate_items(legacy.get("items_json", "[]"))
total = Decimal(legacy["total_cents"]) / 100
return Order(
order_id=str(legacy["id"]),
customer_id=str(legacy["cust_id"]),
items=items,
status=self.STATUS_MAP.get(legacy["stat_cd"], OrderStatus.PENDING),
total_amount=total,
currency=legacy.get("curr", "KRW"),
created_at=datetime.fromtimestamp(legacy["created_ts"]),
updated_at=datetime.fromtimestamp(legacy["modified_ts"]),
)
def _translate_items(self, items_json: str) -> list[OrderItem]:
"""Convert the legacy JSON-string product list into a list of OrderItem."""
import json
raw_items = json.loads(items_json)
return [
OrderItem(
product_id=str(item["pid"]),
product_name=item.get("pname", "Unknown"),
quantity=item["qty"],
unit_price=Decimal(item["price_cents"]) / 100,
total_price=Decimal(item["price_cents"] * item["qty"]) / 100,
)
for item in raw_items
]
def translate_to_legacy(self, order: Order) -> dict:
"""Convert a new Order back into the legacy format (used when updating the legacy system)."""
reverse_status = {v: k for k, v in self.STATUS_MAP.items()}
return {
"id": int(order.order_id),
"cust_id": int(order.customer_id),
"stat_cd": reverse_status.get(order.status, 0),
"total_cents": int(order.total_amount * 100),
"curr": order.currency,
"created_ts": int(order.created_at.timestamp()),
"modified_ts": int(order.updated_at.timestamp()),
}
async def close(self):
await self.client.aclose()
This ACL converts a data model that carries the legacy system's technical debt - numeric status codes, amounts in cents, Unix timestamps - into the clean domain model of the new service. It also provides the reverse conversion (translate_to_legacy), so it supports situations during the migration where the legacy system has to be updated.
Feature Flag Based Traffic Switching
A Feature Flag separates code deployment from feature release, which lets you control traffic dynamically at runtime. In the Strangler Fig pattern the Feature Flag is the core mechanism for controlling the state of the migration at the code level.
// feature-flag-migration.ts
// Feature Flag based migration control
interface MigrationFlag {
name: string
enabled: boolean
rolloutPercentage: number // 0-100
allowedUserIds?: string[] // whitelist (internal testers)
excludedUserIds?: string[] // blacklist (VIP customers and others switched conservatively)
enabledRegions?: string[] // rollout by region
createdAt: string
updatedAt: string
metadata: Record<string, string>
}
class MigrationFeatureFlagService {
private flags: Map<string, MigrationFlag> = new Map()
private refreshIntervalMs = 30_000 // refresh every 30 seconds
constructor(private flagSource: string) {
this.startPeriodicRefresh()
}
async loadFlags(): Promise<void> {
try {
const response = await fetch(this.flagSource)
const data: MigrationFlag[] = await response.json()
this.flags = new Map(data.map((f) => [f.name, f]))
} catch (error) {
console.error('Feature flag load failed, keeping the cached values:', error)
// on a load failure keep the existing cache - the safe default behavior
}
}
isEnabled(
flagName: string,
context: {
userId: string
region?: string
sessionId?: string
}
): boolean {
const flag = this.flags.get(flagName)
if (!flag || !flag.enabled) return false
// check the blacklist (highest priority - protects VIP customers)
if (flag.excludedUserIds?.includes(context.userId)) {
return false
}
// check the whitelist (internal testers get it first)
if (flag.allowedUserIds?.includes(context.userId)) {
return true
}
// check the per-region restriction
if (flag.enabledRegions && context.region) {
if (!flag.enabledRegions.includes(context.region)) {
return false
}
}
// percentage-based rollout (deterministic distribution by user ID)
if (flag.rolloutPercentage < 100) {
const hash = this.consistentHash(context.userId + flagName)
return hash % 100 < flag.rolloutPercentage
}
return true
}
private consistentHash(input: string): number {
let hash = 5381
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) + hash + input.charCodeAt(i)
hash = hash & hash
}
return Math.abs(hash)
}
private startPeriodicRefresh(): void {
setInterval(() => this.loadFlags(), this.refreshIntervalMs)
}
}
// usage example: order service migration
const flagService = new MigrationFeatureFlagService('http://config-server:8888/flags/migration')
async function processOrder(userId: string, orderData: unknown) {
const useNewOrderService = flagService.isEnabled('migration.order-service', {
userId,
region: 'ap-northeast-2',
})
if (useNewOrderService) {
// process the order with the new microservice
return await newOrderService.createOrder(orderData)
} else {
// process the order with the legacy monolith
return await legacyMonolith.createOrder(orderData)
}
}
If you manage the Feature Flag configuration in an external Config Server, you can adjust the traffic ratio or roll back immediately without redeploying code. In practice, an effective strategy is to put VIP customers on the blacklist and switch them over only once stability has been well proven, and to put internal testers on the whitelist so they validate it first.
Data Migration and Synchronization
Data migration is the most complex area of applying the Strangler Fig pattern. Data consistency has to be guaranteed for as long as the monolith and the microservices coexist, and the strategies for that fall broadly into three kinds.
Strategy 1: Shared Database (Transitional)
This is the simplest approach: in the early stage of the migration the monolith and the microservice share the same database. It is simple to implement, but a database schema change affects both sides and it undermines the microservice's independence. It should be used only as a short-term tactical choice.
Strategy 2: Dual Write (Risky)
This approach writes to both systems at the same time. It has the fundamental problem that a write succeeding on one side and failing on the other produces a data mismatch, which is why it is classified as an anti-pattern. Even with distributed transactions you cannot avoid the performance hit and the added complexity.
Strategy 3: Change Data Capture (Recommended)
CDC (Change Data Capture) captures the database's change log in real time and propagates it to another system. Tools such as Debezium, AWS DMS and Oracle GoldenGate are used for this.
# docker-compose.yml - an example Debezium CDC setup
version: '3.8'
services:
# legacy monolith DB (MySQL)
legacy-db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: legacy_password
MYSQL_DATABASE: monolith
ports:
- '3306:3306'
volumes:
- legacy-data:/var/lib/mysql
command: >
--server-id=1
--log-bin=mysql-bin
--binlog-format=ROW
--binlog-row-image=FULL
--gtid-mode=ON
--enforce-gtid-consistency=ON
# Kafka - CDC event streaming
kafka:
image: confluentinc/cp-kafka:7.6.0
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
ports:
- '9092:9092'
depends_on:
- zookeeper
# Debezium Connect - the CDC connector
debezium:
image: debezium/connect:2.5
environment:
BOOTSTRAP_SERVERS: kafka:9092
GROUP_ID: strangler-fig-cdc
CONFIG_STORAGE_TOPIC: cdc_configs
OFFSET_STORAGE_TOPIC: cdc_offsets
STATUS_STORAGE_TOPIC: cdc_status
ports:
- '8083:8083'
depends_on:
- kafka
- legacy-db
# new microservice DB (PostgreSQL)
order-service-db:
image: postgres:16
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_service
POSTGRES_PASSWORD: secure_password
ports:
- '5432:5432'
volumes:
- order-data:/var/lib/postgresql/data
volumes:
legacy-data:
order-data:
# cdc_consumer.py
# Consume Debezium CDC events and sync them into the microservice DB
import json
from kafka import KafkaConsumer
from datetime import datetime
from decimal import Decimal
class CDCEventConsumer:
"""
Consumer that captures changes in the legacy DB with Debezium CDC
and synchronizes them into the new microservice DB.
"""
def __init__(self, kafka_servers: str, topic: str, order_repository):
self.consumer = KafkaConsumer(
topic,
bootstrap_servers=kafka_servers,
group_id='order-service-cdc-sync',
auto_offset_reset='earliest',
enable_auto_commit=False, # manual commit guarantees exactly-once
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
)
self.order_repo = order_repository
self.processed_count = 0
self.error_count = 0
def start(self):
"""Start the CDC event consumption loop."""
print(f"CDC Consumer started - watching the legacy DB for changes...")
for message in self.consumer:
try:
self._process_event(message.value)
self.consumer.commit()
self.processed_count += 1
if self.processed_count % 1000 == 0:
print(f"processed: {self.processed_count}, "
f"errors: {self.error_count}")
except Exception as e:
self.error_count += 1
print(f"CDC event processing failed: {e}")
# send to the Dead Letter Queue
self._send_to_dlq(message.value, str(e))
def _process_event(self, event: dict):
"""Parse the Debezium CDC event and dispatch it to the right handler."""
operation = event.get('op') # c=create, u=update, d=delete, r=read(snapshot)
after = event.get('after') # data after the change
before = event.get('before') # data before the change
if operation in ('c', 'r'):
# INSERT or snapshot
self._handle_create(after)
elif operation == 'u':
# UPDATE
self._handle_update(before, after)
elif operation == 'd':
# DELETE
self._handle_delete(before)
def _handle_create(self, data: dict):
"""Apply a legacy DB INSERT to the new service DB."""
order = self._transform_legacy_to_new(data)
self.order_repo.upsert(order)
def _handle_update(self, before: dict, after: dict):
"""Apply a legacy DB UPDATE to the new service DB."""
order = self._transform_legacy_to_new(after)
self.order_repo.upsert(order)
def _handle_delete(self, data: dict):
"""Apply a legacy DB DELETE to the new service DB."""
order_id = str(data['id'])
self.order_repo.soft_delete(order_id)
def _transform_legacy_to_new(self, legacy: dict) -> dict:
"""Convert the legacy data format into the new microservice format."""
status_map = {0: 'pending', 1: 'confirmed', 2: 'shipped',
3: 'delivered', 4: 'cancelled'}
return {
'order_id': str(legacy['id']),
'customer_id': str(legacy['cust_id']),
'status': status_map.get(legacy.get('stat_cd', 0), 'pending'),
'total_amount': Decimal(legacy.get('total_cents', 0)) / 100,
'currency': legacy.get('curr', 'KRW'),
'created_at': datetime.fromtimestamp(legacy.get('created_ts', 0)),
'updated_at': datetime.fromtimestamp(legacy.get('modified_ts', 0)),
'sync_source': 'cdc_legacy',
'synced_at': datetime.utcnow(),
}
def _send_to_dlq(self, event: dict, error_msg: str):
"""Send an event that failed processing to the Dead Letter Queue."""
# send it to the DLQ topic and keep it there for manual reprocessing
print(f"DLQ send: order_id={event.get('after', {}).get('id')}, "
f"error={error_msg}")
The key advantage of the CDC approach is that data changes can be propagated to the new service without modifying the legacy application code at all. Because it reads the database's WAL (Write-Ahead Log) or binlog directly, no involvement from the application layer is needed.
Operational Considerations and Troubleshooting
Problem: Facade Becoming a Single Point of Failure (SPOF)
In the Strangler Fig pattern the Facade/Proxy layer is the gateway every request passes through, so it can become a single point of failure itself. A Facade outage makes both the monolith and the microservices unreachable.
Solution:
- Deploy the Facade as multiple instances and put a load balancer in front of it
- Keep the routing logic minimal so that the Facade does not turn into "another monolith"
- Apply a Circuit Breaker so that a downstream failure does not propagate into the Facade
- Using a service mesh (Istio, Linkerd) lets you delegate the Facade's functions to the infrastructure layer
Problem: Facade Itself Becoming a Monolith
As the migration proceeds, routing rules, protocol translation, authentication/authorization, rate limiting and other logic pile up in the Facade, and the Facade itself can grow into a complex monolith. This phenomenon is called the "Proxy Monolith" anti-pattern.
Solution:
- Limit the Facade's responsibility to pure routing and delegate business logic to the microservices
- Split cross-cutting concerns such as authentication/authorization and rate limiting out into a service mesh sidecar
- Manage the Facade's routing rules in an external configuration file or a Config Server so that routing can be changed without a code change
Data Consistency Issues
During the migration the legacy system and the microservice can end up holding different values for the same data. CDC lag, network partitions and mismatched transaction boundaries are the causes.
Solution:
- Build a data validation pipeline that periodically compares and verifies the data on both sides
- Accept eventual consistency, but add a synchronous confirmation step for business-critical data (payments, inventory and the like)
- Monitor the CDC consumer's lag and raise an alert when it exceeds the threshold
Problem: Monolith Continuing to Grow
Business requirements keep arriving while the migration is under way. If new features are added to the monolith, the migration scope keeps growing and the migration never finishes.
Solution:
- A "freeze" policy: establish the rule that new features must be developed as microservices
- Manage code changes to modules slated for migration through an Architecture Decision Record (ADR) to keep them visible
- Visualize the migration progress on a dashboard so the whole organization shares the current state
Failure Cases and Recovery Strategies
Failure Case 1: Incorrect Decomposition Boundaries
An e-commerce company split the Order service out as a microservice but overlooked the tight coupling between Order and Payment. Payment processing, inventory deduction and loyalty-point accrual were all handled in the same transaction as order creation, and separating out only the order into its own service created a distributed transaction problem.
Recovery Strategy: order and payment were redefined as a single Bounded Context, and the Saga pattern was introduced to turn the distributed transaction into event-based compensating transactions. DDD's Event Storming was applied to the initial domain analysis to re-establish the Bounded Context boundaries.
Failure Case 2: Permanent Dual Write
While migrating its account service, a financial services company introduced Dual Write - writing data to both the legacy and the new system - as a "temporary" measure. The completion date kept slipping, however, so the Dual Write stayed in place for more than 2 years, and in the meantime the number of data mismatches between the two sides reached the thousands.
Recovery Strategy: the Dual Write was stopped immediately and replaced with CDC-based one-way synchronization. The mismatched records were identified with a batch consistency-verification script, and the new system's data was corrected against the legacy data as the source of truth. A migration completion deadline was set explicitly and included in the SLA.
Failure Case 3: Incomplete Migration State Becoming Permanent
A social media platform started migrating its user profile service 3 years ago, but after moving only 70% of the core functionality the migration project effectively stalled. The ACL, the compatibility layer and the temporary data synchronization logic stayed in production permanently, and maintenance costs doubled.
Recovery Strategy: the whole migration scope was re-evaluated, and the low-business-value features among the remaining 30% were deprecated rather than migrated. The migration of the remaining core functionality was given a 6-month timebox and a dedicated team. Once the migration finished, the ACL and the compatibility layer were removed in stages.
Rollback Strategy Design
Every migration step has to come with an immediate rollback plan. The key elements of an effective rollback strategy are as follows.
# rollback-playbook.yaml
# Microservice migration rollback playbook
rollback_triggers:
- condition: 'new service error rate > 1% (sustained for 5 minutes)'
severity: warning
action: 'reduce the canary percentage to the previous step'
- condition: 'new service P99 latency > 200% of legacy (sustained for 10 minutes)'
severity: critical
action: 'switch all traffic to legacy immediately'
- condition: 'data mismatches > 10 per minute'
severity: critical
action: 'stop CDC sync, switch traffic to legacy, verify data consistency'
rollback_procedures:
instant_rollback:
description: 'immediate rollback by changing API Gateway routing (takes ~10 seconds)'
steps:
- 'change the target service to legacy in the API Gateway routing rules'
- 'set the Feature Flag to disabled'
- 'confirm on the monitoring dashboard that the legacy service is healthy'
- 'analyze the cause of the failure and start the postmortem'
estimated_time: '10 seconds ~ 1 minute'
gradual_rollback:
description: 'reduce the canary percentage step by step (takes ~30 minutes)'
steps:
- 'reduce the canary percentage to 50% of its current value'
- 'wait 5 minutes, then check the error rate and latency'
- 'if healthy, hold; if not, set the canary percentage to 0%'
- 'run the data consistency verification script'
estimated_time: '30 minutes ~ 1 hour'
data_rollback:
description: 'recovery procedure when a data mismatch occurs'
steps:
- 'stop the CDC pipeline immediately'
- 'switch traffic to legacy'
- 'compare the changes in the new service DB against the legacy DB'
- 'identify the mismatched data and run the correction script'
- 'restart the CDC pipeline after verifying consistency'
estimated_time: '1 hour ~ 4 hours'
monitoring_during_rollback:
metrics:
- 'error rate (5xx responses)'
- 'P50/P95/P99 latency'
- 'requests per second (RPS)'
- 'database connection pool utilization'
- 'CDC consumer lag'
alerts:
- 'Slack notification on rollback start/finish'
- 'report on the data consistency verification result'
- 'automatic creation of the postmortem schedule'
The heart of this rollback playbook is defining the trigger conditions in advance. Only by setting quantitative criteria clearly - error rate, latency, number of data mismatches - can you avoid a delayed judgment call during operations. A criterion of "we will look at the situation and decide" is the most common cause of delayed decision-making during an incident.
Production Migration Checklist
Pre-migration Preparation
- Domain analysis complete: Bounded Contexts identified through Event Storming or Domain Storytelling
- Priority decided for the services to migrate (by business value, technical debt and coupling)
- Facade/API Gateway architecture designed and built
- Feature Flag infrastructure built (LaunchDarkly, Unleash, or an in-house implementation)
- CDC pipeline designed (Debezium, AWS DMS and the like)
- Monitoring/observability infrastructure prepared (Prometheus, Grafana, Jaeger)
- Rollback playbook written and the team trained on it
- Performance baseline measured: the current monolith's latency, throughput and error rate
During Migration
- Start the canary deployment with 1-5% of initial traffic
- Compare and verify the new service's responses against legacy with Shadow Testing
- Set up CDC consumer lag monitoring (threshold within 30 seconds)
- Run the data consistency verification batch job periodically
- Increase the canary percentage in stages: 5% -> 10% -> 25% -> 50% -> 75% -> 100%
- Confirm stability for at least 24 to 72 hours at each stage before moving to the next
- Keep confirming that the error rate and latency stay within the SLA
- Report migration progress weekly (traffic switch ratio, open issues)
Post-migration
- Legacy code removed and the related database tables archived
- ACL and compatibility layer removed
- CDC pipeline stopped and its resources released
- Feature Flags cleaned up (migration-related flags removed)
- Facade routing rules cleaned up (legacy paths removed)
- Postmortem and retrospective: success factors, improvements and lessons documented
- The migration experience registered in the organization's Knowledge Base
Step-by-step Migration Roadmap
When applying the Strangler Fig pattern in a real production environment, the following 4-phase roadmap is recommended.
Phase 1: Analysis and Preparation (4-8 weeks)
This phase lays the groundwork for the migration. An Event Storming workshop identifies the domain boundaries of the current monolith, and the dependencies between services are visualized as a graph. The service with the lowest coupling and the greatest independence is selected as the first migration target (the pilot).
At the same time, the Facade/API Gateway, the Feature Flag system, the CDC pipeline and the monitoring infrastructure are built. This infrastructure is reused in every later phase of the migration.
Phase 2: Pilot Migration (4-6 weeks)
The selected pilot service is implemented as a microservice, and the switch begins with a canary deployment taking 1% of production traffic. The goal of this phase is to validate not only the stability of the service itself but the entire migration process - Facade routing, CDC synchronization, Feature Flag management and the rollback procedure.
The migration process is refined by folding in the problems and improvements found during the pilot. The process established in this phase becomes the template for every later service migration.
Phase 3: Full-scale Migration (months to years)
Using the validated process, the remaining services are migrated one after another. Several service migrations can run in parallel, but services with dependencies between them have to keep their order.
The migration order is adjusted dynamically according to business priorities. To respond to changes in the market you can pull a particular service's migration forward, or push back a service where stability comes first.
Phase 4: Cleanup and Completion (4-8 weeks)
Once every feature has moved to microservices, the legacy monolith code is removed. The infrastructure that was only needed during the transition - the ACL, the compatibility layer, the CDC pipeline - is cleaned up. A postmortem is held so that the success factors and improvements from the whole migration are shared at the organizational level.
An unfinished migration becoming permanent is the most common failure pattern, so it matters that this phase is not skipped. Set the migration completion deadline explicitly and secure executive support for it.
Core Principles for Applying the Strangler Fig Pattern
Here are the core principles for applying the Strangler Fig pattern successfully.
Incremental Transition Principle: migrate only one feature at a time. Switching several features at once makes it harder to identify the cause of a failure and widens the rollback scope. Each feature's migration has to be independently rollback-able.
Reversibility Principle: every migration step must be reversible. It has to be possible to return to the previous state within seconds by changing the Facade routing and toggling a Feature Flag, and this rollback procedure has to be tested in advance.
Observability Principle: it has to be possible to compare system behavior before and after the migration quantitatively. Collect metrics such as error rate, latency (P50/P95/P99) and throughput at the Facade layer, and compare the legacy and new service metrics on the same dashboard.
Completeness Principle: once you start a migration, finish it. If an incomplete migration state becomes permanent, the costs pile up - double maintenance, growing ACL complexity, slower development. Include an explicit completion deadline and the cleanup work in the plan.
Domain-first Principle: split services along business domain boundaries, not along whatever is technically convenient. Use DDD's Bounded Context concept to set the service boundaries, and validate those boundaries together with domain experts through Event Storming. Wrong decomposition boundaries can produce the worst possible outcome: a distributed monolith.
Conclusion
The Strangler Fig pattern is the most widely proven approach for moving from a monolith to microservices. Since Martin Fowler proposed it in 2004 it has been applied in practice at countless companies for more than 20 years, and its core principle of incremental transition has not changed.
A simple concept does not make execution easy, however. Facade design, ACL implementation, data synchronization, Feature Flag management, rollback strategy - each step demands a great many engineering decisions, and the quality of those decisions determines whether the migration succeeds. Organizational agreement and executive support matter as much as technical execution. A migration started without clear agreement on its purpose, scope, schedule and completion criteria is very likely to become permanently stuck in an incomplete state.
Recently there have also been more cases of the reverse migration - merging over-decomposed microservices back together. The Strangler Fig pattern applies equally well in that direction. What matters is not blind devotion to a particular architectural style, but choosing the architecture that fits the business requirements and the organization's capabilities, and transitioning to it safely.
References
- Martin Fowler - StranglerFigApplication - The original article on the Strangler Fig pattern. The first definition of the software migration pattern Martin Fowler proposed in 2004, inspired by the Australian rainforest.
- Microsoft Azure - Strangler Fig Pattern - Official Azure Architecture Center documentation. Covers the Strangler Fig pattern's implementation guidance, architecture diagrams and application scenarios in detail.
- Microsoft Azure - Anti-Corruption Layer Pattern - Official ACL pattern documentation. Explains how to design the domain model translation layer between the legacy and the new system.
- AWS Prescriptive Guidance - Strangler Fig Pattern - AWS official cloud design pattern guide. Provides the concrete procedure for applying the Strangler Fig pattern together with CDC and an ACL.
- Microservices.io - Strangler Fig Application - Chris Richardson's microservices pattern site. Explains the Strangler Fig pattern as an incremental modernization strategy.
- Sam Newman - Monolith to Microservices (O'Reilly) - A practitioner's book on migrating from monolith to microservices. It also covers complementary patterns such as Branch by Abstraction and Parallel Run.
- Confluent - Strangler Fig Pattern - Explains the Strangler Fig pattern from the perspective of Kafka-based event streaming, and presents a data migration strategy that combines it with CDC.