Saltar al contenido

Strangler Fig: migrar monolitos sin una gran reescritura

Guía práctica para reemplazar monolitos heredados de forma incremental con el patrón strangler fig: enrutamiento, sincronización de datos y rollback.

6 min de lectura
Diagrama que muestra la migración gradual de un monolito a microservicios

Por qué fracasan las grandes reescrituras

Todo equipo de ingeniería se enfrenta tarde o temprano a la misma tentación: el sistema heredado resulta doloroso, el código está enmarañado y alguien propone reescribirlo desde cero. El razonamiento suena lógico. En la práctica, las grandes reescrituras fracasan más veces de las que tienen éxito.

El nuevo sistema tiene que alcanzar la paridad de funcionalidades con un objetivo que no deja de moverse. El sistema antiguo sigue evolucionando mientras se construye el nuevo. Los responsables del negocio pierden la paciencia cuando la reescritura tarda el doble de lo previsto, porque siempre tarda el doble. Mientras tanto, hay que mantener ambos sistemas a la vez y el equipo queda dividido entre ellos.

El patrón strangler fig ofrece una alternativa. Su nombre proviene de la higuera estranguladora, la enredadera que envuelve lentamente a un árbol huésped: el patrón reemplaza un sistema heredado de forma incremental —una capacidad a la vez— hasta que el sistema antiguo puede eliminarse por completo. En cada paso, el sistema funciona. En cada paso, puedes detenerte y aun así haber entregado valor.

Cómo funciona el patrón

El strangler fig actúa mediante tres mecanismos: interceptar, enrutar y reemplazar. Una routing layer se sitúa delante de los sistemas antiguo y nuevo. Las solicitudes entran por esa capa, que decide si enviarlas al sistema heredado o al nuevo servicio.

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

La tabla de rutas es el plano de control de tu migración. Cada endpoint puede alternarse de forma independiente entre el sistema heredado y el nuevo. El mecanismo de fallback hace que, si el nuevo servicio falla, la solicitud se degrade automáticamente hacia el sistema heredado en lugar de provocar una caída del servicio.

Shadow mode: validar sin riesgo

Antes de dirigir tráfico de producción hacia un nuevo servicio, necesitas confianza en que produce resultados correctos. El shadow mode envía las solicitudes a ambos sistemas simultáneamente, compara las respuestas y siempre devuelve al usuario la respuesta del sistema heredado.

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

El shadow mode es tu red de seguridad. Mantenlo activo durante días o semanas. Analiza los registros de discrepancias. Cuando el nuevo servicio coincide de forma consistente con el comportamiento del sistema heredado, puedes cambiar el tráfico con confianza.

Sincronización de datos durante la migración

La parte más difícil de las migraciones con strangler fig es el dato. El sistema heredado y los nuevos servicios suelen necesitar acceso a los mismos datos, a veces con esquemas distintos. Tienes tres opciones: base de datos compartida, sincronización de datos o escritura dual.

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

El Change Data Capture con Debezium y Kafka es el enfoque de sincronización más robusto. Captura cada cambio de la base de datos a partir del registro de transacciones del sistema heredado y lo envía a los consumidores. El nuevo servicio transforma los datos y los almacena en su propio esquema.

Feature flags para un rollout gradual

Combinar el strangler fig con feature flags te da un control muy preciso sobre la migración. En lugar de cambiar endpoints completos de una vez, puedes migrar por segmento de usuario, por porcentaje o por geografía.

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

El hashing consistente basado en el ID de usuario garantiza que un mismo usuario reciba siempre la misma experiencia dentro de un porcentaje de rollout determinado. Esto evita que los usuarios salten entre el sistema antiguo y el nuevo de una solicitud a otra.

Medir el progreso de la migración

Una migración sin métricas es una migración sin responsabilidad. Haz seguimiento tanto del progreso técnico como del impacto en el negocio en cada etapa.

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

El número de fallbacks es tu señal de fiabilidad. Si el nuevo servicio dispara fallbacks con frecuencia, todavía no está listo para recibir todo el tráfico. El número de discrepancias del shadow mode te indica si el nuevo servicio se comporta correctamente. Ambos deberían tender a cero antes de dar de baja el endpoint heredado.

Dar de baja el sistema: el paso final

La migración no está completa hasta que el sistema heredado se apaga. Este paso se posterga indefinidamente si no lo planificas de forma explícita.

Cuando un endpoint lleva un periodo sostenido sin tráfico heredado (normalmente entre 2 y 4 semanas), elimina la regla de enrutamiento, archiva el código heredado de esa funcionalidad y retira el pipeline de sincronización de ese dominio de datos. Cada endpoint dado de baja simplifica el sistema y reduce la carga operativa.

Conclusiones clave

El patrón strangler fig convierte una reescritura arriesgada de golpe único en una serie de migraciones pequeñas y reversibles. Cada paso entrega valor. Cada paso puede pausarse o revertirse. El sistema heredado sigue atendiendo a los usuarios mientras el nuevo sistema demuestra que funciona.

El patrón exige invertir en tres capacidades: una routing layer capaz de dirigir el tráfico con precisión, un pipeline de sincronización de datos que mantenga ambos sistemas consistentes y un sistema de monitorización que cuantifique el progreso de la migración. Sin las tres, no estás migrando de forma incremental: estás manteniendo dos sistemas de manera indefinida.

Las migraciones no terminan cuando el nuevo sistema está construido, sino cuando el sistema antiguo se da de baja. Planifica ambos extremos desde el principio.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX