LabHub

Blog

Complete Guide to the Strangler Fig Pattern: Zero-downtime Migration from Monolith to Microservices

한국어English日本語

Strangler Fig Pattern

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.

  1. Transform: Identify specific functionality in the monolith and reimplement it as a new microservice.
  2. Coexist: The existing monolith and new microservice operate simultaneously, with the Facade layer routing traffic appropriately.
  3. 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    │  │
└──────────────┘ │   └──────────────┘
                 │   ┌──────────────┐
                 └──▶│ MicroserviceA                     └──────────────┘

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.

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.

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.

CategoryStrangler FigBig BangParallel Run
TransitionIncremental by featureFull system switchSwitch after dual operation
DowntimeZero downtimePlanned downtime requiredZero downtime
Risk LevelLow (per feature)Very high (entire system)Medium (data consistency)
Rollback EaseInstant rollback (routing change)Very difficult (full restore)Easy (traffic switching)
DurationMonths to yearsWeeks to monthsMonths
CostGradual increaseUpfront concentrated investmentDual infrastructure cost
ROI TimelineRight after first service deploymentAfter full transitionAfter verification
Suitable ForLarge-scale legacy systemsSmall simple systemsHigh-reliability (finance/healthcare)
Team LoadDistributed (parallel by feature)Concentrated (all teams)High (dual operation)
Data SyncIndividual strategy per featureBatch migrationReal-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:

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:

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:

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:

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

During Migration

Post-migration

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

Comments

No comments yet.

Sign in to leave a comment