Zum Inhalt springen

Strangler Fig: Legacy-Systeme schrittweise ablösen

Wie du mit dem Strangler Fig Pattern Legacy-Systeme schrittweise modernisierst – ohne riskante Big-Bang-Rewrites oder lange Ausfallzeiten.

5 Min. Lesezeit
Diagramm, das zeigt, wie der Traffic schrittweise von einem Legacy-Monolithen zu neuen Microservices über einen Routing Layer wechselt

Big-Bang-Rewrites scheitern. Die Geschichte der Softwareentwicklung ist voll von mehrjährigen Neuentwicklungsprojekten, die zu spät kamen, das Budget sprengten und am Ende Funktionen vermissen ließen. Das Strangler Fig Pattern bietet eine Alternative: Es umschließt das Legacy-System, fängt dessen Traffic ab und ersetzt die Funktionalität nach und nach, bis das alte System abgeschaltet werden kann.

Das Muster ist nach dem Würgefeigenbaum (strangler fig) benannt, der so lange um seinen Wirtsbaum herumwächst, bis dieser abstirbt – es lässt dich Schritt für Schritt Mehrwert liefern und dabei das Risiko bei jedem einzelnen Schritt kontrollieren.

Die Grundlage: der Routing Layer

Alles beginnt mit einem Proxy, der zwischen den Clients und dem Legacy-System sitzt. Anfangs leitet er 100 % des Traffics an das alte System weiter. Sobald neue Services entstehen, leitet der Proxy die entsprechenden Routen nach und nach an die neuen Implementierungen um.

tstypescript
// ❌ 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,
  })
);

Der Routing Layer ist bewusst einfach gehalten. Er verursacht nur minimale Latenz und dient als der eine zentrale Kontrollpunkt für die Migration. Sobald ein neuer Service bereit ist, änderst du nur eine einzige Routenkonfiguration – nicht das gesamte System.

Migrationsstrategie auf Feature-Ebene

Migriere nicht anhand von Datenbanktabellen oder API-Endpunkten, sondern anhand von Geschäftsfähigkeiten. Eine Migration der „Benutzerverwaltung" umfasst die Benutzer-API, die zugehörigen Datenbanktabellen, die Authentifizierungslogik und die Profilseite – alles, was zu diesem Fachbereich gehört.

tstypescript
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",
  },
];

Jede Phase lässt sich unabhängig ausrollen und unabhängig zurückrollen. Geht bei Phase 2 etwas schief, läuft Phase 1 weiterhin auf dem neuen System, während Bestellungen wieder über das Legacy-System abgewickelt werden. Genau diese Isolation ist die größte Stärke des Musters.

Datensynchronisation während der Migration

Der schwierigste Teil jeder Migration ist die Datenschicht. Während des Übergangs müssen beide Systeme konsistente Daten vorhalten. Dual-Write-Muster lösen dieses Problem, verlangen aber eine sorgfältige Umsetzung.

tstypescript
// ❌ 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
}
tstypescript
// ✅ 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;
  }
}

Das Outbox Pattern sorgt dafür, dass das Anlegen der Bestellung und das Sync-Event atomar zusammen passieren. Ein separater Publisher liest die noch nicht veröffentlichten Events und schickt sie an einen Consumer, der sie in die Legacy-Datenbank schreibt. Stürzt der Publisher ab, bleiben die Events in der Outbox erhalten und werden beim Neustart nachgeholt.

Shadow Traffic zur Validierung

Bevor du echten Traffic auf einen neuen Service umstellst, nutze Shadow Traffic: Schicke eine Kopie der Produktionsanfragen an den neuen Service und vergleiche die Antworten, ohne die Nutzer zu beeinträchtigen.

tstypescript
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 deckt Abweichungen zwischen der Legacy- und der neuen Implementierung auf, bevor sie Nutzer betreffen. Lass es mindestens eine Woche laufen, um Randfälle zu erfassen, die nur bei bestimmten Datenmustern oder zeitabhängiger Logik auftreten.

Migrationsfortschritt messen

Miss den Migrationsfortschritt mit Kennzahlen, die für Stakeholder zählen, nicht nur für Entwicklerinnen und Entwickler. „60 % der Routen migriert" sagt weniger aus als „60 % des umsatzrelevanten Traffics laufen bereits auf dem neuen System".

tstypescript
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,
  };
}

Das Legacy-System abschalten

Der letzte Schritt ist politisch oft der schwierigste. Das Legacy-System sollte erst abgeschaltet werden, wenn der gesamte Traffic auf den neuen Services verifiziert wurde und eine angemessene Bewährungsphase verstrichen ist.

tstypescript
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,
  };
}

Die wichtigsten Erkenntnisse

Das Strangler Fig Pattern funktioniert, weil es Geschwindigkeit gegen Sicherheit eintauscht. Jede Migrationsphase liefert unabhängig Mehrwert, lässt sich unabhängig zurückrollen und bereitet das Team auf die nächste Phase vor. Die Proxy-Schicht gibt dir einen einzigen zentralen Kontrollpunkt für das Traffic-Routing. Das Outbox Pattern sichert die Datenkonsistenz während des Übergangs. Shadow Traffic validiert neue Implementierungen an der Realität der Produktion.

Teams, die mit diesem Muster Schwierigkeiten haben, sind meist die, die zu viel auf einmal migrieren wollen oder die Shadow-Traffic-Phase überspringen. Geduld ist die wichtigste Zutat des Musters – das Legacy-System hat Jahre gebraucht, um zu entstehen, und es sicher zu ersetzen, braucht ebenfalls Zeit.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX