Skip to content

Strangler Fig Pattern: Migrating Legacy Incrementally

Replace legacy systems incrementally with route-based migration, feature flags, data synchronization and rollback strategies that keep production up.

4 min read
Visualization of the strangler fig pattern showing old system routes gradually being replaced by new system handlers over time

Rewriting a legacy system from scratch almost always fails. The new system takes longer than estimated, the old system keeps receiving features that the rewrite didn't account for, and the team ends up maintaining two systems simultaneously. The strangler fig pattern avoids this by incrementally replacing the old system piece by piece while keeping production running continuously.

Named after strangler fig trees that grow around existing trees and gradually replace them, this pattern routes traffic through a facade that decides whether each request goes to the old or new system. Over time, more routes shift to the new system until the old one can be decommissioned.

The Facade Router

The central component is a reverse proxy or API gateway that makes routing decisions. Traffic enters through a single endpoint and gets directed to either the legacy or new system.

tstypescript
// ❌ Big bang migration — all or nothing, high risk
// Deploy new system → switch DNS → hope it works
// Rollback: switch DNS back → data inconsistency
tstypescript
// ✅ Strangler fig facade — incremental, reversible
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
 
const app = express();
 
// Migration configuration — what's been migrated
const migrationConfig = {
  routes: new Map<string, "legacy" | "new">([
    // Already migrated
    ["/api/users", "new"],
    ["/api/users/*", "new"],
    ["/api/auth/*", "new"],
 
    // In progress — using feature flags
    ["/api/orders", "legacy"],
    ["/api/orders/*", "legacy"],
 
    // Not yet started
    ["/api/reports/*", "legacy"],
    ["/api/inventory/*", "legacy"],
  ]),
};
 
// Legacy system proxy
const legacyProxy = createProxyMiddleware({
  target: process.env.LEGACY_URL,
  changeOrigin: true,
  logLevel: "warn",
  onError: (err, req, res) => {
    console.error(
      `Legacy proxy error: ${err.message}`
    );
    (res as express.Response).status(502).json({
      error: "Legacy service unavailable",
    });
  },
});
 
// New system proxy
const newProxy = createProxyMiddleware({
  target: process.env.NEW_SERVICE_URL,
  changeOrigin: true,
  logLevel: "warn",
});
 
// Route decision middleware
function routeDecision(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const path = req.path;
 
  // Check exact match first, then wildcard
  let target: "legacy" | "new" = "legacy";
 
  for (const [pattern, dest] of migrationConfig.routes) {
    if (pattern.endsWith("/*")) {
      const prefix = pattern.slice(0, -2);
      if (path.startsWith(prefix)) {
        target = dest;
        break;
      }
    } else if (path === pattern) {
      target = dest;
      break;
    }
  }
 
  // Tag for observability
  res.set("X-Routed-To", target);
 
  if (target === "new") {
    newProxy(req, res, next);
  } else {
    legacyProxy(req, res, next);
  }
}
 
app.use("/api", routeDecision);

Feature Flag-Controlled Migration

For routes being actively migrated, use feature flags to control the rollout percentage. This lets you shift traffic gradually and roll back instantly.

tstypescript
interface MigrationFlag {
  route: string;
  newServicePercentage: number;
  enabledUserIds?: string[];
  excludedUserIds?: string[];
}
 
const migrationFlags: MigrationFlag[] = [
  {
    route: "/api/orders",
    newServicePercentage: 25,
    enabledUserIds: ["internal-test-user-1"],
  },
  {
    route: "/api/orders/*",
    newServicePercentage: 25,
  },
];
 
function shouldRouteToNew(
  path: string,
  userId?: string
): boolean {
  const flag = migrationFlags.find((f) => {
    if (f.route.endsWith("/*")) {
      return path.startsWith(f.route.slice(0, -2));
    }
    return path === f.route;
  });
 
  if (!flag) return false;
 
  // Explicit user overrides
  if (
    userId &&
    flag.excludedUserIds?.includes(userId)
  ) {
    return false;
  }
  if (userId && flag.enabledUserIds?.includes(userId)) {
    return true;
  }
 
  // Percentage-based rollout
  // Use consistent hashing so users get consistent routing
  if (userId) {
    const hash = simpleHash(userId);
    return hash % 100 < flag.newServicePercentage;
  }
 
  return (
    Math.random() * 100 < flag.newServicePercentage
  );
}
 
function simpleHash(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = (hash * 31 + char) | 0;
  }
  return Math.abs(hash);
}

Data Synchronization During Migration

The hardest part of strangler fig migration is keeping data consistent between old and new systems during the transition period.

tstypescript
// Strategy: Dual writes during migration
class OrderService {
  constructor(
    private legacyDb: LegacyDatabase,
    private newDb: NewDatabase,
    private syncEnabled: boolean
  ) {}
 
  async createOrder(order: OrderInput): Promise<Order> {
    // Primary write to the system of record
    const result = await this.newDb.createOrder(order);
 
    // Sync back to legacy for routes still using it
    if (this.syncEnabled) {
      try {
        await this.legacyDb.syncOrder(
          this.toLegacyFormat(result)
        );
      } catch (error) {
        // Log sync failure but don't fail the request
        console.error(
          "Legacy sync failed:",
          error
        );
        await this.queueForRetry(result);
      }
    }
 
    return result;
  }
 
