Strangler Fig: Incrementally Replacing Legacy Systems
How to apply the strangler fig pattern to migrate legacy systems to modern architectures without risky big-bang rewrites or extended downtime.

Big-bang rewrites fail. The history of software engineering is littered with multi-year rewrite projects that shipped late, over budget, and missing features. The strangler fig pattern offers an alternative: wrap the legacy system, intercept its traffic, and gradually replace functionality until the old system can be retired.
Named after the strangler fig tree that grows around its host until the host dies, this pattern lets you deliver value incrementally while managing risk at every step.
The Routing Layer Foundation
Everything starts with a proxy that sits between clients and the legacy system. Initially, it routes 100% of traffic to the old system. As you build new services, the proxy gradually redirects routes to new implementations.
// ❌ Big-bang approach: replace everything at once
// 18 months later, still not done, legacy still running
// ✅ Strangler fig: route-by-route migration
import express, { Request, Response, NextFunction } from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
interface RouteConfig {
path: string;
target: "legacy" | "new";
newServiceUrl?: string;
}
const routeConfigs: RouteConfig[] = [
// Already migrated
{ path: "/api/users", target: "new", newServiceUrl: "http://user-service:3001" },
{ path: "/api/auth", target: "new", newServiceUrl: "http://auth-service:3002" },
// Still on legacy
{ path: "/api/orders", target: "legacy" },
{ path: "/api/inventory", target: "legacy" },
{ path: "/api/reports", target: "legacy" },
];
const LEGACY_URL = "http://legacy-monolith:8080";
const app = express();
for (const route of routeConfigs) {
const targetUrl =
route.target === "new" && route.newServiceUrl
? route.newServiceUrl
: LEGACY_URL;
app.use(
route.path,
createProxyMiddleware({
target: targetUrl,
changeOrigin: true,
logLevel: "warn",
})
);
}
// Default: everything else goes to legacy
app.use(
createProxyMiddleware({
target: LEGACY_URL,
changeOrigin: true,
})
);The routing layer is intentionally simple. It adds minimal latency and serves as the single control point for the migration. When a new service is ready, you change one route configuration—not the entire system.
Feature-Level Migration Strategy
Don't migrate by database table or API endpoint. Migrate by business capability. A "user management" migration includes the user API, the user database tables, the authentication logic, and the profile page—everything related to that domain.
interface MigrationPhase {
name: string;
capabilities: string[];
routes: string[];
dataStores: string[];
status: "planned" | "in-progress" | "shadow" | "live" | "complete";
rollbackPlan: string;
}
const migrationPlan: MigrationPhase[] = [
{
name: "Phase 1: User Management",
capabilities: ["user-crud", "authentication", "profile"],
routes: ["/api/users", "/api/auth", "/api/profile"],
dataStores: ["users_table", "sessions_table"],
status: "complete",
rollbackPlan: "Revert proxy routes to legacy, user data stays in sync",
},
{
name: "Phase 2: Order Processing",
capabilities: ["order-crud", "order-status", "order-history"],
routes: ["/api/orders", "/api/orders/history"],
dataStores: ["orders_table", "order_items_table"],
status: "in-progress",
rollbackPlan: "Dual-write ensures legacy DB is current, revert routes",
},
{
name: "Phase 3: Inventory",
capabilities: ["stock-levels", "reservations", "replenishment"],
routes: ["/api/inventory", "/api/stock"],
dataStores: ["inventory_table", "reservations_table"],
status: "planned",
rollbackPlan: "Inventory service writes to both DBs, revert routes",
},
];Each phase is independently deployable and independently reversible. If Phase 2 goes wrong, Phase 1 keeps running on the new system while orders fall back to legacy. This isolation is the pattern's greatest strength.
Data Synchronization During Migration
The hardest part of any migration is the data layer. During transition, both systems need consistent data. Dual-write patterns handle this, but they require careful implementation.
// ❌ Naive dual write: data inconsistency risk
async function createOrder(order: Order) {
await newDatabase.insert(order); // Succeeds
await legacyDatabase.insert(order); // Fails! Data is now inconsistent
}// ✅ Event-driven synchronization with outbox pattern
interface OutboxEvent {
id: string;
aggregateId: string;
eventType: string;
payload: string;
createdAt: Date;
published: boolean;
}
class OrderService {
constructor(
private db: Database,
private eventPublisher: EventPublisher
) {}
async createOrder(orderData: CreateOrderInput): Promise<Order> {
// Single transaction: create order + outbox event
return this.db.transaction(async (tx) => {
const order = await tx.insert("orders", {
id: crypto.randomUUID(),
...orderData,
status: "created",
createdAt: new Date(),
});
// Outbox event in same transaction
await tx.insert("outbox_events", {
id: crypto.randomUUID(),
aggregateId: order.id,
eventType: "order.created",
payload: JSON.stringify(order),
createdAt: new Date(),
published: false,
});
return order;
});
}
}
// Separate process: publish outbox events to sync legacy
class OutboxPublisher {
async publishPending(): Promise<number> {
const events = await this.db.query(
"SELECT * FROM outbox_events WHERE published = false " +
"ORDER BY created_at LIMIT 100"
);
for (const event of events) {
await this.eventPublisher.publish(
"legacy-sync",
event
);
await this.db.update("outbox_events", event.id, {
published: true,
});
}
return events.length;
}
}The outbox pattern ensures the order creation and the sync event are atomic. A separate publisher reads unpublished events and sends them to a consumer that writes to the legacy database. If the publisher crashes, events remain in the outbox and get picked up on restart.
Shadow Traffic for Validation
Before switching real traffic to a new service, run shadow traffic—send a copy of production requests to the new service and compare responses without affecting users.
interface ShadowResult {
path: string;
legacyStatus: number;
newStatus: number;
legacyBody: string;
newBody: string;
match: boolean;
latencyLegacyMs: number;
latencyNewMs: number;
timestamp: Date;
}
async function shadowMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const shadowConfig = getShadowConfig(req.path);
if (!shadowConfig?.enabled) {
next();
return;
}
// Clone the request for shadow
const shadowPromise = sendShadowRequest(
shadowConfig.newServiceUrl,
req
).catch(err => ({
status: 0,
body: `Shadow error: ${err.message}`,
latencyMs: 0,
}));
// Let the real request proceed normally
const legacyStart = Date.now();
// Capture legacy response
const originalSend = res.send.bind(res);
res.send = function (body: any) {
const legacyLatency = Date.now() - legacyStart;
// Compare asynchronously, don't block response
shadowPromise.then(shadowResult => {
const result: ShadowResult = {
path: req.path,
legacyStatus: res.statusCode,
newStatus: shadowResult.status,
legacyBody: typeof body === "string" ? body : JSON.stringify(body),
newBody: shadowResult.body,
match: res.statusCode === shadowResult.status &&
normalizeResponse(body) === normalizeResponse(shadowResult.body),
latencyLegacyMs: legacyLatency,
latencyNewMs: shadowResult.latencyMs,
timestamp: new Date(),
};
logShadowResult(result);
});
return originalSend(body);
};
next();
}
function normalizeResponse(body: unknown): string {
try {
const parsed = typeof body === "string" ? JSON.parse(body) : body;
// Remove non-deterministic fields for comparison
const { timestamp, updatedAt, ...stable } = parsed as Record<string, unknown>;
return JSON.stringify(stable, Object.keys(stable).sort());
} catch {
return String(body);
}
}Shadow traffic reveals discrepancies between legacy and new implementations before they affect users. Run it for at least a week to catch edge cases that only appear with certain data patterns or time-dependent logic.
Measuring Migration Progress
Track migration progress with metrics that matter to stakeholders, not just engineers. "60% of routes migrated" means less than "60% of revenue-generating traffic on new system."
interface MigrationMetrics {
totalRoutes: number;
migratedRoutes: number;
trafficOnNew: number; // percentage
trafficOnLegacy: number; // percentage
errorRateNew: number;
errorRateLegacy: number;
p99LatencyNew: number;
p99LatencyLegacy: number;
}
function calculateMigrationHealth(
metrics: MigrationMetrics
): {
overallProgress: number;
readyForNextPhase: boolean;
concerns: string[];
} {
const concerns: string[] = [];
if (metrics.errorRateNew > metrics.errorRateLegacy * 1.1) {
concerns.push(
`New service error rate (${metrics.errorRateNew.toFixed(2)}%) ` +
`exceeds legacy (${metrics.errorRateLegacy.toFixed(2)}%)`
);
}
if (metrics.p99LatencyNew > metrics.p99LatencyLegacy * 1.5) {
concerns.push(
`New service p99 latency (${metrics.p99LatencyNew}ms) ` +
`is 50%+ higher than legacy (${metrics.p99LatencyLegacy}ms)`
);
}
const routeProgress = metrics.migratedRoutes / metrics.totalRoutes;
const trafficProgress = metrics.trafficOnNew / 100;
const overallProgress = (routeProgress + trafficProgress) / 2;
return {
overallProgress: Math.round(overallProgress * 100),
readyForNextPhase: concerns.length === 0 && metrics.errorRateNew < 0.5,
concerns,
};
}Decommissioning the Legacy System
The final step is often the hardest politically. The legacy system should be decommissioned only after all traffic has been verified on new services and a reasonable burn-in period has passed.
interface DecommissionChecklist {
allRouteMigrated: boolean;
shadowTrafficPassing: boolean;
zeroLegacyTraffic: boolean;
dataMigrationVerified: boolean;
burnInPeriodComplete: boolean; // 30+ days
stakeholderSignoff: boolean;
rollbackTestedRecently: boolean;
monitoringInPlace: boolean;
}
function canDecommission(
checklist: DecommissionChecklist
): { approved: boolean; blockers: string[] } {
const blockers: string[] = [];
const entries = Object.entries(checklist) as [string, boolean][];
for (const [item, complete] of entries) {
if (!complete) {
blockers.push(
item.replace(/([A-Z])/g, " $1").toLowerCase().trim()
);
}
}
return {
approved: blockers.length === 0,
blockers,
};
}Key Takeaways
The strangler fig pattern succeeds because it trades speed for safety. Each migration phase delivers value independently, can be rolled back independently, and teaches the team about the next phase. The proxy layer gives you a single control point for traffic routing. The outbox pattern ensures data consistency during the transition. Shadow traffic validates new implementations against production realities.
The teams that struggle with this pattern are the ones who try to migrate too much at once or skip the shadow traffic phase. Patience is the pattern's most important ingredient—the legacy system took years to build, and replacing it safely will take time too.


