Módulo 4: Branch by abstraction

Borrar la implementación vieja

Descripción

Llegaste al último paso, y es el que casi nadie hace. Insertaste la abstracción (lección 2), construiste ModernShipping detrás de ella (lección 3), montaste el flag (lección 4) y validaste con parallel-run que la nueva coincide con la vieja (lección 5). El flag está en 100%: todo el tráfico usa ModernShipping, el parallel-run lleva días en 0 discrepancias, y LegacyShipping no recibe una sola llamada. Y aquí es donde la mayoría de las migraciones se detienen —"ya funciona lo nuevo, ¿para qué tocar más?"— y dejan el legacy en el código "por si acaso". Ese "por si acaso" es la trampa. El paso que paga la migración es el que sigue: borrar la implementación vieja.

Borrar el legacy no es un detalle de limpieza opcional. Es lo que convierte una migración en algo terminado en vez de un estado permanente de dos-sistemas-a-la-vez. Mientras LegacyShipping y ModernShipping coexistan en el código, pagas un impuesto continuo: cada cambio futuro al cálculo de envío hay que hacerlo en las dos implementaciones, y cada vez que alguien olvida una, aparece el drift —las dos divergen y el comportamiento depende de en cuál caíste—. Borrar el legacy elimina ese impuesto de raíz: vuelve a haber una sola fuente de verdad. Y con el legacy fuera, el flag ya no elige entre dos —solo queda uno—, así que el flag también se borra, y la abstracción se queda con una sola implementación (y quizás se colapsa, si ya no gana su lugar).

Esta lección lo ejecuta en dos partes. Primero, un inventario de piezas móviles antes y después del borrado: cuántas cosas hay que mantener con las dos implementaciones y el flag, contra cuántas quedan al borrar. Segundo, una medición del costo de dejar las dos "por si acaso": llega un requisito nuevo —una zona express— y hay que meterlo en cada implementación viva; si editas solo el modern y olvidas el legacy, un pedido enrutado al legacy produce un KeyError real —el drift, ejecutado—. Borrar el legacy hace ese error imposible: solo hay un lugar donde aplicar el cambio.

Conexión con el módulo. Las lecciones 2-5 construyeron y validaron la migración; esta la termina, borrando lo viejo. Es el paso equivalente a "retirar el legacy" del strangler (módulo 3, lección 7): allá se borraba el servicio viejo cuando el tráfico HTTP llegó a 0; aquí se borra la implementación vieja cuando el flag llegó a 100% y el parallel-run está limpio. Fíjate en la frontera: en el strangler, retirar el legacy es apagar y borrar un servicio (un proceso). Aquí es borrar una clase y un flag del código. En los dos casos, la meta es la misma y la resistencia también: la tentación de dejar lo viejo "prendido por si acaso", que convierte la migración en eterna.

Una analogía: desmontar el motor viejo del avión

Vuelve al avión una última vez. La válvula (el flag) está al 100% en el motor nuevo: todo el empuje viene de él, y el motor viejo gira en vacío, sin hacer nada. Llevas días volando así, con las lecturas del motor nuevo perfectas. Ahora tienes dos opciones.

La opción cómoda —y equivocada— es dejar el motor viejo montado, apagado, "por si acaso". Suena prudente: si el nuevo falla algún día, ahí está el viejo. Pero mira el costo real de cargar ese motor muerto. Pesa: el avión gasta más combustible arrastrándolo. Ocupa espacio y cableado. Y —lo peor— cada vez que el equipo de mantenimiento hace un cambio en el sistema de propulsión, tiene que aplicarlo a los dos motores, incluido el que no vuela, para que sigan "iguales por si acaso". Si un día olvidan actualizar el motor viejo y de repente hay que usarlo, arranca con una configuración vieja e incompatible —y ahí el "por si acaso" te mata en vez de salvarte—.

