Módulo 7: Medir el progreso de la migración

La fitness function de la migración

Descripción

El burn-down de la lección 3 mide una cosa: cuántas llamadas al legacy quedan, bajando hacia cero. Pero tiene un punto ciego. Mide el legacy encogiendo por el frente —las requests que dejas de mandarle—, y no ve si el legacy está creciendo por atrás —código nuevo que alguien construye encima del monolito que se supone estás matando—. Puedes tener un burn-down que baja de maravilla mientras, en paralelo, un equipo agrega una feature nueva directo sobre el legacy, reintroduciendo dependencias que costó trabajo cortar. El burn-down no lo notaría; bajaría igual. Necesitas un segundo instrumento, uno que vigile específicamente que el legacy no vuelva a crecer. Ese instrumento es la fitness function de la migración.

Una fitness function, como aprendiste en architecture-decisions, es una prueba automatizada que guarda una propiedad de la arquitectura y se rompe cuando esa propiedad se degrada. Aquí la aplicamos a un objetivo muy concreto de la migración: el número de referencias del código al módulo legacy nunca debe crecer. La fitness function cuenta esas referencias y las compara contra un baseline (el conteo de la última vez que estaba sano). Si el conteo bajó o se mantuvo, PASS: el legacy encogió o al menos no creció. Si el conteo subió, FAIL: alguien agregó código nuevo que llama al legacy, y la prueba rompe el CI para que ese cambio no entre hasta arreglarse. Es una alarma automática contra el peor hábito durante una migración: seguir alimentando al monstruo que quieres matar.

Conexión con el módulo. Esta lección construye el segundo instrumento del tablero, complementario al burn-down: el burn-down mide el avance (cuánto falta), la fitness function protege ese avance (que no retroceda). La lección 5 la convierte en un trinquete —un baseline que solo baja—, haciendo el progreso irreversible, y conecta con la disciplina de no agregar features al legacy. La lección 6 usará "referencias al legacy en cero" como una de las condiciones de done. Fíjate en la frontera: la fitness function como concepto —qué es, sus tipos, cómo se integra en una arquitectura evolutiva— es de architecture-decisions (M6 de esa guía) y del libro de Ford & Parsons; aquí no la re-explicamos, la aplicamos a un fin de migración. Lo nuevo no es la idea de fitness function; es usar una para que el legacy no crezca mientras lo estrangulas.

Una analogía: el límite de peso del elevador que se niega a moverse

Imagina un elevador con un límite de peso —digamos 600 kilos—. Cuando entra demasiada gente y el peso pasa del límite, el elevador no se mueve: suena una alarma, se enciende una luz, y las puertas se quedan abiertas hasta que alguien se baja. El elevador no confía en el buen juicio de los pasajeros ("seguro cabemos"); tiene un sensor que mide el peso y una regla dura que bloquea el movimiento si se excede. Nadie discute con el elevador: o baja el peso, o el elevador no arranca.

Ese es exactamente el papel de una fitness function en una migración. El "peso" es el número de referencias al legacy. El "límite" es el baseline —el máximo permitido, fijado en el último estado sano—. Y el "elevador que no se mueve" es el CI que se pone rojo: si un cambio hace que las referencias al legacy pasen del baseline, la prueba falla y el PR no entra —el cambio no puede subir al elevador—. No depende de que alguien recuerde la regla "no agregues código al legacy" ni de que la revise a mano en cada PR; el sensor mide y la regla bloquea, automáticamente, cada vez.

Y como el elevador, la fitness function no juzga por qué subió el peso —si fue por descuido, por prisa, o porque de verdad hacía falta—; solo constata que se excedió y bloquea. Esa es su virtud: es un límite objetivo, no una opinión. La conversación deja de ser "¿está bien agregar esta dependencia al legacy?" (que se puede racionalizar) y pasa a ser "el CI está rojo, hay que bajar las referencias para que entre" (que no se puede racionalizar). El elevador no se mueve hasta que el peso baja.

Ejemplo trabajado: la fitness function que cuenta y falla

Vamos a escribir la fitness function y correrla contra dos versiones del código del catalog. Modelamos el "código" como un diccionario de archivos, cada uno con las líneas que importan del monolito legacy (mercado.monolith). La función count_legacy_refs cuenta cuántas de esas líneas hay en total, y migration_fitness falla con un assert si ese conteo supera el baseline. Corremos dos escenarios: un sprint sano donde se migraron módulos fuera del legacy (referencias a la baja → PASS), y un sprint donde alguien agregó una feature nueva sobre el legacy (referencias arriba → FAIL, con el AssertionError literal).

# La migration fitness function: cuenta las referencias del catalog al monolito
# legacy y FALLA (assert) si crecieron respecto al baseline. Atrapa el codigo
# nuevo que alguien agrego al modulo que estamos matando.

# El "codigo" del catalog: cada archivo con sus lineas que importan del monolito.
codebase_baseline = {
    "catalog/pricing.py":   ["from mercado.monolith import tax_table",
                             "from mercado.monolith import discount_rules"],
    "catalog/inventory.py": ["from mercado.monolith import stock_ledger"],
    "catalog/search.py":    ["from mercado.monolith import legacy_index"],
}

def count_legacy_refs(codebase):
    return sum(1 for lines in codebase.values()
               for line in lines if "mercado.monolith" in line)

def migration_fitness(codebase, baseline):
    """Falla si las referencias al legacy CRECIERON respecto al baseline."""
    refs = count_legacy_refs(codebase)
    assert refs <= baseline, (
        f"migration_fitness FALLO: legacy_refs={refs} > baseline={baseline} "
        f"(se agrego codigo nuevo al legacy)")
    return refs

baseline = count_legacy_refs(codebase_baseline)
print(f"Fitness function de migracion (baseline = {baseline} referencias al legacy)\n")

# --- Escenario 1: un sprint SANO. Se migraron inventory y search fuera del
#     monolito; solo quedan las 2 referencias de pricing. ---
codebase_good = {
    "catalog/pricing.py":   ["from mercado.monolith import tax_table",
                             "from mercado.monolith import discount_rules"],
    "catalog/inventory.py": [],   # migrado: ya no llama al monolito
    "catalog/search.py":    [],   # migrado: ya no llama al monolito
}
print("Escenario 1 - sprint sano (se migraron inventory y search):")
refs = migration_fitness(codebase_good, baseline)
print(f"  legacy_refs={refs}  baseline={baseline}  ->  migration_fitness PASS\n")

# --- Escenario 2: alguien agrego una feature NUEVA sobre el legacy: un archivo
#     promo.py que vuelve a importar del monolito. Las referencias SUBEN. ---
codebase_bad = dict(codebase_baseline)
codebase_bad["catalog/promo.py"] = ["from mercado.monolith import banner_config"]
print("Escenario 2 - alguien agrego catalog/promo.py que importa del monolito:")
try:
    migration_fitness(codebase_bad, baseline)
    print("  (no deberia llegar aqui)")
except AssertionError as e:
    print(f"  AssertionError: {e}")
    print("  -> el CI se pone ROJO: el PR no entra hasta quitar la referencia nueva.")

Qué esperar. Al correr el archivo, la salida es exactamente esta:

Fitness function de migracion (baseline = 4 referencias al legacy)

Escenario 1 - sprint sano (se migraron inventory y search):
  legacy_refs=2  baseline=4  ->  migration_fitness PASS

Escenario 2 - alguien agrego catalog/promo.py que importa del monolito:
  AssertionError: migration_fitness FALLO: legacy_refs=5 > baseline=4 (se agrego codigo nuevo al legacy)
  -> el CI se pone ROJO: el PR no entra hasta quitar la referencia nueva.

Lee los dos escenarios, porque son las dos únicas cosas que la fitness function distingue: el legacy encoge (o se queda igual) o el legacy crece.

