Saltar al contenido

Strangler Fig: reemplazo incremental de sistemas heredados

Cómo aplicar el strangler fig pattern para migrar sistemas heredados hacia arquitecturas modernas sin reescrituras arriesgadas ni caídas largas.

6 min de lectura
Diagrama que muestra el tráfico desplazándose gradualmente de un monolito heredado hacia nuevos microservicios a través de un routing layer

Las reescrituras totales fracasan. La historia de la ingeniería de software está llena de proyectos de reescritura de varios años que se entregaron tarde, superaron el presupuesto y llegaron con funcionalidades incompletas. El strangler fig pattern ofrece una alternativa: envolver el sistema heredado, interceptar su tráfico y reemplazar la funcionalidad de forma gradual hasta que el sistema antiguo pueda retirarse.

El nombre de este patrón proviene del árbol strangler fig (la higuera estranguladora), que crece alrededor de su árbol huésped hasta provocar su muerte; el patrón te permite entregar valor de forma incremental mientras gestionas el riesgo en cada paso.

La base del routing layer

Todo comienza con un proxy situado entre los clientes y el sistema heredado. Al principio, enruta el 100% del tráfico hacia el sistema antiguo. A medida que se construyen nuevos servicios, el proxy redirige gradualmente las rutas hacia las nuevas implementaciones.

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

El routing layer es intencionalmente simple. Añade una latencia mínima y actúa como el único punto de control de la migración. Cuando un nuevo servicio está listo, basta con cambiar la configuración de una ruta, no todo el sistema.

Estrategia de migración por funcionalidades

No migres por tabla de base de datos ni por endpoint de API. Migra por capacidad de negocio. Una migración de "gestión de usuarios" incluye la API de usuarios, las tablas de base de datos de usuarios, la lógica de autenticación y la página de perfil: todo lo relacionado con ese dominio.

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

Cada fase se puede desplegar y revertir de forma independiente. Si la Fase 2 falla, la Fase 1 sigue funcionando en el nuevo sistema mientras los pedidos vuelven a depender del sistema heredado. Este aislamiento es la mayor fortaleza del patrón.

Sincronización de datos durante la migración

La parte más difícil de cualquier migración es la capa de datos. Durante la transición, ambos sistemas necesitan datos consistentes. Los patrones de escritura dual resuelven esto, pero requieren una implementación cuidadosa.

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

El outbox pattern garantiza que la creación del pedido y el evento de sincronización sean atómicos. Un publicador independiente lee los eventos no publicados y los envía a un consumidor que escribe en la base de datos heredada. Si el publicador falla, los eventos permanecen en el outbox y se procesan al reiniciar.

Shadow traffic para la validación

Antes de dirigir tráfico real a un nuevo servicio, ejecuta shadow traffic: envía una copia de las solicitudes de producción al nuevo servicio y compara las respuestas sin afectar a los usuarios.

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

El shadow traffic revela discrepancias entre la implementación heredada y la nueva antes de que afecten a los usuarios. Ejecútalo durante al menos una semana para detectar casos límite que solo aparecen con determinados patrones de datos o lógica dependiente del tiempo.

Cómo medir el progreso de la migración

Haz seguimiento del progreso de la migración con métricas que importen a los interesados del negocio, no solo a los ingenieros. Decir que "el 60% de las rutas está migrado" tiene menos peso que decir que "el 60% del tráfico que genera ingresos ya corre en el nuevo sistema".

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

Retirada del sistema heredado

El último paso suele ser el más difícil desde el punto de vista político. El sistema heredado solo debería retirarse después de verificar todo el tráfico en los nuevos servicios y de que haya transcurrido un período de estabilización razonable.

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

Conclusiones clave

El strangler fig pattern funciona porque cambia velocidad por seguridad. Cada fase de la migración entrega valor de forma independiente, puede revertirse de forma independiente y enseña al equipo lo que necesita saber para la siguiente fase. La capa de proxy te da un único punto de control para el enrutamiento del tráfico. El outbox pattern garantiza la consistencia de los datos durante la transición. El shadow traffic valida las nuevas implementaciones frente a la realidad de producción.

Los equipos que tienen problemas con este patrón son los que intentan migrar demasiado de golpe o se saltan la fase de shadow traffic. La paciencia es el ingrediente más importante del patrón: el sistema heredado tardó años en construirse, y reemplazarlo con seguridad también llevará tiempo.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX