Cambiar un sistema en marcha (Strangler Fig, Expand–Contract)
En resumen: Strangler Fig reemplaza un sistema antiguo paso a paso, con un router delante que mueve el tráfico al nuevo, y Expand–Contract cambia los datos con doble escritura, backfill y conciliación.
Problema
El gran cambio de golpe
El módulo de pedidos es de 2016. Calcula el total: por debajo de 50 € el cliente paga 5 € de envío, desde 50 € el envío es gratis. El equipo escribe un módulo nuevo, con código nuevo y una tabla nueva. El plan: el sábado por la noche se detiene el sitio, se apaga lo antiguo y se enciende lo nuevo.
Piensa qué pasa si lo nuevo tiene un error que las pruebas no encontraron. El lunes, clientes de tres países ven totales equivocados. Volver a lo antiguo ya es difícil: desde el sábado lo nuevo escribe los pedidos en la tabla nueva, y lo antiguo no los conoce. Tienes que elegir entre malo y peor: arreglar en vivo, o volver atrás y perder pedidos.
El problema no es el código nuevo. El problema es que el cambio es un solo paso enorme sin vuelta atrás.
La idea
Strangler Fig: lo nuevo crece alrededor de lo antiguo
El nombre es de una planta: la higuera estranguladora brota sobre otro árbol, baja raíces a su alrededor y con los años lo reemplaza. El árbol desaparece, y la forma se queda.
Así se reemplaza también un sistema. Delante de lo antiguo se pone un intermediario (aquí el Router), con el que habla el resto de la tienda. Al principio manda todo a lo antiguo. Luego lo nuevo toma una parte pequeña, después una mayor, al final todo. Lo antiguo se apaga al final, cuando ya no recibe nada. Eso es un Strangler Fig.
Cada paso es pequeño y tiene vuelta atrás: si lo nuevo se porta mal, se baja el porcentaje. El Router es el mismo tipo de lugar que una Anti-Corruption Layer: una frontera detrás de la cual se puede cambiar lo que haya sin que los demás se enteren.
Míralo
El Router en código
Los dos módulos responden a la misma pregunta: cuánto cuesta el pedido.
record Order(int Id, int Subtotal);
interface IOrders{ int Total(Order order);}// el módulo antiguo (2016)class Legacy : IOrders{ public int Total(Order order) => order.Subtotal >= 50 ? order.Subtotal : order.Subtotal + 5;}static class Shop{ public const int FreeFrom = 50, Delivery = 5;}class Fresh : IOrders{ public int Total(Order order) { var free = order.Subtotal >= Shop.FreeFrom; return order.Subtotal + (free ? 0 : Shop.Delivery); }}Legacy y Fresh implementan una interfaz IOrders, por eso la tienda no sabe cuál de los dos le responde. El Router también es un módulo así:
class Router(IOrders legacy, IOrders fresh, int percent) : IOrders{ public int ByLegacy { get; private set; } public int ByFresh { get; private set; } public List<int> Mismatches { get; } = [];
public int Total(Order order) { // el mismo pedido siempre va al mismo sitio if (order.Id % 10 * 10 < percent) { ByFresh++; return fresh.Total(order); } ByLegacy++; var answer = legacy.Total(order); // en sombra: lo nuevo calcula, pero no responde if (fresh.Total(order) != answer) Mismatches.Add(order.Id); return answer; }}El pedido va a lo nuevo si su última cifra por 10 está por debajo del porcentaje: con 10 % solo la cifra 0, con 50 % las cifras de 0 a 4. Cuando responde lo antiguo, lo nuevo calcula el mismo pedido en sombra: su resultado solo se compara, el cliente no lo ve.
Míralo
En sombra
La primera versión de lo nuevo:
// la primera versión de lo nuevo: se olvidó el envío gratisclass FreshDraft : IOrders{ public int Total(Order order) => order.Subtotal + Shop.Delivery;}Diez pedidos, con 0 %:
Order[] orders = [ new(1001, 30), new(1002, 64), new(1003, 18), new(1004, 45), new(1005, 120), new(1006, 9), new(1007, 50), new(1008, 22), new(1009, 75), new(1010, 40),];var router = new Router(new Legacy(), new FreshDraft(), 0);foreach (var o in orders) router.Total(o);Console.WriteLine($"0%: lo antiguo {router.ByLegacy}, lo nuevo {router.ByFresh}");Console.WriteLine($"diferencias: [{string.Join(", ", router.Mismatches)}]");0%: lo antiguo 10, lo nuevo 0diferencias: [1002, 1005, 1007, 1009]Los 10 los responde lo antiguo, y todos los totales son correctos. Pero la lista dice que lo nuevo difiere en 4 pedidos, todos desde 50 €: olvidó el envío gratis. El error se encuentra en los pedidos reales, sin que un solo cliente lo vea. La corrección es el Fresh de arriba.
class Router(IOrders legacy, IOrders fresh, int percent) : IOrders{ public int ByLegacy { get; private set; } public int ByFresh { get; private set; } public List<int> Mismatches { get; } = [];
public int Total(Order order) { // el mismo pedido siempre va al mismo sitio if (order.Id % 10 * 10 < percent) { ByFresh++; return fresh.Total(order); } ByLegacy++; var answer = legacy.Total(order); // en sombra: lo nuevo calcula, pero no responde if (fresh.Total(order) != answer) Mismatches.Add(order.Id); return answer; }}Paso a paso, en palabras
- Router está delante del módulo antiguo de pedidos (2016) y del nuevo. La tienda habla solo con Router; él decide quién responde.
- 0 %: lo antiguo responde los 10. Lo nuevo calcula en sombra y difiere en 4: 1002, 1005, 1007, 1009. Todos pasan de 50 €: olvidó el envío gratis.
- Corregido y otra vez 0 %: 0 diferencias. Los clientes no han visto ni un solo total equivocado.
- 10 %: el pedido cuya última cifra es 0 (1010) va a lo nuevo. El mismo pedido siempre va por el mismo camino, así que un cliente no salta entre los dos.
- 50 %: 5 de 10 a lo nuevo. Si se porta mal, la vuelta atrás es un solo número: otra vez 10, y los mismos pedidos vuelven a lo antiguo.
- 100 %: lo nuevo responde todo. Lo antiguo no recibe tráfico, pero sigue funcionando y sigue teniendo sus datos.
- Apagar lo antiguo es el punto sin retorno. Va al final, tras semanas con 0 diferencias y con sus datos archivados.
Míralo
Paso a paso
Con lo nuevo corregido, por todo el plan:
Order[] orders = [ new(1001, 30), new(1002, 64), new(1003, 18), new(1004, 45), new(1005, 120), new(1006, 9), new(1007, 50), new(1008, 22), new(1009, 75), new(1010, 40),];var legacy = new Legacy();var fresh = new Fresh();foreach (var percent in new[] { 0, 10, 50, 100 }){ var router = new Router(legacy, fresh, percent); foreach (var o in orders) router.Total(o); Console.WriteLine($"{percent,3}%: lo nuevo {router.ByFresh}, lo antiguo {router.ByLegacy}, diferencias {router.Mismatches.Count}");} 0%: lo nuevo 0, lo antiguo 10, diferencias 0 10%: lo nuevo 1, lo antiguo 9, diferencias 0 50%: lo nuevo 5, lo antiguo 5, diferencias 0100%: lo nuevo 10, lo antiguo 0, diferencias 0Las diferencias siguen en 0, también con 10 % y con 50 %. En una tienda real pasan días entre dos pasos: cada paso se vigila hasta que los errores, el tiempo de respuesta y las quejas muestran que hay calma.
La elección es por el número del pedido, no al azar. Así un pedido va siempre al mismo sitio, y si el porcentaje baja de 50 a 10, los mismos pedidos vuelven a lo antiguo. En un proyecto real la elección es por algo que mantiene al cliente en un solo lugar: el número del cliente o del carrito.
El porcentaje es un ajuste (una bandera), no código: en un proyecto real cambia sin un lanzamiento nuevo, por eso la vuelta atrás también toma segundos.
Pruébalo
Predice la salida
int percent = 30;foreach (var id in new[] { 1007, 1012, 1020 }) Console.WriteLine($"{id} {(id % 10 * 10 < percent ? "lo nuevo" : "lo antiguo")}");1007 % 10 es 7, por 10 es 70, y 70 no está por debajo de 30: lo antiguo. 1012 da 20, por debajo de 30: lo nuevo. 1020 da 0, también lo nuevo. Con 30 % lo nuevo toma las cifras 0, 1 y 2.
Pruébalo
Encuentra el error
Un colega escribe un Router más corto: «10 % es 10 %, que sea aleatorio». Los clientes empiezan a escribir que el total del carrito cambia con cada recarga. ¿En qué línea está el error?
Elige la línea con el error.
La elección es aleatoria en cada llamada: el mismo pedido va una vez a lo antiguo y otra a lo nuevo. Mientras los dos no calculen igual, el cliente ve dos totales. Y bajar el porcentaje no devuelve los mismos pedidos. La elección debe depender de algo constante, como id % 10 * 10 < percent en el Router de arriba. Falta además la sombra: sin ella nadie sabe si los dos calculan igual.
La idea
Los datos: Expand–Contract
El código se reemplaza con el Router. Los datos son más difíciles: lo nuevo quiere una tabla nueva. En la antigua el total es texto con coma decimal ("64,00"), en la nueva es un número en centavos (6400). Y mientras el tráfico se mueve, las dos tablas tienen que estar correctas.
En la lección Migraciones del esquema el cambio de una columna iba por pasos: ampliar, pasar, reducir. Aquí es lo mismo, pero para una tabla entera y con una comprobación:
- Expand: la tabla nueva se añade junto a la antigua. No se quita nada.
- Doble escritura (dual write): cada pedido nuevo se escribe en las dos.
- Backfill: el historial (los pedidos de antes de la doble escritura) se copia a la nueva.
- Conciliación (reconcile): las dos se comparan hasta que las diferencias sean 0.
- La lectura pasa a la nueva, en la antigua se deja de escribir.
- Contract: se quita la tabla antigua.
Eso es Expand–Contract. Hasta el paso 6 cada paso se puede deshacer: la tabla antigua sigue ahí y está correcta.
Míralo
Doble escritura, backfill y conciliación
Las dos tablas aquí son SortedDictionary (ordenados por número), no una base de datos, para que se vea la lógica:
class Migration{ // la tabla antigua (2016): el total es texto con coma decimal public SortedDictionary<int, string> OldRows { get; } = []; // la tabla nueva: el total está en centavos public SortedDictionary<int, long> NewRows { get; } = [];
static long Cents(string text) { var p = text.Split(','); return long.Parse(p[0]) * 100 + long.Parse(p[1]); }
// doble escritura: cada pedido nuevo va a las dos public void Place(int id, string total) { OldRows[id] = total; NewRows[id] = Cents(total); }
// backfill: copia el historial sin escribir encima de las filas nuevas public void Backfill() { foreach (var (id, total) in OldRows) { try { NewRows.TryAdd(id, Cents(total)); } catch (FormatException) { Console.WriteLine($"✗ backfill {id}: \"{total}\""); } } }
// conciliación: cada fila antigua debe estar también en la tabla nueva public List<int> Reconcile() => OldRows.Keys.Where(id => !NewRows.ContainsKey(id)).ToList();}var m = new Migration();// el historial: pedidos de antes de la migración, solo en la tabla antiguam.OldRows[1001] = "35,00";m.OldRows[1002] = "64,00";m.OldRows[1003] = "23,00";m.OldRows[1004] = "5O,00";// desde hoy: doble escrituram.Place(1011, "42,50");m.Place(1012, "99,90");m.Backfill();var missing = m.Reconcile();Console.WriteLine($"antiguo: {m.OldRows.Count}, nuevo: {m.NewRows.Count}, faltan en lo nuevo: [{string.Join(", ", missing)}]");Console.WriteLine(missing.Count == 0 ? "la lectura puede pasar a la tabla nueva" : "todavía no: primero se arregla la diferencia");✗ backfill 1004: "5O,00"antiguo: 6, nuevo: 5, faltan en lo nuevo: [1004]todavía no: primero se arregla la diferenciaEl pedido 1004 de 2016 tiene en el total la letra O en lugar de un cero. El módulo antiguo vive con eso, pero convertirlo en centavos no se puede. Backfill no lo esconde: lo dice en voz alta y sigue. La conciliación lo atrapa y dice «todavía no». Alguien arreglará la fila en la tabla antigua, backfill pasará otra vez, y la conciliación mostrará 0.
Backfill se puede ejecutar tantas veces como haga falta: copia solo lo que falta. La doble escritura se puede hacer como dos escrituras en una transacción (Bases de datos) o a través de un outbox, para que ninguna escritura quede solo en una de las tablas.
Pruébalo
Completa
Backfill no debe escribir encima de una fila que ya está en la tabla nueva. ¿Cómo?
foreach (var (id, total) in OldRows)
NewRows.(id, Cents(total));TryAdd escribe solo si la clave falta y devuelve false si ya está. La fila de la tabla nueva vino de la doble escritura, es más nueva que la copia del historial. Si backfill escribe encima, deshace un cambio hecho hoy.
Pruébalo
Ordena los pasos
StopOldWrites(); // la antigua solo de lecturaReconcile(); // comparar hasta que las diferencias sean 0Backfill(); // el historial a la nuevaExpand(); // la tabla nueva junto a la antiguaContract(); // quitar la antiguaDualWrite(); // cada escritura en las dosSwitchReads(); // la lectura desde la nueva
La idea
El punto sin retorno
Cada paso hasta aquí tiene vuelta atrás: el porcentaje se baja, la lectura vuelve a la tabla antigua, la doble escritura sigue. Dos pasos no la tienen: apagar el módulo antiguo y quitar la tabla antigua. Después de ellos, volver es restaurar desde un archivo.
Por eso van al final y se deciden a propósito. Antes de ellos: semanas con 0 diferencias en la conciliación, ningún código que lea o escriba lo antiguo, y un archivo de los datos. Conviene dejar el plan, el criterio de cada paso y cómo se deshace anotados en un ADR antes de empezar: a mitad de una migración todos quieren terminarla más rápido.
Una migración detenida a medias es lo peor: dos sistemas, dos conjuntos de datos y una doble escritura que se mantienen para siempre. El plan tiene que llegar hasta contract.
La idea
Cuándo perjudica
- Un sistema pequeño con pocos datos. Si parar media hora por la noche es aceptable y los datos se mueven en minutos, Router, sombra y conciliación son semanas de trabajo sin beneficio. Una sola migración con un plan para volver atrás (un archivo antes) basta.
- Cuando no hay dónde poner un Router. Si lo antiguo se llama directamente desde veinte lugares, primero hace falta una frontera delante (una Facade, un hub), y solo entonces se puede estrangular.
- La sombra no es gratis. Lo nuevo calcula todo una vez más. Si envía correos o cobra dinero, eso no debe ocurrir en sombra: la sombra tiene que estar libre de efectos secundarios.
- Un «casi» sin fin. Si el 100 % nunca llega (el último 5 % es el más difícil), quedan dos sistemas que mantener. Sin plazo y sin criterio, un Strangler Fig se convierte en dos monolitos en vez de uno.
Más precisamente, para curiosos
El nombre viene de un artículo de Martin Fowler de 2004 (Strangler Fig Application). Expand–Contract también se describe como parallel change.
El Router en un proyecto real casi siempre está en un gateway o balanceador de carga delante de los dos sistemas: una regla por ruta (/orders a lo nuevo) o por porcentaje. Se despliegan y se vigilan como infraestructura, y eso está en el curso de DevOps ZeroToOps. El porcentaje suele ser un feature flag: un ajuste que cambia sin un lanzamiento nuevo (LaunchDarkly, Unleash, OpenFeature).
Las migraciones del esquema se escriben con herramientas como Flyway y Liquibase (Java), EF Core Migrations (.NET), Alembic (Python), Prisma o Knex (JavaScript). Ejecutan los pasos en orden, pero no deciden cuándo: eso es el plan de Expand–Contract.
En lugar de una doble escritura en el código a veces se usa CDC (change data capture): una herramienta como Debezium lee el registro de la base antigua y lleva cada cambio a la nueva. El código no se toca, pero se suma un sistema más que vigilar.
La comparación en sombra a gran escala se hace de forma asíncrona: las respuestas se registran y se comparan después, para que lo nuevo no frene al cliente.
Ejercicio
Conciliación por valor
Resuélvela en tu editor.
La conciliación de arriba solo comprueba si la fila está. Escribe Reconcile, que compare dos tablas con totales en centavos y devuelva una línea por cada diferencia: una fila solo en la antigua, una fila solo en la nueva, o un total distinto. Los números van en orden.
La tabla antigua: 1004 → 5000, 1011 → 4250, 1012 → 9900. La nueva: 1011 → 4250, 1012 → 9990, 1013 → 1800.
1004: solo en lo antiguo1012: antiguo 9900, nuevo 99901013: solo en lo nuevodiferencias: 3Más tareas
- Por cliente. Cambia el Router para que elija por el número del cliente en lugar del número del pedido. Añade un campo para el cliente y ejecuta dos pedidos de un mismo cliente con 50 %.
- Volver atrás. Ejecuta el plan 0, 10, 50, 10, 100 y comprueba que en el segundo 10 % están los mismos pedidos que en el primero.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Diseño de API: contrato, versiones, solicitudes repetidas · ADR: registros de decisiones de arquitectura · Una transacción entre servicios (Saga) · Integrar sistemas antiguos y ajenos
- Continúa en: El panorama de la empresa y sus capacidades · Tu primera tarea de arquitectura
- Ejercicios sobre el tema: El arquitecto en acción