El baseline es 4: el código del catalog, en su último estado sano, tenía cuatro referencias al monolito legacy —dos en pricing.py, una en inventory.py, una en search.py—. Ese número es la línea que no se debe cruzar hacia arriba.

En el escenario 1, un sprint hizo su trabajo: migró inventory.py y search.py fuera del monolito (sus listas de imports quedaron vacías), y solo sobrevivieron las dos referencias de pricing.py. count_legacy_refs devuelve 2. Como 2 <= 4, el assert pasa: migration_fitness PASS. El legacy encogió de 4 a 2 referencias —exactamente lo que debe pasar durante una migración—. Esto es lo que la fitness function quiere ver, y no hace ruido: pasa en silencio.

En el escenario 2, alguien agregó catalog/promo.py, un archivo nuevo que importa banner_config del monolito —una feature nueva construida encima del legacy—. Ahora el código tiene las 4 referencias originales más 1 nueva: count_legacy_refs devuelve 5. Como 5 > 4, el assert falla, y la salida muestra el AssertionError literal: "migration_fitness FALLO: legacy_refs=5 > baseline=4 (se agrego codigo nuevo al legacy)". En el CI, esto pone la build en rojo, y el PR de promo.py no puede mergearse hasta que se quite esa dependencia del legacy (construyendo la feature sobre el modern en su lugar). El elevador no se mueve: el peso pasó del límite.

Fíjate en la asimetría, que es el punto entero: la fitness function no exige que las referencias bajen en cada sprint (el escenario 1 habría pasado igual si pricing.py no hubiera cambiado y siguieran siendo 4); solo exige que no suban. Es un techo, no un piso. Puedes tener un sprint donde nadie migró nada (referencias se quedan en 4, PASS) —eso está bien, el burn-down se encargará de medir si avanzas—; lo que la fitness function no tolera es que alguien agregue al legacy (referencias suben a 5, FAIL). Su trabajo es una sola cosa: que el legacy no crezca. La lección 5 la volverá más estricta convirtiendo el baseline en un trinquete que baja.

Profundización: qué contar, y por qué automatizar

La fitness function del ejemplo cuenta líneas de import que mencionan mercado.monolith. Es una aproximación —directa y suficiente para el módulo—, pero vale la pena entender qué es lo que de verdad se está midiendo y cómo se haría en un sistema real.

Lo que quieres contar es cualquier forma en que el código nuevo dependa del legacy: imports del módulo legacy, llamadas a sus funciones, referencias a sus tablas, endpoints que enrutan de vuelta al monolito. En un repositorio real, esto se implementa de varias maneras según el lenguaje: un grep de los imports del paquete legacy, un análisis del grafo de dependencias (herramientas que te dicen qué módulo importa a cuál), una regla de arquitectura en un linter (ArchUnit en Java, import-linter en Python, dependency-cruiser en JavaScript) que declara "el paquete catalog no debe importar el paquete monolith", o una prueba que consulta ese grafo y falla si aparece una arista prohibida. Todas son la misma idea: contar las dependencias hacia el legacy y fallar si crecen.

        codigo nuevo (catalog)              legacy (monolith)
        ┌────────────────────┐              ┌──────────────────┐
        │ pricing.py  ───────┼──ref────────>│ tax_table        │
        │ pricing.py  ───────┼──ref────────>│ discount_rules   │   baseline = 4
        │ inventory.py       │  (migrado)   │ stock_ledger     │
        │ search.py          │  (migrado)   │ legacy_index     │
        │ promo.py    ───────┼──ref──✗─────>│ banner_config    │ <- FAIL: sube a 5
        └────────────────────┘              └──────────────────┘
                                  la fitness function cuenta las flechas
                                  y rompe el CI si aparece una nueva

La pregunta clave es por qué automatizar esto en vez de confiar en la revisión de código. La respuesta es que "no agregues código al legacy" es una regla que todos aprueban y nadie recuerda bajo presión. En una revisión de PR, cuando alguien tiene prisa por sacar una feature y la forma más rápida es colgarla del monolito ("es solo un import, ya está ahí la función"), la regla se racionaliza: "es temporal", "lo migramos después", "es un caso especial". Y cada una de esas excepciones vuelve a hacer crecer el legacy que tanto costó reducir. Una fitness function no se cansa, no tiene prisa, y no acepta racionalizaciones: cuenta y bloquea. Convierte una buena intención (que la gente olvida) en una restricción del sistema (que la gente no puede saltarse). Es la diferencia entre "deberíamos" y "no se puede".

Hay un matiz honesto sobre los falsos positivos y negativos. Contar líneas de import es una heurística: puede haber referencias al legacy que no son imports (una llamada HTTP al monolito, una query a una tabla compartida) que este conteo no ve —falsos negativos—; y puede haber imports del paquete legacy que son legítimos y transitorios (el anti-corruption layer tiene que hablar con el legacy por diseño) que el conteo marca —falsos positivos—. En la práctica se afina: se excluye el ACL de la cuenta (es la frontera autorizada), y se complementa el conteo de imports con otras señales (llamadas, queries). Lo importante es la idea: una medida objetiva de "cuánto depende el nuevo del viejo", vigilada automáticamente, que no puede crecer. La precisión de la medida se ajusta; su papel de guardián no.

Errores comunes

Confiar en la revisión de código para impedir que el legacy crezca. Qué pasa: el equipo acuerda "no agregamos código al legacy durante la migración" pero deja el cumplimiento a la revisión manual de cada PR. Por qué pasa: escribir una fitness function es trabajo extra, y "lo revisamos en el PR" parece suficiente. Cómo detectarlo: aparecen, sprint tras sprint, imports nuevos al módulo legacy que pasaron la revisión "porque eran urgentes" o "temporales"; el conteo de referencias al legacy sube en vez de bajar. Cómo corregirlo: automatiza la regla con una fitness function que rompe el CI. La revisión manual falla justo cuando más se necesita —bajo presión, con prisa, cuando la excepción se siente justificada—; una prueba automática no cede a la presión. El costo de escribir la fitness function (unas líneas que cuentan imports) es minúsculo frente al costo de dejar que el legacy vuelva a crecer una dependencia a la vez.

Poner la fitness function pero no conectarla al CI. Qué pasa: el equipo escribe la fitness function y la corre a mano de vez en cuando, o la deja como un script que nadie ejecuta. Por qué pasa: integrarla al pipeline es un paso más, y "ya está escrita" se siente como suficiente. Cómo detectarlo: la fitness function existe en el repositorio pero las builds nunca fallan por ella; las referencias al legacy crecen sin que nada se ponga rojo. Cómo corregirlo: una fitness function solo sirve si bloquea. Tiene que correr en cada PR y fallar la build cuando el conteo sube, igual que fallan los tests unitarios. Una fitness function que no está conectada al CI es como el sensor de peso del elevador desconectado de los frenos: mide, pero el elevador se mueve igual. Su valor está en el bloqueo automático, no en la existencia del código.

Contar mal: incluir el anti-corruption layer o ignorar las dependencias que no son imports. Qué pasa: la fitness function marca FAIL por el ACL (que debe hablar con el legacy) o, al revés, da PASS mientras el código llama al monolito por HTTP sin importarlo. Por qué pasa: contar imports es la heurística más simple, pero el ACL es una excepción legítima y no todas las dependencias son imports. Cómo detectarlo: la fitness function falla por código que es correcto (el ACL), o pasa mientras el burn-down no baja (hay dependencias reales que no cuenta). Cómo corregirlo: excluye el ACL de la cuenta (es la frontera autorizada entre viejo y nuevo, por diseño va a referenciar el legacy hasta el final) y complementa el conteo de imports con otras señales de dependencia (llamadas HTTP al monolito, queries a tablas compartidas). La medida es una heurística que se afina; lo que no cambia es su papel: contar la dependencia del nuevo hacia el viejo y fallar si crece. Una fitness function mal calibrada o hace ruido (falsos FAIL que el equipo aprende a ignorar) o da falsa tranquilidad (PASS mientras el legacy crece por otro lado).

Ejercicios

Ejercicio 1 — PASS o FAIL. Con el baseline en 4, di si cada estado del código da PASS o FAIL y por qué: (a) 4 referencias (nadie migró ni agregó nada este sprint); (b) 2 referencias (se migraron dos módulos); (c) 6 referencias (se agregaron dos features sobre el legacy); (d) 0 referencias (se migró todo).

Ver solución
  • (a) 4 referencias → PASS. 4 <= 4: el conteo no subió respecto al baseline. La fitness function es un techo, no un piso: no exige que bajes cada sprint, solo que no crezcas. Un sprint sin migrar nada pasa (aunque el burn-down no avanzará —esa es otra métrica—).
  • (b) 2 referencias → PASS. 2 <= 4: el legacy encogió. Es el caso ideal, y pasa en silencio.
  • (c) 6 referencias → FAIL. 6 > 4: alguien agregó dos dependencias nuevas al legacy. La prueba rompe el CI; esos cambios no entran hasta quitar las referencias nuevas.
  • (d) 0 referencias → PASS. 0 <= 4: el código ya no depende del legacy en absoluto. Este es el estado que, combinado con legacy_calls == 0, permite declarar done (lección 6).

La regla es siempre la misma: PASS si el conteo es menor o igual al baseline (el legacy no creció), FAIL si es mayor (el legacy creció).

Ejercicio 2 — Por qué automatizar. El texto dice que "no agregues código al legacy" es una regla que "todos aprueban y nadie recuerda bajo presión". (a) Describe una situación realista donde un desarrollador con prisa agregaría un import al legacy con buena justificación. (b) ¿Por qué la revisión de código no siempre lo atrapa? (c) ¿Qué hace la fitness function distinto que la revisión?

Ver solución

(a) Una situación típica: hay que sacar una feature de promociones para el Buen Fin en dos días. La función que calcula los descuentos ya existe... en el monolito legacy (discount_rules). Construir la versión nueva en el servicio modern tomaría una semana; importar la del legacy toma cinco minutos. El desarrollador, con la fecha encima, importa discount_rules del monolito "por ahora, lo migramos después de la campaña". La justificación es real: la feature se necesita, el tiempo no alcanza, la función ya existe.

(b) Porque la revisión de código la hace una persona, bajo las mismas presiones: el revisor también sabe que la campaña es en dos días, también ve que la función ya existe en el legacy, y la justificación "es temporal, lo migramos después" suena razonable en el momento. La regla se racionaliza caso por caso, y cada excepción individual parece defendible —el problema es que la suma de excepciones defendibles hace crecer el legacy—. La revisión manual es más débil justo cuando más se necesita: bajo presión.

(c) La fitness function no razona ni cede: cuenta las referencias, ve que subieron de 4 a 5, y pone el CI en rojo, sin importar la justificación. Convierte la conversación de "¿está bien esta excepción?" (que se puede ganar con un buen argumento) a "el CI está rojo, hay que bajar las referencias para mergear" (que no se puede ganar con argumentos, solo quitando la dependencia). No impide que la feature se haga —impide que se haga sobre el legacy—, forzando a construirla sobre el modern o a migrar discount_rules primero. Es una restricción del sistema, no una intención que se recuerda.

Ejercicio 3 — Diseña la medición. Te toca implementar la fitness function en un repositorio real de Python donde el catalog migra fuera de mercado.monolith. (a) ¿Qué contarías y cómo? (b) ¿Qué excluirías de la cuenta y por qué? (c) ¿Dónde la conectarías para que de verdad bloquee?

Ver solución

(a) Contaría las dependencias del paquete catalog hacia el paquete mercado.monolith: los imports (from mercado.monolith import ... o import mercado.monolith), que se detectan con un grep/análisis estático o con una herramienta de arquitectura como import-linter declarando la regla "catalog no debe importar monolith". Complementaría con las dependencias que no son imports si las hubiera: llamadas HTTP al endpoint del monolito y queries a las tablas todavía compartidas. El número total de dependencias es lo que se compara contra el baseline.

(b) Excluiría el anti-corruption layer de la cuenta. El ACL es la frontera autorizada entre el modelo viejo y el nuevo (lección de M5); por diseño tiene que referenciar el legacy hasta que la migración termine, así que contarlo daría falsos FAIL. Lo aíslo en su propio módulo (por ejemplo catalog/acl/) y lo excluyo de la regla, para que la fitness function vigile solo las dependencias no autorizadas —las que no deberían existir—.

(c) La conectaría al pipeline de CI, como un paso que corre en cada PR junto con los tests unitarios, y que falla la build si el conteo supera el baseline. El baseline se guarda en el repositorio (un archivo versionado con el número actual permitido), de modo que bajarlo sea un commit explícito (lección 5, el trinquete). Sin la conexión al CI que bloquea el merge, la fitness function solo mide; conectada, impide de verdad que el legacy crezca —el sensor de peso conectado a los frenos del elevador—.

Resumen y siguiente paso

En esta lección construiste el segundo instrumento del tablero: la fitness function de la migración, que tapa el punto ciego del burn-down —vigilar que el legacy no crezca por atrás mientras lo reduces por el frente—. Viste, con el elevador que se niega a moverse cuando el peso pasa del límite, que la fitness function es un límite objetivo y automático: cuenta las referencias al legacy, las compara contra un baseline, y bloquea (rompe el CI) si crecieron. Y la ejecutaste: PASS cuando el código encogió de 4 a 2 referencias (se migraron módulos), y FAIL —con el AssertionError literal— cuando alguien agregó promo.py importando del monolito y las referencias subieron a 5. Aprendiste que es un techo, no un piso (no exige bajar cada sprint, solo no subir), por qué automatizarla vence a la revisión manual (no cede bajo presión), y cómo calibrar qué contar (excluir el ACL, complementar los imports con otras dependencias).

Antes de avanzar deberías poder: escribir una fitness function que cuenta referencias al legacy y falla si superan un baseline; explicar por qué es un techo y no un piso; argumentar por qué automatizarla en el CI vence a confiar en la revisión de código; y calibrar qué contar y qué excluir.

La lección 5 hace la fitness function más poderosa con una idea simple y profunda: convertir el baseline en un trinquete —un número que solo puede bajar, nunca subir—. Cada sprint que reduce las referencias al legacy aprieta el baseline al nuevo mínimo, fijando el progreso para que no pueda retroceder. Con eso, la migración se vuelve irreversible: cada avance queda cerrado con llave, y el legacy no puede volver a crecer ni un import por encima de su mejor marca. Vas a ver el trinquete apretar el baseline sprint a sprint (4→3→2→1→0) y bloquear un intento de agregar código al legacy cuando el baseline ya llegó a cero —y verás cómo esto sostiene la disciplina de no construir features nuevas sobre el monolito que estás matando—.

Recursos

  • Neal Ford, Rebecca Parsons y Patrick Kua, Building Evolutionary Architectures (O'Reilly, 2ª ed., 2022) — el libro que define las fitness functions como pruebas que guardan propiedades arquitectónicas y se integran al pipeline. La fuente del concepto que aquí aplicamos a la migración. En inglés.
  • Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. Fowler subraya que el legacy debe reducirse hasta desaparecer; una fitness function que impide que crezca es lo que protege esa reducción de retroceder. En inglés.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — sobre la disciplina de no seguir agregando funcionalidad al monolito que se está descomponiendo; la fitness function de esta lección es cómo se hace cumplir esa disciplina de forma automática. En inglés.
  • Martin Fowler, martinfowler.com — el bliki con las entradas sobre arquitectura evolutiva y fitness functions que enmarcan el concepto general del que aquí tomamos una instancia concreta. En inglés.