Ir al contenido
wannacode

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.

@dataclass(frozen=True)
class Order:
id: int
subtotal: int
# el módulo antiguo (2016)
class Legacy:
def total(self, order):
s = order.subtotal
return s if s >= 50 else s + 5
FREE_FROM = 50
DELIVERY = 5
class Fresh:
def total(self, order):
free = order.subtotal >= FREE_FROM
return order.subtotal + (0 if free else DELIVERY)

Python no pide una interfaz: basta con que Legacy y Fresh tengan un método total. El Router también es un módulo así:

class Router:
def __init__(self, legacy, fresh, percent):
self.legacy = legacy
self.fresh = fresh
self.percent = percent
self.by_legacy = 0
self.by_fresh = 0
self.mismatches = []
def total(self, order):
# el mismo pedido siempre va al mismo sitio
if order.id % 10 * 10 < self.percent:
self.by_fresh += 1
return self.fresh.total(order)
self.by_legacy += 1
answer = self.legacy.total(order)
# en sombra: lo nuevo calcula, pero no responde
if self.fresh.total(order) != answer:
self.mismatches.append(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 gratis
class FreshDraft:
def total(self, order):
return order.subtotal + DELIVERY

Diez pedidos, con 0 %:

orders = [
Order(1001, 30), Order(1002, 64), Order(1003, 18), Order(1004, 45),
Order(1005, 120), Order(1006, 9), Order(1007, 50), Order(1008, 22),
Order(1009, 75), Order(1010, 40),
]
router = Router(Legacy(), FreshDraft(), 0)
for o in orders:
router.total(o)
print(f"0%: lo antiguo {router.by_legacy}, lo nuevo {router.by_fresh}")
print(f"diferencias: {router.mismatches}")
Salida
0%: lo antiguo 10, lo nuevo 0
diferencias: [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:
def __init__(self, legacy, fresh, percent):
self.legacy = legacy
self.fresh = fresh
self.percent = percent
self.by_legacy = 0
self.by_fresh = 0
self.mismatches = []
def total(self, order):
# el mismo pedido siempre va al mismo sitio
if order.id % 10 * 10 < self.percent:
self.by_fresh += 1
return self.fresh.total(order)
self.by_legacy += 1
answer = self.legacy.total(order)
# en sombra: lo nuevo calcula, pero no responde
if self.fresh.total(order) != answer:
self.mismatches.append(order.id)
return answer
Paso a paso, en palabras
  1. Router está delante del módulo antiguo de pedidos (2016) y del nuevo. La tienda habla solo con Router; él decide quién responde.
  2. 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.
  3. Corregido y otra vez 0 %: 0 diferencias. Los clientes no han visto ni un solo total equivocado.
  4. 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.
  5. 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.
  6. 100 %: lo nuevo responde todo. Lo antiguo no recibe tráfico, pero sigue funcionando y sigue teniendo sus datos.
  7. 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:

orders = [
Order(1001, 30), Order(1002, 64), Order(1003, 18), Order(1004, 45),
Order(1005, 120), Order(1006, 9), Order(1007, 50), Order(1008, 22),
Order(1009, 75), Order(1010, 40),
]
legacy = Legacy()
fresh = Fresh()
for percent in [0, 10, 50, 100]:
router = Router(legacy, fresh, percent)
for o in orders:
router.total(o)
print(f"{percent:3}%: lo nuevo {router.by_fresh}, lo antiguo {router.by_legacy}, diferencias {len(router.mismatches)}")
Salida
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 0
100%: lo nuevo 10, lo antiguo 0, diferencias 0

Las 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

percent = 30
for id in [1007, 1012, 1020]:
print(id, "lo nuevo" if id % 10 * 10 < percent else "lo antiguo")
Respuesta

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 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:

  1. Expand: la tabla nueva se añade junto a la antigua. No se quita nada.
  2. Doble escritura (dual write): cada pedido nuevo se escribe en las dos.
  3. Backfill: el historial (los pedidos de antes de la doble escritura) se copia a la nueva.
  4. Conciliación (reconcile): las dos se comparan hasta que las diferencias sean 0.
  5. La lectura pasa a la nueva, en la antigua se deja de escribir.
  6. 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 diccionarios, no una base de datos, para que se vea la lógica:

def cents(text):
euros, rest = text.split(",")
return int(euros) * 100 + int(rest)
class Migration:
def __init__(self):
# la tabla antigua (2016): el total es texto con coma decimal
self.old_rows = {}
# la tabla nueva: el total está en centavos
self.new_rows = {}
# doble escritura: cada pedido nuevo va a las dos
def place(self, id, total):
self.old_rows[id] = total
self.new_rows[id] = cents(total)
# backfill: copia el historial sin escribir encima de las filas nuevas
def backfill(self):
for id, total in self.old_rows.items():
try:
self.new_rows.setdefault(id, cents(total))
except ValueError:
print(f'✗ backfill {id}: "{total}"')
# conciliación: cada fila antigua debe estar también en la tabla nueva
def reconcile(self):
return [id for id in self.old_rows if id not in self.new_rows]
m = Migration()
# el historial: pedidos de antes de la migración, solo en la tabla antigua
m.old_rows[1001] = "35,00"
m.old_rows[1002] = "64,00"
m.old_rows[1003] = "23,00"
m.old_rows[1004] = "5O,00"
# desde hoy: doble escritura
m.place(1011, "42,50")
m.place(1012, "99,90")
m.backfill()
missing = m.reconcile()
print(f"antiguo: {len(m.old_rows)}, nuevo: {len(m.new_rows)}, faltan en lo nuevo: {missing}")
print("la lectura puede pasar a la tabla nueva" if not missing else "todavía no: primero se arregla la diferencia")
Salida
✗ backfill 1004: "5O,00"
antiguo: 6, nuevo: 5, faltan en lo nuevo: [1004]
todavía no: primero se arregla la diferencia

El 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?

for id, total in self.old_rows.items():
    self.new_rows.(id, cents(total))

Pruébalo

Ordena los pasos

  1. stop_old_writes() # la antigua solo de lectura
  2. reconcile() # comparar hasta que las diferencias sean 0
  3. backfill() # el historial a la nueva
  4. expand() # la tabla nueva junto a la antigua
  5. contract() # quitar la antigua
  6. dual_write() # cada escritura en las dos
  7. switch_reads() # 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.

Salida
1004: solo en lo antiguo
1012: antiguo 9900, nuevo 9990
1013: solo en lo nuevo
diferencias: 3

Má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

1. ¿Qué hace un Strangler Fig?
2. ¿Por qué lo nuevo corre primero en sombra?
3. El Router manda un 10 % aleatorio de las peticiones a lo nuevo. ¿Cuál es el problema?
4. ¿Por qué backfill no escribe encima de las filas que ya están en la tabla nueva?
5. ¿Cuándo se quita la tabla antigua (contract)?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·