  private toLegacyFormat(order: Order): LegacyOrder {
    return {
      order_id: order.id,
      customer_id: order.userId,
      order_total: order.total.toString(),
      order_status: this.mapStatus(order.status),
      created_date: order.createdAt
        .toISOString()
        .split("T")[0],
    };
  }
 
  private mapStatus(
    status: Order["status"]
  ): string {
    const statusMap: Record<string, string> = {
      pending: "P",
      confirmed: "C",
      shipped: "S",
      delivered: "D",
    };
    return statusMap[status] ?? "P";
  }
 
  private async queueForRetry(
    order: Order
  ): Promise<void> {
    // Push to a retry queue for eventual consistency
    await messageQueue.publish("legacy-sync-retry", {
      type: "order",
      data: order,
      attempts: 0,
      maxAttempts: 5,
    });
  }
}

Verification: Shadow Testing

Before switching traffic, verify the new system produces correct results by running requests through both systems and comparing outputs.

tstypescript
async function shadowTest(
  req: express.Request
): Promise<{
  match: boolean;
  legacyResponse: unknown;
  newResponse: unknown;
  differences: string[];
}> {
  // Send to both systems in parallel
  const [legacyResult, newResult] = await Promise.all([
    forwardToLegacy(req),
    forwardToNew(req),
  ]);
 
  const differences = compareResponses(
    legacyResult,
    newResult
  );
 
  // Log results for analysis
  if (differences.length > 0) {
    console.warn(
      `Shadow test mismatch on ${req.path}:`,
      differences
    );
  }
 
  return {
    match: differences.length === 0,
    legacyResponse: legacyResult,
    newResponse: newResult,
    differences,
  };
}
 
function compareResponses(
  legacy: unknown,
  modern: unknown
): string[] {
  const diffs: string[] = [];
 
  if (typeof legacy !== typeof modern) {
    diffs.push(
      `Type mismatch: ${typeof legacy} vs ${typeof modern}`
    );
    return diffs;
  }
 
  if (
    typeof legacy === "object" &&
    legacy !== null &&
    modern !== null
  ) {
    const legacyObj = legacy as Record<string, unknown>;
    const modernObj = modern as Record<string, unknown>;
 
    // Check known field mappings
    const fieldMappings: [string, string][] = [
      ["order_id", "id"],
      ["customer_id", "userId"],
      ["order_total", "total"],
    ];
 
    for (const [legacyField, newField] of fieldMappings) {
      const legacyVal = String(
        legacyObj[legacyField] ?? ""
      );
      const newVal = String(
        modernObj[newField] ?? ""
      );
 
      if (legacyVal !== newVal) {
        diffs.push(
          `${legacyField}/${newField}: ` +
            `"${legacyVal}" vs "${newVal}"`
        );
      }
    }
  }
 
  return diffs;
}
 
// Shadow test middleware — only in staging
if (process.env.ENABLE_SHADOW_TESTING === "true") {
  app.use("/api/orders/*", async (req, res, next) => {
    if (req.method === "GET") {
      const result = await shadowTest(req);
      // Store results in metrics system
      metrics.recordShadowTest(
        req.path,
        result.match,
        result.differences
      );
    }
    next();
  });
}

Migration Completion and Legacy Decommission

Track migration progress and know when it's safe to decommission the legacy system.

tstypescript
interface MigrationProgress {
  totalRoutes: number;
  migratedRoutes: number;
  inProgressRoutes: number;
  remainingRoutes: number;
  trafficPercentage: {
    legacy: number;
    new: number;
  };
}
 
function getMigrationProgress(): MigrationProgress {
  const routes = [...migrationConfig.routes.entries()];
  const migrated = routes.filter(
    ([, target]) => target === "new"
  ).length;
  const inProgress = migrationFlags.filter(
    (f) =>
      f.newServicePercentage > 0 &&
      f.newServicePercentage < 100
  ).length;
 
  return {
    totalRoutes: routes.length,
    migratedRoutes: migrated,
    inProgressRoutes: inProgress,
    remainingRoutes:
      routes.length - migrated - inProgress,
    trafficPercentage: {
      legacy: 0, // Calculate from actual traffic metrics
      new: 0,
    },
  };
}
 
// Decommission checklist
const decommissionChecklist = [
  "All routes serving from new system",
  "Zero traffic to legacy for 30 days",
  "All data migrated and verified",
  "Legacy sync disabled",
  "Rollback plan documented (just in case)",
  "Legacy database archived",
  "Legacy infrastructure teardown scheduled",
];

Key Takeaways

The strangler fig pattern replaces legacy systems incrementally through a routing facade that directs traffic to either the old or new system on a per-route basis—each route migrated is a small, reversible change that keeps production running continuously. Feature flags with percentage-based rollout and consistent user hashing enable gradual traffic shifting from legacy to new system, with instant rollback by setting the percentage back to zero—no deployment or DNS change needed. Data synchronization during migration requires dual writes from the new system back to legacy with retry queues for failed syncs, maintaining consistency for routes that still read from the old database until the migration is complete. Shadow testing runs requests through both systems and compares outputs before switching traffic, catching data format mismatches, missing field mappings, and behavioral differences that integration tests miss.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX