Strangler Fig: Monolithen ohne Neuentwicklung migrieren
Praxisnaher Leitfaden zum schrittweisen Ablösen alter Monolithen mit dem Strangler-Fig-Pattern: Routing, Datensynchronisierung und Rollback-Netze.

Warum große Neuentwicklungen scheitern
Jedes Engineering-Team steht früher oder später vor derselben Versuchung: Das Altsystem ist mühsam, der Code ist verworren, und irgendjemand schlägt vor, alles von Grund auf neu zu schreiben. Die Logik dahinter klingt überzeugend. In der Praxis scheitern große Neuentwicklungen jedoch häufiger, als sie gelingen.
Das neue System muss funktionale Parität mit einem sich ständig verändernden Ziel erreichen. Das alte System entwickelt sich weiter, während das neue noch gebaut wird. Den Business-Stakeholdern geht die Geduld aus, wenn die Neuentwicklung doppelt so lange dauert wie geschätzt – und das tut sie fast immer. Währenddessen müssen beide Systeme gepflegt werden, und das Team ist zwischen ihnen aufgeteilt.
Das Strangler-Fig-Pattern bietet eine Alternative. Benannt nach der Würgefeige, einer Kletterpflanze, die einen Wirtsbaum langsam umschließt, ersetzt das Pattern ein Altsystem schrittweise – eine Fähigkeit nach der anderen –, bis das alte System vollständig entfernt werden kann. Bei jedem Schritt funktioniert das System. Bei jedem Schritt kann man anhalten und hat trotzdem bereits Wert geliefert.
Wie das Pattern funktioniert
Das Strangler-Fig-Pattern arbeitet über drei Mechanismen: abfangen, weiterleiten und ersetzen. Ein Routing Layer liegt vor dem alten und dem neuen System. Anfragen laufen durch diese Schicht, die entscheidet, ob sie an das Altsystem oder an den neuen Service geschickt werden.
// API Gateway / Routing Layer
import { NextRequest, NextResponse } from "next/server";
interface RouteConfig {
pattern: RegExp;
target: "legacy" | "new";
fallback?: "legacy" | "new";
shadowMode?: boolean;
}
const routeTable: RouteConfig[] = [
// Already migrated — all traffic to new service
{ pattern: /^\/api\/users/, target: "new" },
// In progress — shadow mode for validation
{ pattern: /^\/api\/orders/, target: "legacy", shadowMode: true },
// Not yet migrated — legacy handles it
{ pattern: /^\/api\/inventory/, target: "legacy" },
// Partially migrated — new service with legacy fallback
{ pattern: /^\/api\/payments/, target: "new", fallback: "legacy" },
];
const SERVICE_URLS: Record<string, string> = {
legacy: process.env.LEGACY_URL!,
new: process.env.NEW_SERVICE_URL!,
};
async function routeRequest(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
const route = routeTable.find((r) => r.pattern.test(path));
if (!route) {
return proxyTo(req, "legacy");
}
if (route.shadowMode) {
return handleShadowMode(req, route);
}
try {
return await proxyTo(req, route.target);
} catch (error) {
if (route.fallback) {
console.error(`Primary target failed, falling back:`, error);
return proxyTo(req, route.fallback);
}
throw error;
}
}Die Routing-Tabelle ist die Kontrollebene deiner Migration. Jeder Endpunkt lässt sich unabhängig zwischen Alt- und Neusystem umschalten. Der Fallback-Mechanismus sorgt dafür, dass ein fehlschlagender neuer Service automatisch auf das Altsystem zurückfällt, statt einen Ausfall zu verursachen.
Shadow Mode: Validieren ohne Risiko
Bevor Produktionstraffic auf einen neuen Service umgestellt wird, muss sichergestellt sein, dass dieser korrekte Ergebnisse liefert. Der Shadow Mode schickt Anfragen gleichzeitig an beide Systeme, vergleicht die Antworten und liefert dem Nutzer immer die Antwort des Altsystems zurück.
async function handleShadowMode(
req: NextRequest,
route: RouteConfig
): Promise<NextResponse> {
const legacyPromise = proxyTo(req, "legacy");
// Fire-and-forget to new service — don't block the response
compareShadowResponse(req, route).catch((err) =>
console.error("Shadow comparison failed:", err)
);
return legacyPromise;
}
async function compareShadowResponse(
req: NextRequest,
route: RouteConfig
): Promise<void> {
const clonedReq = req.clone();
try {
const [legacyRes, newRes] = await Promise.all([
proxyTo(req, "legacy"),
proxyTo(clonedReq, "new"),
]);
const legacyBody = await legacyRes.json();
const newBody = await newRes.json();
const differences = findDifferences(legacyBody, newBody);
if (differences.length > 0) {
await logDiscrepancy({
path: req.nextUrl.pathname,
method: req.method,
differences,
timestamp: new Date().toISOString(),
});
}
} catch (error) {
await logDiscrepancy({
path: req.nextUrl.pathname,
method: req.method,
error: (error as Error).message,
timestamp: new Date().toISOString(),
});
}
}
function findDifferences(legacy: unknown, current: unknown): string[] {
const diffs: string[] = [];
function compare(a: unknown, b: unknown, path: string): void {
if (typeof a !== typeof b) {
diffs.push(`${path}: type mismatch (${typeof a} vs ${typeof b})`);
return;
}
if (a === null || b === null) {
if (a !== b) diffs.push(`${path}: null mismatch`);
return;
}
if (typeof a === "object" && typeof b === "object") {
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const keys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]);
for (const key of keys) {
compare(aObj[key], bObj[key], `${path}.${key}`);
}
return;
}
if (a !== b) {
diffs.push(`${path}: value mismatch (${String(a)} vs ${String(b)})`);
}
}
compare(legacy, current, "root");
return diffs;
}Der Shadow Mode ist dein Sicherheitsnetz. Lass ihn tage- oder wochenlang laufen. Analysiere die Protokolle der Abweichungen. Wenn der neue Service durchgängig mit dem Verhalten des Altsystems übereinstimmt, kannst du den Traffic mit Zuversicht umstellen.
Datensynchronisierung während der Migration
Der schwierigste Teil einer Strangler-Fig-Migration sind die Daten. Das Altsystem und die neuen Services benötigen oft Zugriff auf dieselben Daten, mitunter in unterschiedlichen Schemas. Es gibt drei Optionen: eine gemeinsame Datenbank, Datensynchronisierung oder doppelte Schreibvorgänge.
// Change Data Capture (CDC) for real-time synchronization
import { Kafka, Consumer } from "kafkajs";
interface ChangeEvent {
table: string;
operation: "INSERT" | "UPDATE" | "DELETE";
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
timestamp: string;
}
async function setupCDCConsumer(): Promise<Consumer> {
const kafka = new Kafka({ brokers: [process.env.KAFKA_BROKER!] });
const consumer = kafka.consumer({ groupId: "migration-sync" });
await consumer.connect();
await consumer.subscribe({
topic: "legacy-db.public.orders",
fromBeginning: false,
});
await consumer.run({
eachMessage: async ({ message }) => {
const event: ChangeEvent = JSON.parse(
message.value?.toString() || "{}"
);
await syncToNewSchema(event);
},
});
return consumer;
}
async function syncToNewSchema(event: ChangeEvent): Promise<void> {
if (event.table !== "orders" || !event.after) return;
const legacyOrder = event.after;
// Transform legacy schema to new schema
const newOrder = {
id: legacyOrder.order_id,
customerId: legacyOrder.customer_id,
items: JSON.parse(legacyOrder.items_json as string),
total: {
amount: legacyOrder.total_cents,
currency: legacyOrder.currency || "USD",
},
status: mapLegacyStatus(legacyOrder.status as string),
createdAt: legacyOrder.created_at,
updatedAt: new Date().toISOString(),
};
await newOrdersDb.upsert(newOrder);
}
function mapLegacyStatus(status: string): string {
const statusMap: Record<string, string> = {
"0": "pending",
"1": "confirmed",
"2": "shipped",
"3": "delivered",
"-1": "cancelled",
};
return statusMap[status] || "unknown";
}Change Data Capture mit Debezium und Kafka ist der robusteste Ansatz zur Synchronisierung. Dabei wird jede Datenbankänderung aus dem Transaktionslog des Altsystems erfasst und an die Konsumenten weitergegeben. Der neue Service transformiert die Daten und speichert sie in seinem eigenen Schema.
Feature Flags für einen schrittweisen Rollout
Die Kombination aus Strangler-Fig-Pattern und Feature Flags gibt dir eine sehr feine Kontrolle über die Migration. Statt ganze Endpunkte auf einmal umzustellen, kannst du nach Nutzersegment, Prozentsatz oder Region migrieren.
interface MigrationFlag {
name: string;
enabled: boolean;
rolloutPercentage: number;
allowList: string[];
blockList: string[];
}
const migrationFlags: Map<string, MigrationFlag> = new Map([
[
"orders-v2",
{
name: "orders-v2",
enabled: true,
rolloutPercentage: 25,
allowList: ["internal-team", "beta-users"],
blockList: ["enterprise-customer-a"],
},
],
]);
function shouldUseNewService(
flagName: string,
userId: string,
userGroups: string[]
): boolean {
const flag = migrationFlags.get(flagName);
if (!flag || !flag.enabled) return false;
// Block list takes priority
if (flag.blockList.some((g) => userGroups.includes(g))) return false;
// Allow list gets immediate access
if (flag.allowList.some((g) => userGroups.includes(g))) return true;
// Percentage-based rollout using consistent hashing
const hash = simpleHash(`${flagName}:${userId}`);
return (hash % 100) < flag.rolloutPercentage;
}
function simpleHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}Konsistentes Hashing auf Basis der Nutzer-ID sorgt dafür, dass derselbe Nutzer innerhalb eines bestimmten Rollout-Prozentsatzes immer dieselbe Erfahrung erhält. Das verhindert, dass Nutzer von Anfrage zu Anfrage zwischen altem und neuem System hin- und herspringen.
Migrationsfortschritt messen
Eine Migration ohne Kennzahlen ist eine Migration ohne Rechenschaft. Verfolge auf jeder Stufe sowohl den technischen Fortschritt als auch die geschäftlichen Auswirkungen.
interface MigrationMetrics {
endpoint: string;
totalRequests: number;
legacyRequests: number;
newServiceRequests: number;
fallbacksTriggered: number;
shadowDiscrepancies: number;
p99LatencyLegacy: number;
p99LatencyNew: number;
errorRateLegacy: number;
errorRateNew: number;
}
function generateMigrationReport(
metrics: MigrationMetrics[]
): string {
let report = "# Migration Progress Report\n\n";
report += "| Endpoint | Migration % | Fallbacks | Discrepancies | P99 New vs Legacy |\n";
report += "|----------|------------|-----------|---------------|-------------------|\n";
for (const m of metrics) {
const migrationPct = (
(m.newServiceRequests / m.totalRequests) * 100
).toFixed(1);
const latencyComparison =
m.p99LatencyNew < m.p99LatencyLegacy ? "faster" : "slower";
report += `| ${m.endpoint} | ${migrationPct}% | ${m.fallbacksTriggered} | ${m.shadowDiscrepancies} | ${latencyComparison} |\n`;
}
return report;
}Die Anzahl der Fallbacks ist dein Signal für die Zuverlässigkeit. Löst der neue Service häufig Fallbacks aus, ist er noch nicht bereit für den vollen Traffic. Die Anzahl der Abweichungen im Shadow Mode zeigt, ob sich der neue Service korrekt verhält. Beide Werte sollten gegen null tendieren, bevor der Altsystem-Endpunkt abgeschaltet wird.
Außerbetriebnahme: der letzte Schritt
Die Migration ist erst abgeschlossen, wenn das Altsystem abgeschaltet ist. Dieser Schritt verzögert sich auf unbestimmte Zeit, wenn er nicht explizit eingeplant wird.
Sobald ein Endpunkt über einen längeren Zeitraum (in der Regel 2 bis 4 Wochen) keinen Traffic mehr auf das Altsystem lenkt, entfernst du die Routing-Regel, archivierst den Legacy-Code für dieses Feature und entfernst die Synchronisierungs-Pipeline für diese Datendomäne. Jeder außer Betrieb genommene Endpunkt vereinfacht das System und reduziert den operativen Aufwand.
Die wichtigsten Erkenntnisse
Das Strangler-Fig-Pattern verwandelt eine riskante Big-Bang-Neuentwicklung in eine Reihe kleiner, reversibler Migrationsschritte. Jeder Schritt liefert Wert. Jeder Schritt lässt sich pausieren oder zurückrollen. Das Altsystem bedient weiterhin die Nutzer, während sich das neue System bewährt.
Das Pattern erfordert Investitionen in drei Fähigkeiten: einen Routing Layer, der Traffic präzise steuern kann, eine Pipeline zur Datensynchronisierung, die beide Systeme konsistent hält, und ein Monitoring, das den Migrationsfortschritt quantifiziert. Ohne alle drei migrierst du nicht schrittweise – du betreibst auf unbestimmte Zeit zwei Systeme parallel.
Migrationen enden nicht, wenn das neue System fertiggestellt ist, sondern wenn das alte System abgeschaltet wird. Plane von Anfang an für beides.


