Migrar un monolito heredado a servicios sin detener el negocio: el patrón strangler fig en la práctica
Cuando un monolito lleva años en producción, la propuesta de reescribirlo desde cero casi nunca sobrevive a la primera conversación seria sobre presupuesto y riesgo. El negocio no puede detener el sistema que factura, atiende soporte o procesa pedidos mientras un equipo reconstruye todo en paralelo, y una reescritura completa suele tardar mucho más de lo estimado, con el agravante de que durante todo ese tiempo el sistema viejo sigue recibiendo cambios que el nuevo tiene que volver a incorporar. El patrón strangler fig evita ese dilema: en vez de reescribir y luego reemplazar, se extraen módulos del monolito uno a la vez, se enruta el tráfico correspondiente hacia el servicio nuevo, y el monolito sigue vivo y en producción hasta que, módulo por módulo, deja de tener nada que hacer.
El nombre viene de la higuera estranguladora, una planta que crece alrededor de un árbol huésped hasta reemplazarlo por completo sin que el árbol original deje de sostenerlo mientras dura el proceso. La metáfora es literal en el diseño técnico: el sistema nuevo crece envolviendo al viejo, no reemplazándolo de golpe.
El componente que hace posible la migración gradual
El elemento central del patrón no es el servicio nuevo, es el enrutador que decide, para cada petición entrante, si la atiende el monolito o el servicio recién extraído. Sin ese componente no hay migración incremental posible: cualquier extracción obligaría a mover todo el tráfico de una vez.
En la práctica esto se implementa como una capa de proxy inversa delante de ambos sistemas, con reglas de enrutamiento basadas en la ruta o en el recurso de negocio:
# nginx como capa de fachada durante la migración
location /api/catalogo/ {
proxy_pass http://catalogo-service:8080;
}
location /api/ {
proxy_pass http://monolito-legado:8080;
}
La regla de rutas específicas antes que la genérica no es un detalle menor: nginx evalúa location por especificidad de coincidencia, así que cualquier extracción nueva necesita su propio bloque explícito, o el tráfico sigue cayendo silenciosamente en el monolito aunque el servicio nuevo ya esté desplegado. Ese es un error fácil de cometer en la primera extracción: el equipo despliega el servicio, lo prueba de forma aislada, y al no ver una regla de enrutamiento actualizada, el tráfico real jamás llega a tocarlo.
Para casos donde la decisión de enrutamiento no depende solo de la ruta sino de una condición de negocio -por ejemplo, migrar primero un subconjunto de clientes o de tenants antes que el resto-, la lógica se mueve a una capa de aplicación en vez de configuración estática de proxy:
function resolveHandler(req) {
if (req.path.startsWith('/api/catalogo') && isMigratedTenant(req.tenantId)) {
return catalogoServiceClient;
}
return monolitoLegacyClient;
}
Esta versión permite hacer un rollout progresivo por tenant, con la posibilidad de revertir un tenant puntual al monolito si algo falla, sin tocar el enrutamiento del resto.
Qué módulo extraer primero
El criterio que aplicamos no es extraer el módulo más urgente de reescribir, ni el más pequeño porque es el más fácil. Es extraer el módulo con el límite de dominio más claro y el menor acoplamiento de datos con el resto del sistema. Un módulo que solo lee y escribe sus propias tablas, sin joins directos contra tablas de otros dominios dentro del mismo monolito, es un candidato mucho mejor que uno que técnicamente parece independiente en el código pero comparte esquema de base de datos con media docena de otras funcionalidades.
La razón es que la dificultad real de una extracción casi nunca está en el código de negocio -eso se puede reescribir de forma más o menos mecánica-, está en desenredar las dependencias de datos. Un módulo con datos ya aislados se puede extraer con su propia base de datos desde el primer día. Un módulo con datos entrelazados obliga a decidir, antes de escribir una sola línea del servicio nuevo, cómo va a funcionar la sincronización mientras ambos sistemas coexistan, y esa pregunta sin responder es la que después genera los problemas más difíciles de depurar.
Dos fuentes de verdad: el riesgo que no se puede evitar, solo acotar
Mientras dura la migración, hay un período -que puede extenderse durante meses según cuántos módulos queden por extraer- en el que el mismo dato de negocio puede existir tanto en la base de datos del monolito como en la del servicio nuevo. Ignorar ese período y asumir que "ya vamos a resolver la sincronización después" es la forma más común de que un strangler fig fracase a mitad de camino.
El patrón que nos funcionó es tratar al monolito como la fuente de verdad hasta que el módulo extraído queda completamente independiente, y usar un outbox transaccional en el monolito para publicar los cambios relevantes como eventos que el servicio nuevo consume:
-- Dentro de la misma transacción que actualiza la tabla de negocio
INSERT INTO outbox_events (aggregate_id, event_type, payload, created_at)
VALUES ($1, 'catalogo.producto.actualizado', $2, now());
Un proceso separado lee la tabla outbox_events y publica cada fila a una cola, borrándola o marcándola como procesada después. La ventaja de escribir el evento dentro de la misma transacción que el cambio de negocio es que no puede haber un cambio en el monolito que se pierda sin generar su evento correspondiente: si la transacción falla, ni el cambio ni el evento existen; si tiene éxito, ambos existen de forma atómica.
El servicio nuevo consume esos eventos para mantener su propia copia de los datos que necesita, pero durante la transición sigue sin ser la fuente de verdad: cualquier escritura que el servicio nuevo necesite hacer sobre un dato que el monolito todavía posee se reenvía al monolito, no se aplica directamente en la base del servicio nuevo. Esa asimetría es incómoda de mantener en el código, pero evita el escenario mucho peor de que ambos sistemas acepten escrituras sobre el mismo dato sin ningún mecanismo de resolución de conflictos.
Lo que no funcionó: extraer un módulo sin desacoplar antes sus tablas
En una de las primeras extracciones intentamos saltarnos el trabajo de desacoplar el esquema de base de datos, con la idea de que el servicio nuevo consultara directamente algunas tablas del monolito mientras se resolvía la separación completa más adelante. El razonamiento era ahorrar tiempo: el servicio nuevo ya podía funcionar sin esperar a un proyecto separado de migración de datos.
El problema apareció rápido. El monolito seguía escribiendo en esas tablas compartidas con su propia lógica de validación y sus propios triggers, y el servicio nuevo las leía asumiendo invariantes que esa lógica garantizaba pero que no estaban documentadas en ningún lado fuera del código del monolito. Cuando una migración de esquema en el monolito modificó una de esas tablas por una razón completamente ajena a la extracción en curso, el servicio nuevo empezó a fallar de una forma que nadie relacionó de inmediato con ese cambio, porque desde la perspectiva del equipo que tocó el monolito esa tabla ya "pertenecía" al servicio nuevo. Revertimos el enrutamiento de ese módulo de vuelta al monolito mientras resolvíamos el desacoplamiento de datos, y solo volvimos a intentar la extracción una vez que el servicio nuevo tuvo su propio esquema y consumía los datos exclusivamente por eventos, nunca por acceso directo a tablas ajenas.
El criterio que quedó
La secuencia que seguimos ahora invierte el orden intuitivo: primero se desacopla el modelo de datos del módulo a extraer, aunque eso signifique escribir código de sincronización que en apariencia no aporta funcionalidad nueva, y solo después se mueve la lógica de negocio al servicio nuevo. La tentación de extraer primero por el código y resolver los datos después es exactamente la que generó el problema descrito arriba. El límite de dominio que de verdad importa para decidir el orden de extracción no es el límite que se ve en la estructura de carpetas del monolito, es el límite real del esquema de base de datos, y ese es el que hay que resolver primero.