Strangler Fig Pattern for Legacy System Modernization

Strangler Fig Pattern for Legacy System Modernization

The Problem

Principal engineers frequently face the challenge of modernizing legacy systems that are critical to business operations but built on outdated technology stacks, lacking tests, and too risky to replace in a single “big bang” migration. These systems typically:

Traditional approaches—complete rewrites or gradual internal refactoring—both carry significant risks. Rewrites often fail due to underestimating complexity, while internal refactoring can take years without delivering business value.

The Strangler Fig Pattern

Named after strangler fig trees that grow around host trees and eventually replace them, this pattern gradually replaces legacy system functionality by intercepting calls and routing them to new implementations. Over time, the new system “strangles” the old one until it can be safely removed.

Core Principles

  1. Incremental replacement: Migrate functionality piece by piece, not all at once
  2. Risk isolation: Each increment is independently testable and rollbackable
  3. Parallel operation: Old and new systems coexist during migration
  4. Business continuity: System remains operational throughout transition
  5. Iterative value delivery: Each increment can provide improvements

Architecture Components

1. Facade/Router Layer

A proxy that intercepts requests and routes them to either the legacy system or new implementation based on configurable rules.

Go Implementation:

package main

import (
    "net/http"
    "net/http/httputil"
    "net/url"
)

type StranglerRouter struct {
    legacyBackend *httputil.ReverseProxy
    newBackend    *httputil.ReverseProxy
    featureFlags  FeatureFlagService
}

func NewStranglerRouter(legacyURL, newURL string, flags FeatureFlagService) *StranglerRouter {
    legacy, _ := url.Parse(legacyURL)
    newSvc, _ := url.Parse(newURL)
    
    return &StranglerRouter{
        legacyBackend: httputil.NewSingleHostReverseProxy(legacy),
        newBackend:    httputil.NewSingleHostReverseProxy(newSvc),
        featureFlags:  flags,
    }
}

func (sr *StranglerRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    route := r.URL.Path
    userID := r.Header.Get("X-User-ID")
    
    // Route decision based on feature flags and routing rules
    if sr.shouldRouteToNew(route, userID) {
        // Add header for observability
        r.Header.Set("X-Routed-To", "new-service")
        sr.newBackend.ServeHTTP(w, r)
    } else {
        r.Header.Set("X-Routed-To", "legacy-service")
        sr.legacyBackend.ServeHTTP(w, r)
    }
}

func (sr *StranglerRouter) shouldRouteToNew(route, userID string) bool {
    // Check if route is fully migrated
    if sr.featureFlags.IsRouteMigrated(route) {
        return true
    }
    
    // Progressive rollout: route percentage of users to new system
    if sr.featureFlags.IsUserInRollout(userID, route) {
        return true
    }
    
    return false
}

type FeatureFlagService interface {
    IsRouteMigrated(route string) bool
    IsUserInRollout(userID, route string) bool
}

2. Feature Toggle System

Critical for controlling rollout and enabling quick rollbacks.

Python Implementation with LaunchDarkly:

import launchdarkly
from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app)

ld_client = launchdarkly.get()

class MigrationController:
    def __init__(self, legacy_service, new_service):
        self.legacy = legacy_service
        self.new = new_service
        
    def route_request(self, route, user_context):
        """Route request based on migration state"""
        
        # Feature flag key based on route
        flag_key = f"migrate-{route.replace('/', '-')}"
        
        # Check if route is fully migrated
        if ld_client.variation(flag_key, user_context, False):
            return self.new.handle(route)
        
        # Shadow mode: call both, return legacy, compare results
        if ld_client.variation(f"{flag_key}-shadow", user_context, False):
            return self._shadow_mode(route, user_context)
        
        return self.legacy.handle(route)
    
    def _shadow_mode(self, route, user_context):
        """Call both services, return legacy, log differences"""
        legacy_response = self.legacy.handle(route)
        
        try:
            new_response = self.new.handle(route)
            self._compare_and_log(legacy_response, new_response, route)
        except Exception as e:
            logger.error(f"Shadow call failed for {route}: {e}")
        
        return legacy_response
    
    def _compare_and_log(self, legacy, new, route):
        """Compare responses and log discrepancies"""
        if legacy.data != new.data:
            logger.warning(
                f"Response mismatch for {route}",
                extra={
                    "legacy": legacy.data,
                    "new": new.data,
                    "route": route
                }
            )

3. Frontend Migration (ReactJS)

Migrate UI components incrementally using module federation or micro-frontends.

ReactJS with Module Federation:

// Legacy app (webpack.config.js)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'legacyApp',
      remotes: {
        newApp: 'newApp@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      },
    }),
  ],
};

// Migration component wrapper
import React, { Suspense, lazy } from 'react';
import { useFeatureFlag } from './featureFlags';

// Lazy load new component from federated module
const NewUserProfile = lazy(() => import('newApp/UserProfile'));
const LegacyUserProfile = lazy(() => import('./LegacyUserProfile'));

export function UserProfile({ userId }) {
  const useMigratedVersion = useFeatureFlag('user-profile-v2', userId);
  
  return (
    <Suspense fallback={<LoadingSpinner />}>
      {useMigratedVersion ? (
        <NewUserProfile userId={userId} />
      ) : (
        <LegacyUserProfile userId={userId} />
      )}
    </Suspense>
  );
}

Implementation Strategy

Phase 1: Preparation (2-4 weeks)

  1. Set up observability: Instrument legacy system with metrics, logging, tracing
  2. Deploy facade layer: Introduce routing proxy (initially pass-through to legacy)
  3. Implement feature flags: Deploy configuration system for rollout control
  4. Define success metrics: Response times, error rates, business KPIs

Phase 2: Shadow Mode (2-3 weeks per feature)

  1. Build new implementation: Develop replacement for specific feature/route
  2. Deploy in shadow mode: Call both systems, return legacy response
  3. Compare outputs: Log discrepancies, iterate until parity achieved
  4. Load test: Verify new implementation performance under production load

Phase 3: Progressive Rollout (1-2 weeks per feature)

  1. Start with 1% traffic: Route small percentage to new system
  2. Monitor metrics: Watch for errors, latency regressions, business impact
  3. Gradually increase: 1% → 5% → 10% → 25% → 50% → 100%
  4. Enable quick rollback: Feature flag toggle for instant reversion if issues arise

Phase 4: Decommission (After all features migrated)

  1. Remove routing logic: Directly call new system
  2. Deprecate legacy code: Remove unused legacy functionality
  3. Clean up infrastructure: Decommission legacy servers and dependencies

Trade-offs and Considerations

Advantages

Challenges

When to Use

Ideal scenarios:

Avoid when:

Monitoring and Observability

Essential metrics to track:

type MigrationMetrics struct {
    // Route traffic distribution
    LegacyRequestCount  prometheus.Counter
    NewRequestCount     prometheus.Counter
    
    // Performance comparison
    LegacyResponseTime  prometheus.Histogram
    NewResponseTime     prometheus.Histogram
    
    // Error rates
    LegacyErrorRate     prometheus.Counter
    NewErrorRate        prometheus.Counter
    
    // Shadow mode metrics
    ResponseMismatch    prometheus.Counter
}

Real-World Example: Monolith to Microservices

A financial services company used the strangler fig pattern to migrate a 15-year-old Java monolith to Go microservices:

  1. Months 1-2: Deployed Envoy proxy as facade, implemented feature flag system
  2. Months 3-8: Migrated user authentication service (shadow mode → 100% rollout)
  3. Months 9-14: Migrated payment processing (highest risk, slowest rollout)
  4. Months 15-24: Migrated remaining 12 services in parallel
  5. Month 25: Decommissioned monolith

Results: Zero downtime, 40% cost reduction, 60% latency improvement, no major incidents during migration.

Conclusion

The Strangler Fig Pattern is the safest, most pragmatic approach to legacy modernization for mission-critical systems. While it requires patience and discipline, it enables continuous value delivery while systematically reducing technical debt. The key is treating it as a product, not a project—with proper instrumentation, progressive rollout, and a clear migration roadmap.

For principal engineers, this pattern demonstrates strategic thinking: choosing reliability and incremental progress over the allure of a complete rewrite. The strangler fig grows slowly but surely, and so does your modernization effort.