La opción correcta es desmontar el motor viejo. Una vez que el nuevo demostró, durante suficiente tiempo, que empuja bien, quitas el viejo del avión. El avión pesa menos, gasta menos, y el equipo de mantenimiento ya solo cuida un motor. ¿Y la seguridad? No viene de cargar un motor muerto: viene de que el motor nuevo esté bien construido y monitoreado, y de poder aterrizar y arreglarlo si hace falta (en el código: el legacy sigue en el historial de git, recuperable si de verdad se necesitara, sin pesar en el avión). Un motor viejo montado y sin mantener no es una red de seguridad; es peso muerto que además se pudre.

Desmontar el motor viejo es borrar LegacyShipping. Dejarlo montado "por si acaso" es la migración eterna. Y el equipo de mantenimiento que tiene que tocar los dos motores por cada cambio es el doble mantenimiento que vamos a medir.

Ejemplo trabajado: el inventario del borrado y el drift medido

Vamos a ejecutar dos cosas. Primero, un inventario de las piezas móviles —las cosas que hay que mantener— antes y después de borrar el legacy. Segundo, una demostración del drift: llega un requisito nuevo (una zona express con tarifa 15.0), y vemos qué pasa cuando las dos implementaciones siguen vivas y alguien edita solo una.

# --- Parte 1: inventario de piezas moviles, antes y despues de borrar el legacy. ---
before = {
    "implementaciones": ["LegacyShipping", "ModernShipping"],
    "flag":             ["FeatureFlag(rollout)"],
    "wiring":           ["resolve_calculator (elige legacy vs modern)"],
}
after = {
    "implementaciones": ["ModernShipping"],
    "flag":             [],
    "wiring":           ["get_calculator -> ModernShipping (directo)"],
}

def count_parts(inv):
    return sum(len(v) for v in inv.values())

print("=== Parte 1: piezas moviles antes y despues del borrado ===")
for k in before:
    print(f"  {k:<18} antes  : {before[k]}")
    print(f"  {'':<18} despues: {after[k] or '(borrado)'}")
print(f"\n  total piezas moviles: {count_parts(before)} -> {count_parts(after)}")
print("  el flag desaparece; la abstraccion queda con una sola implementacion.")

# --- Parte 2: el costo de dejar las dos "por si acaso". Llega un requisito nuevo:
# una zona "express" con tarifa 15.0. Con las dos vivas, hay que editar CADA una;
# si editas solo modern y olvidas legacy, un pedido enrutado a legacy hace DRIFT. ---
class LegacyShipping:
    RATES = {"local": 5.0, "national": 10.0, "international": 25.0}          # sin 'express'
    def cost(self, order):
        return round(self.RATES[order["zone"]] + max(0.0, order["weight_kg"] - 1.0) * 2.0, 2)

class ModernShipping:
    RATES = {"local": 5.0, "national": 10.0, "international": 25.0, "express": 15.0}  # editada
    def cost(self, order):
        return round(self.RATES[order["zone"]] + max(0.0, order["weight_kg"] - 1.0) * 2.0, 2)

express = {"id": 99, "zone": "express", "weight_kg": 2.0, "order_total": 40.0}

print("\n=== Parte 2: requisito nuevo (zona 'express') con las DOS vivas ===")
try:
    print("  legacy.cost(express):", LegacyShipping().cost(express))
except KeyError as e:
    print(f"  legacy.cost(express): KeyError {e}  <- DRIFT: se olvido editar legacy")
print("  modern.cost(express):", ModernShipping().cost(express))
print("  dejar las dos => cada cambio futuro es 2 edits y un riesgo de drift.")

print("\n=== con el legacy ya borrado: una sola implementacion ===")
print("  modern.cost(express):", ModernShipping().cost(express),
      " (un solo edit, drift imposible: solo hay una fuente de verdad)")

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

=== Parte 1: piezas moviles antes y despues del borrado ===
  implementaciones   antes  : ['LegacyShipping', 'ModernShipping']
                     despues: ['ModernShipping']
  flag               antes  : ['FeatureFlag(rollout)']
                     despues: (borrado)
  wiring             antes  : ['resolve_calculator (elige legacy vs modern)']
                     despues: ['get_calculator -> ModernShipping (directo)']

  total piezas moviles: 4 -> 2
  el flag desaparece; la abstraccion queda con una sola implementacion.

=== Parte 2: requisito nuevo (zona 'express') con las DOS vivas ===
  legacy.cost(express): KeyError 'express'  <- DRIFT: se olvido editar legacy
  modern.cost(express): 17.0
  dejar las dos => cada cambio futuro es 2 edits y un riesgo de drift.

=== con el legacy ya borrado: una sola implementacion ===
  modern.cost(express): 17.0  (un solo edit, drift imposible: solo hay una fuente de verdad)

Lee la Parte 1. Antes del borrado, el sistema tiene cuatro piezas móviles: dos implementaciones (LegacyShipping, ModernShipping), un flag (FeatureFlag), y el wiring que elige entre las dos (resolve_calculator). Después del borrado, quedan dos: una sola implementación (ModernShipping) y un wiring trivial que la entrega directo. El flag desapareció —ya no hay entre qué elegir— y la abstracción quedó con una sola implementación detrás. total piezas moviles: 4 -> 2. Cada pieza que se borra es una cosa menos que mantener, entender y que pueda fallar. Borrar el legacy no solo quita LegacyShipping: quita también el flag y simplifica el wiring, porque esas piezas solo existían para gestionar la coexistencia.

Lee la Parte 2, que es el corazón de por qué el borrado importa. Llega un requisito nuevo: una zona express con tarifa 15.0. El equipo la agrega a ModernShipping.RATES —pero olvida agregarla a LegacyShipping.RATES, porque son dos lugares y es fácil que uno se escape—. Ahora mira qué pasa con un pedido express: modern.cost(express) devuelve 17.0 (base 15.0 + recargo de 2.0 por el kg extra), pero legacy.cost(express) lanza KeyError 'express' —el legacy no conoce esa zona—. Ese KeyError es el drift, ejecutado: las dos implementaciones divergieron, y ahora el comportamiento de un pedido express depende de si el flag lo enrutó al modern (funciona) o al legacy (revienta). Con las dos vivas, cada cambio futuro es dos ediciones y un riesgo de drift cada vez que una se olvida.

Y lee la tercera sección: con el legacy ya borrado, solo existe ModernShipping. El requisito express se aplica en un solo lugar, modern.cost(express) da 17.0, y el drift es imposible —no hay una segunda implementación que se pueda olvidar de actualizar—. Esa es la recompensa del borrado: una sola fuente de verdad. Cada cambio futuro se hace una vez, en un lugar, sin riesgo de que dos copias diverjan. El KeyError de la Parte 2 no puede ocurrir cuando solo hay una implementación.

Profundización: por qué el "por si acaso" es una trampa, y qué hacer con la abstracción

El argumento para dejar el legacy —"por si el modern falla, tenemos el viejo como respaldo"— suena razonable pero no resiste el análisis. Desglosémoslo:

Argumento: "dejo el legacy por si el modern falla"
  │
  ├─ ¿El legacy se mantiene al día con los cambios?
  │     SI  -> pagas doble mantenimiento por cada cambio, para siempre
  │     NO  -> el legacy se pudre; si algun dia lo necesitas, ya no sirve (drift)
  │
  └─ ¿De verdad es tu red de seguridad?
        La red real es: modern bien construido + parallel-run + git (recuperable).
        El legacy montado y sin mantener no es red; es peso muerto que ademas se pudre.

El "por si acaso" tiene solo dos finales, y los dos son malos. Si mantienes el legacy al día, pagas el doble mantenimiento eternamente —cada cambio en dos lugares, con el riesgo de drift de la Parte 2—. Si no lo mantienes al día (lo más común, porque nadie quiere tocar código muerto), el legacy se pudre: acumula divergencia con el modern, y el día que "por si acaso" llegara a usarlo, arrancaría con comportamiento viejo e incompatible —justo cuando más lo necesitas—. En ninguno de los dos finales el legacy es una red de seguridad real. La red de seguridad de verdad es otra: un ModernShipping bien construido y validado (el parallel-run), un flag para revertir en caliente mientras la migración está en curso, y —una vez borrado— el historial de git, que conserva el código del legacy recuperable si de verdad hiciera falta, sin que pese en el sistema vivo.

