Módulo 3: El patrón strangler fig
Construir el servicio nuevo al lado
Descripción
En la lección anterior pusiste el facade delante del legacy y verificaste que es transparente: el punto de control quedó instalado, en 0%, sin cambiar una sola respuesta. Ahora viene la segunda fase del strangler fig, y es donde por fin escribes código nuevo: construir el servicio nuevo al lado del legacy. La palabra clave es al lado. No reemplazas nada, no tocas el legacy, no cortas ningún cable. Levantas una implementación nueva —el modern_catalog— que vive en paralelo, que responde al mismo GET /products, y que el facade podrá llamar cuando tú lo decidas.
Aquí hay una distinción que es el corazón de la lección: el servicio nuevo tiene que respetar el mismo contrato público que el legacy —el mismo endpoint, la misma forma de respuesta, las mismas claves— pero es libre de tener una implementación interna completamente distinta. El legacy quizás guarda los productos en una lista plana; el modern puede guardarlos en un store con stock, en otra base de datos, en otro lenguaje incluso. Lo que importa es que, visto desde afuera —desde el facade y desde el cliente—, los dos hablen el mismo idioma. Es esa igualdad de contrato la que permite que el facade los intercambie sin que el cliente note el cambio.
Y esto es lo que hace al strangler tan seguro: el servicio nuevo se construye sin ninguna presión de romper el viejo, porque el viejo sigue sirviendo el 100% del tráfico mientras tú desarrollas. Puedes tomarte tu tiempo, escribir el modern bien, probarlo con calma, correrlo contra los characterization tests del módulo 2 —y solo cuando esté listo, el facade empieza a mandarle un chorrito de tráfico real—. Construir al lado es construir en paz.
Conexión con el módulo. La lección 2 puso el facade (fase 1); esta construye el servicio nuevo al lado (fase 2). Con las dos piezas en su lugar —el facade que decide y el modern que ya existe— la lección 4 hace la fase 3: desviar el tráfico por porcentaje, subiendo el traffic_percent para que el facade empiece a llamar al modern que aquí construyes. Fíjate en la frontera: aquí construimos el modern como una función/servicio que coexiste y verificamos que cumple el mismo contrato, pero no hacemos todavía la comparación exhaustiva viejo-vs-nuevo que detecta discrepancias antes de confiar —eso es el parallel-run del módulo 6, que compara las respuestas de los dos sobre datos reales y reporta las diferencias—. Aquí solo confirmamos que el nuevo responde el mismo contrato; validarlo a fondo con datos históricos es trabajo de la migración de datos. Y cómo está construido el modern por dentro —si es un microservicio, orientado a eventos, con qué base de datos— es decisión de las guías de estilos y de datos; aquí nos importa solo que exista al lado y hable el contrato.
Una analogía: la cocina nueva que se construye sin cerrar la vieja
Volvamos al restaurante de la lección anterior. Ya tienes al mesero (el facade) tomando todos los pedidos y llevándolos a la cocina vieja. Ahora quieres una cocina nueva —con mejores hornos, más rápida, mejor distribuida—. ¿La construyes cerrando el restaurante dos meses? No: la construyes en el local de al lado, mientras la cocina vieja sigue sirviendo a los comensales todos los días.
Mientras la construyes, el restaurante no pierde un solo servicio. Los comensales comen igual que siempre, servidos por la cocina vieja. Tú, en paz, montas la cocina nueva: instalas los hornos, contratas al equipo, ajustas las recetas. Y aquí está el punto crítico: la cocina nueva tiene que producir los mismos platos del menú —una hamburguesa tiene que salir siendo una hamburguesa reconocible, con su pan, su carne, sus ingredientes—, aunque por dentro la cocina nueva funcione completamente distinto: otros hornos, otro flujo de trabajo, otra forma de organizar los ingredientes. El comensal pide "hamburguesa" y recibe una hamburguesa; no le importa —ni lo nota— en qué cocina se hizo.
El menú es el contrato público: GET /products, con su forma de respuesta. La cocina es la implementación interna: la lista plana del legacy, o el store con stock del modern. Construir la cocina nueva al lado, con el mismo menú pero otra cocina por dentro, y sin cerrar la vieja, es exactamente lo que hace esta fase del strangler. Cuando la cocina nueva esté probada, el mesero empezará a mandarle algunos pedidos —primero pocos—, y ahí entra la lección 4.
Ejemplo trabajado: el modern al lado del legacy, misma interfaz, otra cocina
Vamos a construir el modern_catalog al lado del legacy_catalog y a demostrar tres cosas: que tienen implementaciones internas distintas, que responden el mismo contrato público, y que el facade puede desviar un canary de 20% al nuevo mientras el viejo sigue sirviendo el resto. Fíjate en la diferencia de estructura: el legacy usa una lista plana de productos; el modern usa un store —un diccionario con stock por producto—. Son cocinas distintas, y aun así el GET /products devuelve lo mismo.
import zlib
# === LEGACY: implementacion vieja. Lista plana, sin cambios. No se toca. ===
LEGACY_ROWS = ["ssd", "usb-hub", "webcam", "hdmi"]
def legacy_catalog(request):
q = request.get("q", "")
items = [p for p in LEGACY_ROWS if q in p] if q else list(LEGACY_ROWS)
return {"status": 200, "source": "legacy", "products": items}
# === MODERN: servicio nuevo, construido AL LADO. Otra estructura interna, ===
# === mismo contrato publico. Se desarrolla y prueba sin tocar el legacy. ===
MODERN_STORE = {
"ssd": {"name": "ssd", "stock": 12},
"usb-hub": {"name": "usb-hub", "stock": 3},
"webcam": {"name": "webcam", "stock": 0},
"hdmi": {"name": "hdmi", "stock": 20},
}
def modern_catalog(request):
q = request.get("q", "")
names = [row["name"] for row in MODERN_STORE.values() if q in row["name"]] if q \
else [row["name"] for row in MODERN_STORE.values()]
return {"status": 200, "source": "modern", "products": names}
# === El facade puede apuntar a cualquiera de los dos. Aqui, canary de 20%. ===
def bucket(request_id):
return zlib.crc32(str(request_id).encode()) % 100
def strangler_router(request, traffic_percent):
if bucket(request["id"]) < traffic_percent:
return modern_catalog(request)
return legacy_catalog(request)
# --- Coexistencia: ambos vivos. Verificamos que modern cumple el mismo contrato. ---
def satisfies_contract(resp):
return (resp.get("status") == 200
and isinstance(resp.get("products"), list))
sample = [{"id": i, "path": "GET /products", "q": ""} for i in range(1, 11)]
print("Servicio nuevo construido AL LADO del legacy - canary 20%\n")
print(f"{'req':>4}{'ruta':>10}{'products':>28}{'contrato ok?':>14}")
print("-" * 56)
for req in sample:
resp = strangler_router(req, traffic_percent=20)
ok = satisfies_contract(resp)
print(f"{req['id']:>4}{resp['source']:>10}{','.join(resp['products']):>28}{('SI' if ok else 'NO'):>14}")
# El legacy sigue intacto; el nuevo ya responde el mismo contrato publico.
print("-" * 56)
print("\nContrato publico identico (misma clave 'products', mismo GET /products).")
print("Estructura INTERNA distinta: el legacy usa una lista; modern, un store con stock.")
print("El legacy no se toco: el nuevo vive a su lado y el facade elige a cual llamar.")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
Servicio nuevo construido AL LADO del legacy - canary 20%
req ruta products contrato ok?
--------------------------------------------------------
1 legacy ssd,usb-hub,webcam,hdmi SI
2 legacy ssd,usb-hub,webcam,hdmi SI
3 modern ssd,usb-hub,webcam,hdmi SI
4 modern ssd,usb-hub,webcam,hdmi SI
5 legacy ssd,usb-hub,webcam,hdmi SI
6 legacy ssd,usb-hub,webcam,hdmi SI
7 legacy ssd,usb-hub,webcam,hdmi SI
8 legacy ssd,usb-hub,webcam,hdmi SI
9 modern ssd,usb-hub,webcam,hdmi SI
10 legacy ssd,usb-hub,webcam,hdmi SI
--------------------------------------------------------
Contrato publico identico (misma clave 'products', mismo GET /products).
Estructura INTERNA distinta: el legacy usa una lista; modern, un store con stock.
El legacy no se toco: el nuevo vive a su lado y el facade elige a cual llamar.
Lee la columna ruta: de las 10 requests, tres (las 3, 4 y 9) fueron al modern y siete al legacy. Eso es el canary de 20% en acción —el bucket estable manda esos tres ids a la ruta nueva—. Pero lo interesante no es solo que algunas fueron al nuevo, sino que en la columna products la respuesta es idéntica venga del legacy o del modern: ssd,usb-hub,webcam,hdmi en las diez filas. El cliente que recibe cualquiera de estas respuestas no puede saber cuál cocina la produjo. Eso es el mismo menú desde dos cocinas.
Y la columna contrato ok? da SI en las diez filas. La función satisfies_contract verifica lo esencial del contrato: que la respuesta tenga status == 200 y que products sea una lista. Que el modern pase esa verificación en cada request es lo que le da permiso al facade de mandarle tráfico: si el modern devolviera un status distinto, o un products que no es lista, romper el contrato rompería al cliente, y el facade no debería llamarlo. La verificación de contrato es el mínimo que el servicio nuevo debe cumplir para coexistir.
Fíjate en el detalle de las cocinas distintas. El legacy itera sobre LEGACY_ROWS, una lista de strings. El modern itera sobre MODERN_STORE.values(), diccionarios con name y stock, y extrae solo el name para armar la respuesta. Son dos implementaciones que no comparten ni una línea de lógica interna —el modern hasta tiene información que el legacy no tiene, el stock— y aun así producen la misma salida pública. Esa independencia es deliberada: el modern es una reimplementación desde el contrato, no un copiar-pegar del legacy. Puede evolucionar por su cuenta (mañana el stock se usará para ocultar productos agotados), pero hoy, para coexistir, solo necesita hablar el mismo GET /products.
Profundización: el contrato como frontera, y qué significa "al lado"
El contrato público es la frontera entre lo que el mundo exterior ve y lo que cada servicio hace por dentro. Mientras los dos servicios respeten esa frontera, el facade los puede intercambiar libremente. Es la misma idea que sostiene toda la programación contra interfaces: el llamador depende del qué (el contrato), no del cómo (la implementación). El strangler la lleva a escala de sistema: el GET /products es la interfaz, y legacy_catalog y modern_catalog son dos implementaciones de ella que el facade elige por porcentaje.
GET /products (el contrato: status 200, products: [...])
│
┌───────────────┴───────────────┐
│ │
legacy_catalog modern_catalog
(lista plana) (store con stock)
la cocina vieja la cocina nueva, al lado
"Al lado" tiene un significado técnico preciso: el modern no comparte estado mutable con el legacy de forma que uno pueda romper al otro. Son independientes en su ejecución. En este módulo los simulamos como dos funciones en el mismo proceso —suficiente para ver el desvío de tráfico—, pero la idea escala: en producción, el modern suele ser un proceso o servicio aparte, con su propio despliegue. Lo que no cambia con la escala es la propiedad esencial: construir el nuevo no requiere tocar el viejo. El legacy sirve el 100% del tráfico mientras el modern se construye y se prueba; solo cuando el modern está listo, el facade le empieza a dar trabajo.
Hay una tentación que vale la pena nombrar: querer que el modern sea "mejor" desde el primer día —más features, mejor formato, más rápido—. En esta fase, el objetivo del modern no es ser mejor, es cumplir el contrato. Un modern que hace exactamente lo que el legacy hace, pero con una implementación limpia y probada, ya es un avance enorme: es una rebanada del monolito reescrita bajo control. Las mejoras vienen después, cuando el modern ya sirve el tráfico y puedes evolucionarlo con seguridad. Primero paridad de contrato; luego, mejoras. (Y ojo: la comparación exhaustiva de si el modern realmente produce lo mismo que el legacy sobre todos los datos —no solo la forma, sino los valores— es el parallel-run del módulo 6. Aquí verificamos la forma del contrato; allá se verifica la equivalencia de contenido.)
Errores comunes
Copiar el legacy en vez de reimplementar desde el contrato. Qué pasa: para "ir rápido", el equipo copia el código del legacy al modern y le hace ajustes cosméticos. Por qué pasa: reimplementar desde cero da miedo y copiar parece seguro. Cómo detectarlo: el modern arrastra las mismas rarezas, los mismos bugs y la misma estructura enredada del legacy —incluido el conocimiento tácito mal entendido del módulo 1—. Cómo corregirlo: el modern debe construirse desde el contrato y desde los characterization tests (módulo 2), no desde el código viejo. Los characterization tests te dicen qué comportamiento preservar (incluidos los bugs que sí importan); a partir de ahí, escribes una implementación limpia que pase esos tests. Copiar el legacy te deja con dos copias del mismo problema; reimplementar desde el contrato te deja con una versión limpia y entendida. El punto del strangler no es mover el legacy, es reemplazarlo por algo mejor construido.
Romper el contrato "para mejorar de una vez". Qué pasa: el equipo aprovecha el modern para cambiar la forma de la respuesta —renombrar products a items, agregar un envoltorio, cambiar el tipo de un campo—. Por qué pasa: el modern es código nuevo y da la sensación de que "ahora sí podemos hacerlo bien". Cómo detectarlo: satisfies_contract falla, o —peor— pasa la verificación laxa pero el cliente real se rompe porque esperaba products y recibió items. Cómo corregirlo: en la fase de coexistencia, el contrato es sagrado: el modern responde exactamente la misma forma que el legacy, porque el facade los intercambia sin avisarle al cliente. Cambiar el contrato es un cambio que todos los clientes deben coordinar, y no se puede hacer con un canary silencioso. Si quieres evolucionar el contrato, eso es un versionado de API —tema de la guía de API design— y va después de que el modern ya reemplazó al legacy, no durante la migración.
Construir el modern tocando el legacy "solo un poquito". Qué pasa: al construir el modern, el equipo modifica el legacy para "compartir" una función, extraer una utilidad común, o "limpiar de paso". Por qué pasa: se ve código duplicado entre los dos y el instinto es no repetir. Cómo detectarlo: el legacy tiene commits nuevos durante la fase de construcción del modern —cuando debería estar congelado e intacto—. Cómo corregirlo: en esta fase el legacy no se toca. "Al lado" significa independencia: el modern puede duplicar lógica del legacy si hace falta, porque esa duplicación es temporal —el legacy se va a retirar—. Compartir código entre el viejo y el nuevo los acopla justo cuando quieres separarlos, y un cambio en la utilidad compartida puede romper el legacy que sirve el 100% del tráfico. La duplicación temporal es el precio correcto de mantener el legacy congelado y seguro.
Ejercicios
Ejercicio 1 — Mismo menú, otra cocina. En el ejemplo, el legacy usa una lista plana y el modern un store con stock, pero ambos devuelven products: ["ssd","usb-hub","webcam","hdmi"]. (a) ¿Por qué es una buena señal que las implementaciones internas sean distintas? (b) El modern tiene información que el legacy no tiene (stock). ¿Debería exponerla en el GET /products durante la fase de coexistencia? (c) ¿Cuándo sí podría empezar a usar el stock?
Ver solución
(a) Porque demuestra que el modern es una reimplementación genuina desde el contrato, no una copia del legacy. Si el modern tuviera la misma estructura interna que el legacy, probablemente sería un copiar-pegar que arrastra sus problemas. Estructuras distintas que producen la misma salida pública prueban que el modern entiende el contrato (el qué) y lo resuelve con su propia cocina (el cómo) —que es justo el punto del strangler: reemplazar, no mover—.
(b) No, durante la coexistencia no. El facade intercambia legacy y modern sin avisarle al cliente; si el modern devolviera un campo stock que el legacy no devuelve, las respuestas de las dos rutas serían distintas y un cliente que reciba unas veces stock y otras no tendría un comportamiento errático según el bucket. En la fase de coexistencia, el modern responde exactamente el mismo contrato que el legacy, aunque internamente sepa más.
(c) El stock se puede empezar a exponer después de que el modern haya reemplazado al legacy al 100% y el legacy esté retirado —cuando ya no hay dos rutas que deban coincidir—. En ese momento, agregar stock es una evolución del contrato (un cambio de API versionado, tema de la guía de API design), no una discrepancia entre rutas. Primero el modern alcanza y reemplaza al legacy con paridad de contrato; luego, ya solo, evoluciona.
Ejercicio 2 — La verificación de contrato. La función satisfies_contract del ejemplo solo checa status == 200 y que products sea una lista. (a) ¿Es suficiente esa verificación para confiar en que el modern produce lo mismo que el legacy? (b) ¿Qué tipo de problema no detectaría? (c) ¿En qué módulo se hace la verificación que sí detecta ese problema?
Ver solución
(a) No es suficiente para confiar en equivalencia total. satisfies_contract verifica la forma de la respuesta (que tenga status 200 y una lista de products), que es el mínimo para que el cliente no se rompa estructuralmente. Pero no verifica que los valores sean correctos.
(b) No detectaría que el modern devuelva la lista de productos equivocada pero con la forma correcta. Por ejemplo, si para q="usb" el legacy devuelve ["usb-hub"] y el modern devuelve ["usb-hub","usb-cable"] (porque su filtrado tiene un bug), ambas respuestas pasan satisfies_contract —las dos son listas con status 200— pero el modern está dando un resultado distinto. La verificación de forma no ve las diferencias de contenido.
(c) La verificación que sí detecta diferencias de contenido es el parallel-run del módulo 6: correr las dos implementaciones sobre las mismas requests reales, comparar sus respuestas campo por campo, y reportar las discrepancias antes de confiar en el modern. En esta fase (módulo 3) verificamos que el modern pueda coexistir (cumple la forma del contrato); en el módulo 6 verificamos que el modern sea equivalente al legacy sobre datos reales. Son dos niveles de confianza distintos, y hacen falta los dos.
Ejercicio 3 — Al lado, de verdad. Un equipo construye el modern y, para no duplicar código, extrae una función parse_query() del legacy a un módulo compartido que ambos importan. (a) ¿Qué principio de "al lado" viola esto? (b) ¿Qué riesgo concreto introduce sobre el legacy, que sirve el 100% del tráfico? (c) ¿Por qué la duplicación de esa función sería, aquí, la opción más segura?
Ver solución
(a) Viola el principio de que construir el modern no debe tocar el legacy. Extraer parse_query() a un módulo compartido modifica el legacy (ahora importa desde otro lado en vez de tener la función propia) y lo acopla al modern justo cuando el objetivo es separarlos. "Al lado" significa independiente, no "compartiendo tripas".
(b) El riesgo es que un cambio en la función compartida —hecho para el modern— rompa el legacy sin querer. El legacy sirve el 100% del tráfico; si mañana alguien ajusta parse_query() para un caso del modern y ese ajuste cambia el comportamiento del parseo, el legacy —que también la importa— empieza a fallar para todos los usuarios. Acoplaste el sistema que quieres retirar con el que estás construyendo, y le metiste una vía por la que el nuevo puede romper al viejo.
(c) Porque la duplicación aquí es temporal: el legacy se va a retirar. Duplicar parse_query() en el modern deja al legacy exactamente como estaba —congelado, seguro, sin dependencias nuevas— y le da al modern su propia copia que puede evolucionar sin afectar a nadie. La regla general "no dupliques código" cede ante la regla del strangler "no toques el legacy que estás migrando": la duplicación desaparece sola cuando el legacy se retira, y mientras tanto compra independencia. Es el precio correcto de mantener el viejo intacto.
Resumen y siguiente paso
En esta lección hiciste la segunda fase del strangler fig: construir el servicio nuevo al lado. Viste, con la cocina nueva que se levanta en el local de al lado sin cerrar la vieja, que el modern se construye en paz mientras el legacy sirve el 100% del tráfico. Y lo ejecutaste: montaste un modern_catalog con una implementación interna distinta —un store con stock, no la lista plana del legacy— pero el mismo contrato público, y verificaste, con un canary de 20%, que las dos rutas devuelven respuestas idénticas y que el modern cumple el contrato en cada request. El legacy quedó intacto; el nuevo vive a su lado; el facade elige a cuál llamar.
Antes de avanzar deberías poder: distinguir el contrato público (que debe ser idéntico) de la implementación interna (que puede ser distinta); explicar por qué el modern se construye desde el contrato y los characterization tests, no copiando el legacy; argumentar por qué no se toca el legacy al construir el modern, aunque implique duplicar código; y saber qué verifica —y qué no verifica— una verificación de contrato de forma, frente al parallel-run del módulo 6.
La lección 4 hace la tercera fase y el corazón del módulo: desviar el tráfico por porcentaje. Ahora que el facade está puesto y el modern existe al lado, vas a subir el traffic_percent del 0 al 100 —0→10→50→100— y a ver, medido sobre 1000 requests, el tráfico moverse del viejo al nuevo. Y vas a agregar la pieza que hace seguro el desvío: el fallback, la vieja ruta como red de seguridad cuando el modern lanza un error. Vas a descubrir algo revelador: aun a 100% de tráfico "en el nuevo", el caso que el modern todavía no maneja sigue cayendo al legacy —y eso te dice, con números, que aún no puedes retirarlo—.
Recursos
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — la construcción del servicio nuevo en paralelo al monolito y el rol del contrato compartido que permite intercambiarlos. La referencia central de esta fase. En inglés.
- Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. El nuevo sistema que crece al lado del viejo hasta reemplazarlo: la imagen de la higuera aplicada a construir en paralelo. En inglés.
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — por qué reimplementar desde los characterization tests (no copiando el legacy) preserva el comportamiento que importa sin arrastrar la estructura vieja. El puente con el módulo 2. En inglés.
- Chris Richardson, "Pattern: Strangler application" — microservices.io/patterns/refactoring/strangler-application.html. La fase de construir la nueva implementación que coexiste con el monolito detrás del facade. En inglés.