En los bosques tropicales hay un árbol que no empieza en el suelo. La semilla de la higuera estranguladora germina en una rama alta de otro árbol, y desde ahí lanza raíces hacia abajo. Durante años el huésped sigue viviendo con normalidad: da sombra, da fruto, sostiene el peso del inquilino. Las raíces bajan, se tocan entre sí, se fusionan y forman una malla que envuelve el tronco original. Un día el huésped muere y se pudre, y lo que queda en pie es una higuera hueca con la forma exacta del árbol que reemplazó.
Y justo esta es una de las mejores metáforas para migrar un sistema legacy. No se tira y construye otro nuevo. Creces alrededor de él, le vas quitando responsabilidades una a una, y cuando ya no sostiene nada, lo talas.
1. El big bang rewrite y por qué casi siempre falla
La alternativa evidente es la reescritura completa: un equipo construye el sistema nuevo en paralelo mientras otro mantiene el viejo, y cuando el nuevo esté listo se hace el cambio de un día para otro. Es la opción que mejor suena en una reunión y la que peor envejece.
El primer problema es que el sistema viejo no se queda quieto. Mientras se reescribe, el negocio sigue pidiendo funcionalidades, y esas funcionalidades se implementan en el legacy porque es lo que está en producción. El objetivo se mueve. Cada mes que pasa, el sistema nuevo tiene un mes más de deuda que alcanzar, y no es raro que la reescritura termine persiguiendo una diana que nunca se para.
El segundo problema es que no hay valor entregado hasta el final. Durante doce o dieciocho meses el proyecto consume presupuesto y no devuelve nada medible. Es exactamente el tipo de iniciativa que se cancela cuando cambian las prioridades, y cuando se cancela, todo el trabajo se tira.
El tercero es el riesgo concentrado. El día del corte, todo lo que puede fallar falla a la vez, y el plan de vuelta atrás es "volvemos al sistema viejo", que ya lleva meses sin recibir los datos nuevos. La ventana de rollback es corta y el estrés es máximo.
El síntoma clásico de un big bang que va mal es la frase "cuando terminemos la migración". Si esa frase lleva más de dos trimestres en las reuniones, el sistema nuevo ya no es un reemplazo, es un segundo sistema legacy que además no está en producción.
2. La higuera estranguladora: la metáfora completa
Martin Fowler acuñó el nombre en 2004 después de ver estos árboles en Australia, y la metáfora aguanta bastante más de lo que suelen aguantar las metáforas.
Lo importante no es que el árbol nuevo mate al viejo. Es cómo lo hace. La higuera no compite por el mismo espacio desde el principio: empieza arriba, en un punto muy concreto, y desde ahí extiende raíces hacia zonas donde el huésped todavía manda. Cada raíz es una funcionalidad migrada. Mientras baja, el huésped sigue haciendo su trabajo; nadie en el bosque nota que hay una transición en curso.
Trasladado a software, esto se traduce en tres reglas:
- El sistema viejo sigue en producción todo el tiempo. No hay periodo de apagado, no hay ventana de mantenimiento de un fin de semana.
- Cada funcionalidad se migra de forma independiente, con su propio despliegue, su propia validación y su propia vuelta atrás.
- Alguien decide, por petición, quién responde. Esa pieza es la que hace posible todo lo demás.
La diferencia práctica con el big bang es el tamaño del riesgo por evento. En una reescritura hay un solo momento crítico enorme. En un strangler fig hay treinta momentos críticos pequeños, cada uno reversible en minutos.
Migrar un sistema no es un salto. Es una sustitución progresiva en la que, en cada instante, hay algo funcionando.
3. La pieza clave: el interceptor
Todo el patrón depende de una capa que se pone delante de los dos sistemas y decide a cuál va cada petición. Se le llama fachada, proxy, router o gateway según el contexto, pero la responsabilidad es siempre la misma: los clientes hablan con el interceptor y no saben (ni deben saber) quién les está respondiendo.
Esa pieza puede vivir en varios sitios. En infraestructura, como reglas en un nginx o en un API gateway. En la aplicación, como un middleware. En el propio código, como una interfaz con dos implementaciones. La elección depende de dónde esté el límite más limpio, pero el efecto es idéntico.
1import express from "express"
2import { createProxyMiddleware } from "http-proxy-middleware"
3
4const app = express()
5
6// Rutas ya migradas al sistema nuevo. Todo lo demás sigue en el legacy.
7const MIGRATED_ROUTES = ["/api/users", "/api/invoices"]
8
9app.use((req, res, next) => {
10 const isMigrated = MIGRATED_ROUTES.some((route) => req.path.startsWith(route))
11 const target = isMigrated ? "http://service-new:8080" : "http://legacy-monolith:8080"
12
13 return createProxyMiddleware({ target, changeOrigin: true })(req, res, next)
14})
15
16app.listen(3000)La lista de rutas convierte una migración en una configuración. Migrar /api/payments deja de ser un proyecto y pasa a ser añadir una línea, con la vuelta atrás siendo quitarla.
Instala el interceptor antes de migrar nada, con la lista de rutas migradas vacía. Así el primer despliegue solo verifica que el proxy funciona y que no rompe nada, sin mezclarlo con la primera funcionalidad. Si algo va mal, sabes exactamente qué es.
4. Migrar la primera funcionalidad
La elección de la primera funcionalidad importa más que la implementación. La tentación es empezar por lo más importante, porque es donde está el dolor, y suele ser mala idea: es lo que más acoplado está y lo que peor tolera un fallo.
Un buen primer candidato cumple tres cosas: tiene un límite claro (poca dependencia de datos que tocan otras partes), es fácil de verificar (puedes comparar la salida vieja y la nueva sin ambigüedad), y su fallo es tolerable durante cinco minutos. Un endpoint de lectura suele ser perfecto. Un listado, un detalle, un informe.
Ese primer corte es donde vas a descubrir todo lo que el sistema viejo hacía y no estaba documentado: la cabecera rara que un cliente móvil de 2019 necesita, el campo que siempre viene en null pero que alguien parsea, el ordenamiento implícito que nadie escribió pero del que depende una pantalla. Por eso conviene empezar con algo pequeño: no porque la migración sea fácil, sino porque el aprendizaje es caro y quieres pagarlo barato.
Una técnica que ayuda mucho aquí es ejecutar ambos sistemas en paralelo y comparar, sin que el nuevo responda todavía:
1async function handleWithComparison(req: Request): Promise<Response> {
2 const legacyResponse = await legacyService.handle(req)
3
4 // El nuevo se ejecuta en sombra: si falla o difiere, se registra pero no afecta.
5 void newService
6 .handle(req)
7 .then((newResponse) => {
8 if (!deepEqual(legacyResponse.body, newResponse.body)) {
9 logger.warn("strangler.mismatch", {
10 path: req.path,
11 legacy: legacyResponse.body,
12 next: newResponse.body,
13 })
14 }
15 })
16 .catch((error) => logger.error("strangler.shadow_failed", { error }))
17
18 return legacyResponse
19}Una semana de tráfico real en sombra te dice más sobre la corrección de tu implementación que cualquier suite de tests, porque el tráfico real contiene los casos que nadie imaginó. Cuando el contador de mismatch lleve días a cero, la migración es un cambio de configuración con muy poco misterio.
5. Rollout gradual y vuelta atrás
Una lista de rutas migradas es binaria: o todo el tráfico de /api/users va al sistema nuevo, o no va ninguno. Para una funcionalidad pequeña puede bastar, pero en cuanto el volumen importa conviene poder abrir el grifo poco a poco.
Aquí es donde el strangler fig se apoya de forma natural en feature flags. En vez de una lista de rutas, la decisión pasa a ser una consulta a un flag con contexto: porcentaje de tráfico, identidad del usuario, región, tipo de cliente.
1async function resolveTarget(req: Request): Promise<Backend> {
2 const useNewService = await flags.isEnabled("users-service-migration", {
3 userId: req.user?.id,
4 country: req.headers["x-country"],
5 })
6
7 return useNewService ? newBackend : legacyBackend
8}Con esto, el rollout típico es: equipo interno, luego 1%, luego 10%, luego 50%, luego todo. Entre escalón y escalón se miran las mismas métricas que se mirarían en cualquier despliegue (tasa de error, latencia p99, y las métricas de negocio que esa funcionalidad mueve), y si algo se tuerce el flag vuelve a cero en segundos sin necesidad de desplegar.
Esa es la propiedad que hace que el patrón sea sostenible: la vuelta atrás no es un plan de contingencia que nadie ha probado, es una operación que se ejecuta varias veces por semana.
Los flags de migración son flags de vida corta por definición. Cuando una funcionalidad lleva un mes al 100% en el sistema nuevo, ese flag es deuda: borra la rama del legacy y borra el flag. Un strangler fig que no limpia sus flags acaba con un router que nadie entiende.
6. Doble escritura y consistencia de datos
Todo lo anterior es cómodo mientras la funcionalidad migrada solo lee. En cuanto escribe, aparece el problema de verdad: dos sistemas, dos modelos de datos, y una realidad que tiene que ser una.
Hay tres estrategias, en orden creciente de dolor.
La más simple es compartir la base de datos. El sistema nuevo lee y escribe en las mismas tablas que el legacy. No hay inconsistencia posible porque no hay duplicación. Se paga con acoplamiento: el sistema nuevo hereda el esquema viejo, con sus nombres raros y sus columnas muertas. Para migraciones de meses es casi siempre la opción correcta, y es la que menos gente elige porque no se siente "limpia".
La segunda es la doble escritura: cada operación se escribe en los dos sistemas. Suena razonable y es la que más problemas silenciosos genera, porque la escritura no es atómica. Si la primera va bien y la segunda falla, quedan divergentes y nadie se entera hasta que un informe no cuadra.
1async function updateUser(user: User): Promise<void> {
2 await legacyRepository.save(user)
3
4 try {
5 await newRepository.save(user)
6 } catch (error) {
7 // El legacy es la fuente de verdad: no se propaga el fallo al cliente,
8 // pero se encola para reconciliar. Sin esto, la divergencia es invisible.
9 await reconciliationQueue.push({ entity: "user", id: user.id })
10 logger.error("strangler.dual_write_failed", { id: user.id, error })
11 }
12}La regla que hace viable la doble escritura es declarar una única fuente de verdad en cada momento. Al principio es el legacy y el nuevo es una copia; después del corte se invierte. Lo que nunca puede pasar es que ambos sean autoritativos a la vez, porque entonces no hay forma de resolver un conflicto salvo a mano.
La tercera estrategia es la sincronización por eventos: el legacy publica cambios (por CDC sobre el log de la base de datos, o emitiendo eventos de dominio) y el sistema nuevo los consume para construir su propio modelo. Es la más flexible y la que permite que el sistema nuevo tenga un esquema propio de verdad, pero introduce consistencia eventual y toda la infraestructura que eso arrastra. Merece la pena cuando la migración va a durar más de un año o cuando el modelo nuevo difiere mucho del viejo.
La doble escritura sin un proceso de reconciliación no es doble escritura, es duplicación con esperanza. Antes de activarla, es recomendable escribir el job que compara ambos lados y reporta las diferencias. Y ejecutarlo desde el día uno, no cuando haya sospechas de que algo va mal.
7. La parte que nadie hace: talar el huésped
La higuera termina el trabajo. El árbol huésped muere, se pudre y deja un hueco. En software, esa última fase es la que se salta el 90% de las migraciones, y es la que decide si el patrón fue un éxito o si solo añadiste un sistema más.
El estado terminal de una migración a medias es peor que no haber empezado: hay dos sistemas vivos, dos despliegues, dos on-call, un router en medio con reglas que nadie recuerda por qué están, y funcionalidades que a saber cuál de los dos sirve. La complejidad no se ha movido, se ha duplicado.
Evitarlo es más organizativo que técnico. Sirven tres cosas:
- Definir el final desde el principio. No "migrar a microservicios", sino "el monolito de facturación no recibe tráfico y su repositorio está archivado". Un criterio que se puede verificar mirando un dashboard.
- Medir el progreso por tráfico, no por funcionalidades. El porcentaje de peticiones que todavía llegan al legacy es el único número honesto. Puedes haber migrado veinte endpoints y seguir con el 70% del tráfico en el viejo.
- Borrar cuando toca, no al final. Cada vez que una funcionalidad lleva un mes estable en el nuevo, se borra su código del legacy en el mismo sprint. Si se acumula para "una limpieza grande al terminar", esa limpieza no ocurre nunca.
Cuando el contador de tráfico al legacy llega a cero y se mantiene ahí unas semanas, queda el paso más satisfactorio: apagar el servicio, esperar a que nadie grite, y borrar el repositorio. El interceptor también se puede simplificar entonces, porque ya no decide nada.
8. Cuándo no usarlo
El patrón tiene un coste real y no siempre compensa. Durante toda la migración mantienes dos sistemas, una capa de routing y, probablemente, un mecanismo de sincronización de datos. Eso es trabajo continuo.
Si el sistema es pequeño (unas semanas de reescritura), el big bang es más barato y más simple. Si el sistema está congelado y sin usuarios activos, no hay nada que estrangular. Y si el legacy no se puede poner detrás de un interceptor, porque los clientes hablan directamente con él y no puedes cambiarlos, el patrón no aplica hasta que resuelvas eso primero.
Donde brilla es en el caso más común: un sistema grande, en producción, con tráfico real, que sigue evolucionando y que nadie puede permitirse apagar. Ahí la higuera es la única estrategia que entrega valor desde el primer mes y que te deja parar en cualquier punto sin haber roto nada.
Lo que hace que funcione no es ninguna pieza de infraestructura concreta. Es aceptar que la migración no es un evento, sino una forma de trabajar durante un tiempo largo, y que la métrica del éxito no es la fecha de finalización, sino que cada semana haya un poco menos de tronco viejo sosteniendo peso.


