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.

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.
// ❌ Big bang migration — all or nothing, high risk
// Deploy new system → switch DNS → hope it works
// Rollback: switch DNS back → data inconsistency// ✅ 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.
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.
// 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.
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.
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.


