Saltar al contenido

Strangler Fig Pattern: migración incremental de legacy

Reemplaza sistemas legacy de forma incremental con migración por rutas, feature flags, sincronización de datos y rollback sin parar producción.

5 min de lectura
Visualización del strangler fig pattern que muestra las rutas del sistema antiguo siendo reemplazadas gradualmente por manejadores del nuevo sistema a lo largo del tiempo

Reescribir un sistema legacy desde cero casi siempre fracasa. El nuevo sistema tarda más de lo estimado, el sistema antiguo sigue recibiendo funcionalidades que la reescritura no contempló, y el equipo termina manteniendo dos sistemas en paralelo. El strangler fig pattern evita esto reemplazando el sistema antiguo de forma incremental, pieza por pieza, mientras la producción sigue funcionando sin interrupciones.

El nombre proviene de los árboles strangler fig, que crecen alrededor de árboles existentes y los reemplazan gradualmente. Este patrón dirige el tráfico a través de un facade que decide si cada solicitud debe ir al sistema antiguo o al nuevo. Con el tiempo, más rutas pasan al nuevo sistema hasta que el antiguo puede darse de baja.

El router facade

El componente central es un proxy inverso o API gateway que toma las decisiones de enrutamiento. El tráfico entra por un único endpoint y se dirige hacia el sistema legacy o el nuevo, según corresponda.

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);

Migración controlada por feature flags

Para las rutas que están en proceso de migración, se utilizan feature flags para controlar el porcentaje de tráfico migrado. Esto permite desplazar el tráfico de forma gradual y hacer rollback de manera instantánea.

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);
}

Sincronización de datos durante la migración

La parte más difícil de una migración strangler fig es mantener la consistencia de los datos entre el sistema antiguo y el nuevo durante el período de transición.

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

Verificación: shadow testing

Antes de desviar el tráfico, hay que verificar que el nuevo sistema produce resultados correctos: se ejecutan las solicitudes en ambos sistemas y se comparan las salidas.

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();
  });
}

Finalización de la migración y baja del sistema legacy

Es necesario llevar un seguimiento del avance de la migración para saber cuándo es seguro dar de baja el sistema legacy.

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

Conclusiones clave

El strangler fig pattern reemplaza sistemas legacy de forma incremental mediante un facade de enrutamiento que dirige el tráfico al sistema antiguo o al nuevo ruta por ruta: cada ruta migrada es un cambio pequeño y reversible que mantiene la producción funcionando de forma continua. Los feature flags con rollout basado en porcentajes y hashing consistente de usuarios permiten desplazar el tráfico de manera gradual del sistema legacy al nuevo, con rollback instantáneo simplemente llevando el porcentaje de vuelta a cero, sin necesidad de un nuevo deployment ni de cambios de DNS. La sincronización de datos durante la migración requiere escrituras duales desde el nuevo sistema hacia el legacy, con colas de reintento para las sincronizaciones fallidas, manteniendo la consistencia en las rutas que todavía leen de la base de datos antigua hasta que la migración se completa. El shadow testing ejecuta las solicitudes en ambos sistemas y compara las salidas antes de desviar el tráfico, detectando incompatibilidades en el formato de los datos, mapeos de campos faltantes y diferencias de comportamiento que las pruebas de integración no detectan.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX