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.

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.
// ❌ 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.
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.
// ❌ 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
}// ✅ 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.
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".
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.
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.


