TechLead
Lesson 12 of 27
5 min read
Software Architecture

Strangler Fig Pattern

Learn how to gradually migrate from a monolith to microservices using the strangler fig approach

What is the Strangler Fig Pattern?

The Strangler Fig Pattern, named by Martin Fowler after a tropical tree that grows around and eventually replaces its host tree, is a migration strategy for incrementally replacing a legacy system. Instead of a risky big-bang rewrite, you gradually build new functionality alongside the old system, redirecting traffic piece by piece until the legacy system can be decommissioned.

Three Phases of the Strangler Fig

  • Transform: Build the new component that will replace a specific part of the legacy system
  • Coexist: Run both old and new implementations side by side, gradually routing traffic to the new one
  • Eliminate: Once the new component handles all traffic and is proven stable, remove the old one

Implementation with a Routing Facade

// Strangler facade that routes between old and new systems
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";

const app = express();

// Configuration: which routes go to which system
interface RouteConfig {
  path: string;
  target: "legacy" | "new";
  methods?: string[];
  featureFlag?: string;
}

const routeConfigs: RouteConfig[] = [
  // Already migrated — all traffic to new system
  { path: "/api/products", target: "new" },
  { path: "/api/products/*", target: "new" },

  // In progress — feature flag controlled
  { path: "/api/orders", target: "new", methods: ["GET"], featureFlag: "new-order-reads" },
  { path: "/api/orders", target: "legacy", methods: ["POST"] },

  // Not yet migrated — all traffic to legacy
  { path: "/api/users", target: "legacy" },
  { path: "/api/reports", target: "legacy" },
];

const targets = {
  legacy: "http://monolith:8080",
  new: "http://new-services-gateway:3000",
};

// Feature flag service
class FeatureFlagService {
  private flags = new Map();

  async isEnabled(flag: string): Promise {
    // In production: check LaunchDarkly, Split.io, etc.
    return this.flags.get(flag) ?? false;
  }
}

const featureFlags = new FeatureFlagService();

// Route each request to the appropriate target
app.use(async (req, res, next) => {
  const matchingRoute = routeConfigs.find(config => {
    const pathMatch = req.path.startsWith(config.path.replace("/*", ""));
    const methodMatch = !config.methods || config.methods.includes(req.method);
    return pathMatch && methodMatch;
  });

  if (!matchingRoute) {
    // Default to legacy for unmigrated routes
    return proxy(req, res, "legacy");
  }

  // Check feature flag if applicable
  if (matchingRoute.featureFlag) {
    const enabled = await featureFlags.isEnabled(matchingRoute.featureFlag);
    if (!enabled) {
      return proxy(req, res, "legacy");
    }
  }

  return proxy(req, res, matchingRoute.target);
});

function proxy(req: express.Request, res: express.Response, target: string): void {
  createProxyMiddleware({
    target: targets[target as keyof typeof targets],
    changeOrigin: true,
  })(req, res, () => {});
}

Migration Steps in Detail

// Step 1: Identify a bounded context to extract
// Start with a module that has clear boundaries and few dependencies

// Step 2: Build the new service
// New Product Service with its own database
class ProductService {
  constructor(private readonly productRepo: ProductRepository) {}

  async getProduct(id: string): Promise {
    return this.productRepo.findById(id);
  }

  async createProduct(data: CreateProductDTO): Promise {
    const product = Product.create(data);
    await this.productRepo.save(product);
    return product;
  }
}

// Step 3: Data migration — sync data from legacy to new
class DataMigrationService {
  constructor(
    private readonly legacyDb: LegacyDatabase,
    private readonly newDb: NewDatabase
  ) {}

  async migrateProducts(): Promise {
    const legacyProducts = await this.legacyDb.query("SELECT * FROM products");
    let migrated = 0;
    let failed = 0;

    for (const row of legacyProducts) {
      try {
        await this.newDb.query(
          "INSERT INTO products (id, name, price, category, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = $2, price = $3",
          [row.id, row.name, row.price, row.category_name, row.created_at]
        );
        migrated++;
      } catch (error) {
        failed++;
        console.error(`Failed to migrate product ${row.id}:`, error);
      }
    }

    return { total: legacyProducts.length, migrated, failed };
  }

  // Keep data in sync during coexistence period
  async setupChangeDataCapture(): Promise {
    // Use Debezium or similar CDC tool to stream changes
    // from legacy DB to new service's event bus
  }
}

// Step 4: Shadow testing — send traffic to both, compare results
class ShadowTestMiddleware {
  async handle(req: Request, res: Response, next: NextFunction): Promise {
    // Send to legacy (primary)
    const legacyResult = await this.callLegacy(req);

    // Send to new service (shadow — result is discarded)
    this.callNewService(req).then(newResult => {
      // Compare results asynchronously
      if (JSON.stringify(legacyResult) !== JSON.stringify(newResult)) {
        console.warn("Shadow test mismatch:", {
          path: req.path,
          legacy: legacyResult,
          new: newResult,
        });
      }
    }).catch(err => {
      console.error("Shadow call failed:", err);
    });

    // Always return legacy result during shadow testing
    res.json(legacyResult);
  }
}

// Step 5: Gradual traffic shift
// 0% -> 1% -> 5% -> 25% -> 50% -> 100% new service
class TrafficSplitter {
  private newServicePercentage = 0;

  setPercentage(pct: number): void {
    this.newServicePercentage = Math.min(100, Math.max(0, pct));
  }

  shouldRouteToNew(): boolean {
    return Math.random() * 100 < this.newServicePercentage;
  }
}

Migration Checklist

  • Data synchronization: Ensure data stays in sync during the coexistence period — use CDC or dual-write patterns
  • Rollback plan: Always be able to route 100% traffic back to the legacy system if issues arise
  • Monitoring: Compare error rates, latency, and correctness between old and new systems
  • Team alignment: The team working on the new service needs deep knowledge of the legacy behavior
  • Feature parity: The new service must handle all edge cases the legacy system handles before full cutover

Continue Learning