Strangler Fig Pattern

Incrementally replace legacy systems without big-bang migrations. Covers the strangler fig approach, facade routing, feature toggles for migration, data synchronization during transition, and the patterns that let you modernize production systems safely.

The strangler fig pattern is a migration strategy for replacing legacy systems by gradually wrapping new components around existing ones, allowing both to coexist during transition. This approach matters when migrating monolithic applications or legacy services to modern architectures without requiring complete system downtime or risk.

Implementing the Strangler Fig

Creating a Wrapper Layer

The core of the strangler fig is a routing layer that intercepts requests to the legacy system and routes them appropriately. In a typical implementation, you create an API gateway or reverse proxy that handles all incoming traffic and makes decisions about where to route requests.

# Example HTTP proxy configuration using nginx
server {
    listen 80;
    server_name example.com;

    location /api/v1/ {
        # Route to new service for specific endpoints
        if ($request_uri ~ "^/api/v1/users/") {
            proxy_pass http://new-users-service:8080/;
        }
        
        # Route to legacy system for other endpoints
        proxy_pass http://legacy-backend:8080/;
    }
}

The routing logic should be based on path patterns, headers, or query parameters. A common pattern is to route specific resource types through the new system while keeping others in the legacy system.

Service Discovery and Routing Logic

In containerized environments, you can use service discovery mechanisms to determine which services are available for routing:

# Example using kubectl to examine service endpoints
kubectl get endpoints -n production
NAME                    ENDPOINTS           AGE
legacy-backend          10.244.0.5:8080     1d
new-users-service       10.244.0.6:8080     1d

# Example service definition that enables routing
apiVersion: v1
kind: Service
metadata:
  name: api-gateway
spec:
  selector:
    app: api-gateway
  ports:
    - port: 80
      targetPort: 8080

The key is to avoid hardcoding service addresses in the routing logic, using instead dynamic discovery mechanisms that update automatically when services scale or change.

Gradual Migration Strategy

Implement a migration strategy that moves traffic gradually from legacy to new systems. This often involves:

  1. Identifying which endpoints or features can be migrated first
  2. Implementing feature flags to control routing decisions
  3. Monitoring both systems during the transition period
# Example of feature flag implementation in code
if (feature_flag_enabled('users_migration')) {
    // Route to new service
    return forward_to_new_service('/api/v1/users');
} else {
    // Route to legacy system
    return forward_to_legacy_service('/api/v1/users');
}

Error Handling and Fallback Mechanisms

When the new system fails, requests must fall back gracefully to the legacy system:

# Example error handling in proxy configuration
location /api/v1/users/ {
    proxy_pass http://new-users-service:8080/;
    
    # If new service is unavailable, fallback to legacy
    proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
    proxy_next_upstream_tries 3;
}

The system should log these failures and potentially trigger alerts for operations teams. The fallback behavior must be consistent across all client applications.

Managing Data Synchronization

Read-Only Access to Legacy Data

New systems often need access to legacy data, which requires careful consideration of read patterns:

# Example database connection configuration
DATABASE_URL="postgresql://user:password@legacy-db:5432/datastore"
# Use read-only connections for new services accessing legacy data
DATABASE_READONLY_URL="postgresql://readonly_user:password@legacy-db:5432/datastore"

The new system should treat legacy data as a source of truth for reads, not attempt to write back to it directly.

Write Path Considerations

When writing data, the system must handle scenarios where both systems need to be updated:

# Example write operation with dual persistence
function createUser(userData) {
    // Write to new system
    const newRecord = await newService.createUser(userData);
    
    // If legacy system is still needed for writes, write there too
    if (legacyWriteEnabled) {
        await legacySystem.createUser(userData);
    }
    
    return newRecord;
}

Data Consistency Patterns

Data consistency between systems often requires implementing eventual consistency patterns rather than immediate consistency. The implementation must be explicit about when data is considered "synchronized."

Configuration Management

Routing Configuration Updates

Configuration changes to routing rules should be version-controlled and deployed through standard CI/CD pipelines:

# Example configuration file for routing rules
{
  "routes": [
    {
      "path": "/api/v1/users/*",
      "target": "new-users-service"
    },
    {
      "path": "/api/v1/orders/*",
      "target": "legacy-backend"
    }
  ]
}

Changes to routing should be validated before deployment and rolled back automatically if they cause failures.

Feature Flag Management

Feature flags controlling which routes are active should be configurable without code changes:

# Example configuration for feature flag
{
  "features": {
    "users_migration": true,
    "orders_migration": false
  }
}

Operational Considerations

Monitoring and Observability

Both legacy and new systems must be monitored during migration, with specific metrics for:

The monitoring should track which paths are being taken and how often.

Rollback Strategy

A rollback plan requires that the system can quickly revert to using only the legacy system:

# Example rollback configuration
{
  "migration_state": "rollback",
  "routes": [
    {
      "path": "/api/v1/*",
      "target": "legacy-backend"
    }
  ]
}

Deployment Pipeline Integration

The migration should be integrated into standard deployment practices, with clear steps for:

  1. Deploying new services
  2. Updating routing rules
  3. Monitoring the transition
  4. Removing legacy components

Common Failure Modes

Silent Data Loss

When a new system fails to write data correctly, requests may succeed but without persistence. This often manifests as successful HTTP responses while data is only stored in memory or temporary storage.

Circuit Breaker Misconfiguration

The circuit breaker pattern can mask failures when it's configured to allow traffic to flow through even when the downstream service is failing. A misconfigured circuit breaker might allow requests to pass to a new system that is down and never fallback to the legacy system.

# Example of incorrect circuit breaker configuration
# This setup allows traffic even when service is down
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_next_upstream_tries 1;

Inconsistent Response Formats

When the new system returns different response formats than the legacy system, client applications may fail to parse responses correctly. This often happens with status codes or error message structures.

Memory Leaks in Gateway Components

The proxy or gateway that implements routing can accumulate memory over time if it doesn't properly clean up connections or caches:

# Example of memory leak in proxy configuration
location /api/v1/ {
    # Missing connection cleanup
    proxy_pass http://new-service;
    
    # Should include proper connection handling
    proxy_set_header Connection "";
}

Race Conditions in Data Access

When both systems access the same data source, race conditions can occur:

# Example race condition scenario
function updateUser(userId, userData) {
    // Legacy system updates first
    legacySystem.updateUser(userId, userData);
    
    // New system may read stale data
    const currentData = newService.getUser(userId);
    
    // Updates may conflict
    newService.updateUser(userId, userData);
}

Configuration Drift

Configuration files that define routing rules can drift from the actual deployed services, causing requests to be routed incorrectly:

# Example configuration drift issue
{
  "routes": [
    {
      "path": "/api/v1/users/*",
      "target": "non-existent-service"  # Service no longer exists
    }
  ]
}

Migration Progression

Phase 1: Read-Only Access

Start by allowing the new system to read from the legacy system without writing back:

# Example read-only access pattern
function getUser(userId) {
    // Read from legacy system
    const userData = await legacySystem.getUser(userId);
    
    // Process in new system
    return processUserData(userData);
}

Phase 2: Read-Write Access

Once the new system can handle writes, implement bidirectional data flow:

# Example read-write access pattern
function updateUser(userId, userData) {
    // Write to both systems
    const result = await Promise.all([
        legacySystem.updateUser(userId, userData),
        newSystem.updateUser(userId, userData)
    ]);
    
    return result[1];  // Return result from new system
}

Phase 3: Complete Replacement

Gradually reduce reliance on the legacy system until it's fully replaced:

# Example complete migration
function processRequest(request) {
    // All requests go to new system
    return newService.handleRequest(request);
}

The pattern works best when the new system is built to interface with the legacy system, not as a completely independent replacement.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.