Queda una decisión: ¿qué pasa con la abstracción cuando solo hay una implementación? Tres caminos:

  1. Conservarla. Si la abstracción ShippingCalculator gana su lugar por otras razones —hay planes de una tercera implementación (una PremiumShipping), o el desacople facilita los tests (puedes inyectar un FakeShipping en las pruebas)— déjala. Una abstracción con una sola implementación hoy está bien si se justifica por el diseño, no solo por la migración.
  2. Colapsarla. Si la abstracción existía solo para la migración —para poder conmutar entre legacy y modern— y no aporta nada más, colápsala: los llamadores pasan a usar ModernShipping directamente (o la abstracción se convierte en la clase misma). Menos indirección, código más simple.

La regla: la abstracción se conserva si gana su lugar por el diseño; se colapsa si solo servía como andamio de la migración. No hay una respuesta única —depende de si ShippingCalculator es útil más allá de haber permitido conmutar—. Lo que no se hace es dejar la abstracción y las dos implementaciones "por si acaso": eso es no terminar.

Errores comunes

Dejar las dos implementaciones "por si acaso", para siempre. Qué pasa: el flag llega a 100%, el modern funciona, y el equipo deja LegacyShipping y el flag en el código —"no molesta, y por si acaso"—. Por qué pasa: borrar código se siente arriesgado ("¿y si lo necesitamos?") y no borrarlo no cuesta hoy. Cómo detectarlo: llevas meses con el flag al 100% pero LegacyShipping sigue en el repositorio, y cada cambio al cálculo de envío alguien pregunta "¿esto va también en el legacy?". Cómo corregirlo: el borrado es parte de la migración, no un extra opcional. Cuando el flag lleva suficiente tiempo estable al 100% con el parallel-run limpio, borra LegacyShipping, borra el flag, y simplifica el wiring. La red de seguridad no es el legacy montado (que se pudre): es el modern validado y el git que lo conserva recuperable. Una migración sin borrado no está terminada; está congelada a un paso del final, pagando el impuesto del doble mantenimiento indefinidamente.

Borrar el legacy antes de que el modern esté estable. Qué pasa: con prisa por "terminar", el equipo borra LegacyShipping en cuanto el flag toca 100%, sin dejar que el modern se pruebe en producción un tiempo. Por qué pasa: la satisfacción de "cerrar" la migración empuja a borrar temprano. Cómo detectarlo: borraste el legacy el mismo día que subiste el flag a 100%, sin días de operación estable ni parallel-run sostenido. Cómo corregirlo: el borrado va después de que el modern demostró estabilidad —el flag al 100% durante suficiente tiempo, el parallel-run en 0 discrepancias sostenidas, sin incidentes—. Mientras esa evidencia no exista, el legacy y el flag son tu poder de reversión rápida (bajas el flag y vuelves al viejo en el acto). Borrarlos antes de tiempo te quita esa reversión justo cuando el modern es más nuevo y menos probado. Estabilidad demostrada primero, borrado después: es lo simétrico de "validar antes de subir el flag".

Colapsar una abstracción que sí ganaba su lugar. Qué pasa: al borrar el legacy, el equipo también elimina la abstracción ShippingCalculator "porque ya solo hay una implementación", y hace que los llamadores usen ModernShipping directo —perdiendo el desacople que servía para los tests o para una futura tercera implementación—. Por qué pasa: "una sola implementación no necesita interfaz" suena a simplificación sensata. Cómo detectarlo: después de colapsar, los tests que inyectaban un FakeShipping ya no pueden, o el plan de agregar una PremiumShipping se vuelve un refactor grande otra vez. Cómo corregirlo: decide sobre la abstracción por su valor de diseño, no solo por el conteo de implementaciones actual. Si ShippingCalculator facilita los tests (inyectar un doble) o hay implementaciones futuras planeadas, consérvala aunque hoy tenga una sola implementación detrás. Colápsala solo si existía puramente como andamio de la migración y no aporta nada más. Borrar el legacy es obligatorio; colapsar la abstracción es una decisión de diseño aparte.

Ejercicios

Ejercicio 1 — El motor viejo montado "por si acaso". En la analogía, dejar el motor viejo montado y apagado "por si acaso" parece prudente. (a) ¿Cuáles son los dos finales posibles de esa decisión, y por qué los dos son malos? (b) ¿Cuál es la red de seguridad real del avión, si no es el motor viejo montado? (c) Traduce las dos cosas al código (el motor viejo y la red real).

Ver solución

(a) Los dos finales: (i) mantienes el motor viejo al día con cada cambio del sistema de propulsión —pagas doble mantenimiento eternamente, y arriesgas drift cada vez que olvidas actualizar uno—; o (ii) no lo mantienes —el motor viejo se pudre, acumula configuración incompatible, y el día que "por si acaso" quisieras usarlo, ya no arranca bien—. En (i) pagas para siempre; en (ii) tu supuesta red no funciona cuando la necesitas. Ninguno de los dos entrega el respaldo que prometía.

(b) La red de seguridad real es que el motor nuevo esté bien construido y monitoreado (bien probado antes de confiarle el vuelo, con sus lecturas vigiladas), y poder aterrizar y arreglarlo si hace falta. La seguridad viene de la calidad y el monitoreo de lo nuevo, no de arrastrar un motor muerto.

(c) El motor viejo montado "por si acaso" = LegacyShipping dejado en el código tras el flag al 100% (peso muerto que se pudre y exige doble mantenimiento). La red de seguridad real = ModernShipping validado con parallel-run + el flag para revertir en caliente mientras la migración está en curso + el historial de git, que conserva el código del legacy recuperable si de verdad hiciera falta, sin que pese en el sistema vivo. "Aterrizar y arreglar" = poder recuperar del git y corregir, no cargar el código muerto en producción.

Ejercicio 2 — El drift, leído desde la corrida. La Parte 2 mostró legacy.cost(express): KeyError 'express' mientras modern.cost(express) daba 17.0. (a) ¿Qué causó exactamente el KeyError? (b) ¿Por qué este error es un ejemplo de "drift" y no un bug cualquiera? (c) ¿Por qué borrar el legacy hace este error imposible, en vez de solo improbable?

Ver solución

(a) Lo causó que se agregó la zona express a ModernShipping.RATES pero no a LegacyShipping.RATES. Cuando legacy.cost hace self.RATES["express"], la clave no existe y Python lanza KeyError 'express'. El modern, que sí tiene la clave, calcula 17.0 (base 15.0 + 2.0 de recargo). El error nace de que un cambio se aplicó a una implementación y no a la otra.

(b) Es "drift" —deriva— porque las dos implementaciones, que debían comportarse igual, divergieron con el tiempo: empezaron idénticas (paridad validada por el parallel-run), pero un cambio posterior las separó. No es un bug aislado en una función; es la consecuencia estructural de mantener dos copias que hay que sincronizar a mano. El drift es el modo de falla característico de dejar las dos implementaciones vivas: cada cambio es una oportunidad de que se desincronicen.

(c) Porque el KeyError requiere que exista una segunda implementación (el legacy) que alguien pudo olvidar actualizar. Si borras el legacy, solo queda ModernShipping: no hay una segunda copia que sincronizar, así que no hay nada de qué olvidarse. El cambio de express se aplica en el único lugar que existe, y punto. Borrar la vieja no reduce la probabilidad del drift (como lo haría, digamos, un test que verifica la sincronía); lo hace imposible, porque elimina la condición que lo permite —la existencia de dos fuentes de verdad—. Una sola implementación no puede divergir de sí misma.

Ejercicio 3 — ¿Conservar o colapsar la abstracción? Después de borrar LegacyShipping, quedas con la abstracción ShippingCalculator y una sola implementación (ModernShipping). Para cada escenario, decide si conservarías la abstracción o la colapsarías, y por qué: (a) los tests del checkout inyectan un FakeShipping que devuelve costos fijos, para no depender del cálculo real; (b) hay un plan aprobado de agregar PremiumShipping para clientes VIP el próximo trimestre; (c) la abstracción se creó solo para poder conmutar durante la migración y ningún test ni plan la usa.

Ver solución

(a) Conservar. Los tests inyectan un FakeShipping a través de la abstracción ShippingCalculator; ese desacople es valioso —permite probar el checkout sin depender del cálculo real de envío—. Si colapsaras la abstracción, los tests tendrían que usar ModernShipping real o hacer monkey-patching frágil. La abstracción gana su lugar por el diseño (testabilidad), no solo por la migración: se queda.

(b) Conservar. Hay una segunda implementación planeada (PremiumShipping) para el próximo trimestre. Si colapsas la abstracción ahora, tendrías que reintroducirla en tres meses —repitiendo el trabajo de la lección 2—. Conservarla deja el punto de conmutación listo para cuando llegue PremiumShipping. La abstracción gana su lugar por una necesidad futura concreta: se queda.

(c) Colapsar. La abstracción existía puramente como andamio de la migración —para conmutar entre legacy y modern— y nada más la usa: ni tests ni planes. Terminada la migración, es indirección sin propósito. Colápsala: haz que los llamadores usen ModernShipping directamente (o que la abstracción sea la clase). Menos indirección, código más simple. La regla que separa los tres casos: conservar la abstracción si gana su lugar por el diseño (testabilidad, extensibilidad concreta); colapsarla si solo servía de andamio. No la dejes "por si algún día" —eso es la misma trampa del "por si acaso", ahora aplicada a la interfaz—.

Resumen y siguiente paso

En esta lección hiciste el último paso de branch by abstraction, el que paga la migración: borrar la implementación vieja. Viste, con el motor viejo que se desmonta del avión en vez de cargarlo "por si acaso", que dejar el legacy no es prudencia sino peso muerto que se pudre y exige doble mantenimiento. Y lo ejecutaste en dos partes: el inventario de piezas móviles cayendo de 4 a 2 (el flag desaparece, la abstracción queda con una sola implementación), y el drift medido —un requisito nuevo (express) editado solo en el modern produjo un KeyError real en el legacy, el drift ejecutado, imposible una vez que solo hay una fuente de verdad—. Aprendiste por qué el "por si acaso" es una trampa con dos finales malos (doble mantenimiento eterno o legacy podrido), cuál es la red de seguridad real (modern validado + git), y cómo decidir el destino de la abstracción (conservar si gana su lugar por el diseño, colapsar si solo era andamio).

Antes de avanzar deberías poder: explicar por qué borrar el legacy es parte de la migración y no un extra; medir el costo de dejar las dos implementaciones (doble mantenimiento y drift); decir por qué la red de seguridad real no es el legacy montado; y decidir si conservar o colapsar la abstracción según su valor de diseño.

La lección 7 sube un nivel para explicar la razón profunda de todo el patrón: sin rama de larga vida, para evitar el merge hell. Has hecho los cinco pasos (insertar, construir, conmutar, validar, borrar) —pero ¿por qué hacerlos en main, en pasos chicos, en vez de en una rama aparte donde "hacer el refactor tranquilo" y mergear al final? Vas a ejecutar la comparación medida: el mismo refactor en una rama larga que no integra durante semanas acumula líneas en conflicto al mergear, contra 0 cuando cada paso se integra en main. Branch by abstraction es, precisamente, lo que hace posible un refactor grande sin esa rama larga —y la lección 7 lo demuestra con números—.

Recursos

  • Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Fowler subraya que el patrón termina removiendo la implementación vieja y, si corresponde, la abstracción: la migración no está hecha hasta que se borra lo viejo. El paso de esta lección. En inglés.
  • Pete Hodgson, "Feature toggles are one of the worst kinds of technical debt" (martinfowler.com, 2017) — martinfowler.com/articles/feature-toggles.html. Por qué los flags de migración deben ser temporales y removerse en cuanto cumplen su función, en vez de acumularse como deuda —el borrado del flag de esta lección—. En inglés.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — sobre retirar el código viejo tras la migración y no dejar caminos muertos "por si acaso", con el mismo razonamiento aplicado a servicios. En inglés.
  • Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — el legacy como código que pesa y da miedo; borrar lo que ya no se usa es reducir esa superficie. La motivación de fondo para no acumular implementaciones muertas. En inglés.