Strangler Fig Pattern: Legacy schrittweise migrieren
Ersetze Legacy-Systeme schrittweise mit routenbasierter Migration, Feature Flags, Datensynchronisation und Rollback — bei laufendem Betrieb.

Ein Legacy-System von Grund auf neu zu schreiben, scheitert fast immer. Das neue System dauert länger als geschätzt, das alte System bekommt weiterhin Features, mit denen die Neuentwicklung nicht gerechnet hat, und am Ende pflegt das Team zwei Systeme gleichzeitig. Das Strangler Fig Pattern umgeht dieses Problem, indem es das alte System schrittweise, Stück für Stück, ersetzt, während der Produktivbetrieb durchgehend weiterläuft.
Der Name stammt von Strangler-Fig-Bäumen, die um bestehende Bäume herumwachsen und sie nach und nach verdrängen. Das Pattern leitet den Traffic über eine Facade, die für jede Anfrage entscheidet, ob sie an das alte oder das neue System geht. Mit der Zeit wandern immer mehr Routen zum neuen System, bis das alte abgeschaltet werden kann.
Der Facade-Router
Die zentrale Komponente ist ein Reverse Proxy oder API-Gateway, das die Routing-Entscheidungen trifft. Der Traffic läuft über einen einzigen Endpoint ein und wird je nach Fall an das Legacy- oder das neue System weitergeleitet.
// ❌ 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);Migration gesteuert über Feature Flags
Für Routen, die gerade aktiv migriert werden, steuern Feature Flags den Rollout-Prozentsatz. So lässt sich der Traffic schrittweise verlagern und bei Bedarf sofort zurückrollen.
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);
}Datensynchronisation während der Migration
Der schwierigste Teil einer Strangler-Fig-Migration ist es, die Daten zwischen altem und neuem System während der Übergangsphase konsistent zu halten.
// 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,
});
}
}Verifizierung: Shadow Testing
Bevor der Traffic umgeschaltet wird, muss überprüft werden, ob das neue System korrekte Ergebnisse liefert: Dazu laufen die Anfragen durch beide Systeme, und die Ausgaben werden verglichen.
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();
});
}Abschluss der Migration und Abschaltung des Legacy-Systems
Der Migrationsfortschritt wird nachverfolgt, damit klar ist, wann sich das Legacy-System gefahrlos abschalten lässt.
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",
];Wichtigste Erkenntnisse
Das Strangler Fig Pattern ersetzt Legacy-Systeme schrittweise über eine Routing-Facade, die den Traffic pro Route entweder an das alte oder das neue System schickt – jede migrierte Route ist eine kleine, reversible Änderung, die den Produktivbetrieb durchgehend am Laufen hält. Feature Flags mit prozentualem Rollout und konsistentem User-Hashing ermöglichen eine schrittweise Verlagerung des Traffics vom Legacy- zum neuen System, mit sofortigem Rollback, indem der Prozentsatz einfach wieder auf null gesetzt wird – ganz ohne neues Deployment oder DNS-Änderung. Die Datensynchronisation während der Migration erfordert doppelte Schreibvorgänge vom neuen System zurück zum Legacy-System, mit Retry-Queues für fehlgeschlagene Synchronisationen, damit die Konsistenz für Routen erhalten bleibt, die bis zum Abschluss der Migration noch aus der alten Datenbank lesen. Shadow Testing schickt Anfragen durch beide Systeme und vergleicht die Ausgaben, bevor der Traffic umgeschaltet wird – so werden Formatabweichungen bei den Daten, fehlende Feldzuordnungen und Verhaltensunterschiede erkannt, die Integrationstests übersehen.


