Zum Inhalt springen

Strangler Fig Pattern: Legacy schrittweise migrieren

Ersetze Legacy-Systeme schrittweise mit routenbasierter Migration, Feature Flags, Datensynchronisation und Rollback — bei laufendem Betrieb.

5 Min. Lesezeit
Visualisierung des Strangler Fig Patterns, die zeigt, wie Routen des alten Systems im Zeitverlauf schrittweise durch Handler des neuen Systems ersetzt werden

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.

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

tstypescript
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.

tstypescript
// 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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX