Módulo 4: Branch by abstraction
Construir la implementación moderna detrás de la abstracción
Descripción
En la lección anterior insertaste la abstracción ShippingCalculator y envolviste la lógica vieja en LegacyShipping, sin cambiar comportamiento. Ahora viene el segundo paso: construir la implementación nueva detrás de esa misma abstracción, al lado de la vieja. Es el paso donde por fin escribes código nuevo —el ModernShipping—, pero con una restricción que lo cambia todo: la implementación nueva cumple exactamente el mismo contrato que la vieja (cost(order) -> float), aunque por dentro esté construida de otra forma. Y no toca al legacy: lo deja intacto, corriendo, sosteniendo el sistema, mientras la nueva se levanta a su costado.
Esto es el motor nuevo del avión montado al lado del viejo, conectado al mismo adaptador. Los dos motores existen a la vez. El nuevo todavía no recibe potencia —el flag sigue en OFF, producción usa el legacy—, pero ya está ahí, listo, atornillado al mismo acople neutro. La clave de este paso es la coexistencia: las dos implementaciones viven en el código al mismo tiempo, las dos cumplen ShippingCalculator, y la abstracción puede delegar en cualquiera de las dos. Ninguna sabe de la otra; ninguna hereda de la otra; comparten solo el contrato.
Esta lección lo ejecuta con una verificación de coexistencia y paridad: montamos ModernShipping con una estructura interna distinta a la del legacy —tarifas nombradas, reglas separadas en métodos con nombre— y corremos las dos lado a lado sobre los casos conocidos, solo para comparar. El sistema en producción sigue usando el legacy (el flag está en OFF): ModernShipping está lista, pero no recibe una sola llamada de usuario real. Esa separación —la nueva existe pero no tiene tráfico— es lo que hace este paso seguro.
Conexión con el módulo. La lección 2 insertó la abstracción (el primer paso); esta construye la implementación nueva detrás de ella (el segundo). La lección 4 hace el tercero: conmutar con el flag para darle tráfico a la nueva. La 5 hace la validación seria con parallel-run antes de confiar. Todo lo que sigue asume que ModernShipping ya existe y cumple el contrato: sin la implementación nueva no hay nada que conmutar. Fíjate en la frontera: construir ModernShipping al lado —sin tocar el legacy— es el equivalente interno de "construir el servicio nuevo al lado" del strangler (módulo 3, lección 3). Allá el servicio nuevo era un proceso aparte con el mismo contrato HTTP; aquí es una clase aparte con el mismo contrato de método. El principio es idéntico: lo nuevo se construye sin modificar lo viejo, y comparten el contrato, no la implementación.
Una analogía: el segundo motor montado al lado, sin quitar el primero
Volvamos al avión en vuelo. Ya instalaste el adaptador —la brida universal— y el motor viejo sigue atornillado a él, empujando. Ahora traes el motor nuevo. Lo que no haces es quitar el viejo para poner el nuevo: eso dejaría al avión sin motor por un instante. Lo que haces es montar el motor nuevo en un segundo acople del mismo adaptador, al lado del viejo, con el avión volando con el motor de siempre.
Durante este paso, el motor nuevo está montado pero apagado. No mueve al avión: el viejo sigue haciendo todo el trabajo. Pero puedes hacer algo valiosísimo sin arriesgar el vuelo: encender el motor nuevo en tierra de pruebas —o dejarlo girar en vacío— y medir que empuja lo que debe, que la temperatura es la correcta, que no vibra raro. Comparas sus lecturas contra las del viejo en las mismas condiciones. Si el nuevo da los mismos números que el viejo, ganaste confianza sin haberle dado un solo pasajero.
El motor nuevo montado y apagado es ModernShipping construido detrás de la abstracción con el flag en OFF: existe, cumple el mismo acople, pero no recibe tráfico. Encenderlo en tierra para comparar sus lecturas contra el viejo es correr las dos implementaciones sobre los mismos pedidos y verificar que dan el mismo costo —la paridad de esta lección, y el parallel-run de la lección 5—. Lo importante es que el motor nuevo puede tener una ingeniería interna completamente distinta —otro diseño de turbina, otro sistema de inyección— siempre que empuje igual y encaje en el mismo adaptador. Eso es lo que significa "mismo contrato, distinta implementación".
Ejemplo trabajado: la coexistencia de legacy y modern detrás de la abstracción
Vamos a construir ModernShipping al lado del LegacyShipping y a demostrar que las dos coexisten detrás de la misma abstracción, cumpliendo el mismo contrato. La diferencia está en la estructura interna: mientras el legacy tiene toda su lógica apelmazada en un solo método, el modern la organiza —una tabla de tarifas con nombre (RATES), constantes nombradas para el umbral de envío gratis y el peso incluido, y las reglas separadas en métodos privados (_qualifies_for_free, _weight_surcharge)—. Es más legible y más fácil de cambiar, pero produce el mismo resultado. Luego corremos las dos sobre los casos conocidos, solo para comparar, dejando claro que producción sigue en el legacy.
from typing import Protocol
class ShippingCalculator(Protocol):
def cost(self, order: dict) -> float: ...
# --- LEGACY: intacta. La abstraccion la envuelve; no la tocamos. ---
class LegacyShipping:
def cost(self, order: dict) -> float:
base = {"local": 5.0, "national": 10.0, "international": 25.0}[order["zone"]]
surcharge = max(0.0, order["weight_kg"] - 1.0) * 2.0
cost = base + surcharge
if order["order_total"] >= 50.0 and order["zone"] != "international":
cost = 0.0
return round(cost, 2)
# --- MODERN: construida AL LADO, detras de la MISMA abstraccion. Estructura interna
# distinta (tabla de tarifas + reglas nombradas), pero el MISMO contrato: cost(order). ---
class ModernShipping:
RATES = {"local": 5.0, "national": 10.0, "international": 25.0}
FREE_SHIPPING_MIN = 50.0
WEIGHT_INCLUDED_KG = 1.0
PER_EXTRA_KG = 2.0
def _qualifies_for_free(self, order: dict) -> bool:
return order["order_total"] >= self.FREE_SHIPPING_MIN and order["zone"] != "international"
def _weight_surcharge(self, order: dict) -> float:
return max(0.0, order["weight_kg"] - self.WEIGHT_INCLUDED_KG) * self.PER_EXTRA_KG
def cost(self, order: dict) -> float:
if self._qualifies_for_free(order):
return 0.0
return round(self.RATES[order["zone"]] + self._weight_surcharge(order), 2)
ORDERS = [
{"id": 1, "zone": "local", "weight_kg": 0.5, "order_total": 20.0},
{"id": 2, "zone": "national", "weight_kg": 1.0, "order_total": 30.0},
{"id": 3, "zone": "international", "weight_kg": 3.0, "order_total": 80.0},
{"id": 4, "zone": "local", "weight_kg": 1.0, "order_total": 60.0},
{"id": 5, "zone": "national", "weight_kg": 4.0, "order_total": 55.0},
]
# --- Coexistencia: las dos implementan ShippingCalculator. Corremos las dos lado a
# lado sobre los casos conocidos, SOLO para comparar. El sistema en produccion sigue
# usando legacy (el flag aun esta en OFF): modern esta lista, pero no recibe trafico. ---
legacy, modern = LegacyShipping(), ModernShipping()
print("Coexistencia detras de la abstraccion: las dos cumplen el mismo contrato.\n")
print(f"{'order':>6}{'zone':>16}{'legacy.cost':>13}{'modern.cost':>13}{'igual?':>9}")
print("-" * 57)
equal_count = 0
for o in ORDERS:
lc, mc = legacy.cost(o), modern.cost(o)
same = lc == mc
equal_count += same
print(f"{o['id']:>6}{o['zone']:>16}{lc:>13}{mc:>13}{('SI' if same else 'NO'):>9}")
print("-" * 57)
print(f"\nCoinciden en {equal_count}/{len(ORDERS)} casos conocidos.")
print("El flag sigue en OFF: produccion usa legacy. modern vive al lado, lista.")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
Coexistencia detras de la abstraccion: las dos cumplen el mismo contrato.
order zone legacy.cost modern.cost igual?
---------------------------------------------------------
1 local 5.0 5.0 SI
2 national 10.0 10.0 SI
3 international 29.0 29.0 SI
4 local 0.0 0.0 SI
5 national 0.0 0.0 SI
---------------------------------------------------------
Coinciden en 5/5 casos conocidos.
El flag sigue en OFF: produccion usa legacy. modern vive al lado, lista.
Lee la columna igual?: SI en las cinco filas, y el resumen lo confirma: Coinciden en 5/5 casos conocidos. Las dos implementaciones —una apelmazada, la otra organizada— producen el mismo costo en todos los casos: 5.0, 10.0, 29.0, 0.0, 0.0. Eso es la paridad de comportamiento con estructura interna distinta. ModernShipping no copió línea por línea al legacy: lo re-expresó con tarifas nombradas y reglas separadas, y aun así llega al mismo número. Fíjate especialmente en la fila 3, el international: ModernShipping replicó la regla tácita (envío gratis "y no international") en su método _qualifies_for_free, así que devuelve 29.0, igual que el legacy. Esa es la regla que la lección 5 mostrará que es fácil de perder; aquí ModernShipping la conservó.
Y lee la última línea, que es la más importante de este paso: El flag sigue en OFF: produccion usa legacy. modern vive al lado, lista. ModernShipping existe, cumple el contrato, y coincide con el legacy en los casos conocidos —pero no recibe una sola llamada de un usuario real—. Producción sigue corriendo el legacy. La implementación nueva está montada y apagada, como el segundo motor del avión. Este es el estado más seguro posible de la migración: tienes lo nuevo listo y verificado, sin que nadie lo esté usando todavía. Conmutar el flag —darle tráfico— es el paso siguiente, y solo se hace cuando la validación seria (el parallel-run de la lección 5) está limpia.
Profundización: mismo contrato, distinta implementación
La restricción que gobierna este paso —"mismo contrato, distinta implementación"— merece desmenuzarse, porque es lo que hace que branch by abstraction funcione. Aquí está la estructura, con las dos implementaciones colgando de la abstracción:
ShippingCalculator (el contrato: cost(order) -> float)
│
┌─────────────┴─────────────┐
│ │
LegacyShipping ModernShipping
(logica apelmazada, (RATES nombradas,
un solo metodo) reglas en metodos,
mismo resultado)
[recibe trafico HOY] [existe, sin trafico aun]
Lo que las dos comparten es el contrato: reciben un order, devuelven un float que es el costo de envío. Lo que no comparten es cómo llegan a ese número. El legacy lo hace con un diccionario inline y una condición larga; el modern con una tabla de tarifas, constantes con nombre y métodos separados. Ninguna hereda de la otra —no hay una clase base con lógica compartida—; las dos, independientemente, cumplen el Protocol. Esto importa por una razón: si ModernShipping heredara de LegacyShipping, arrastraría su lógica, y no sería una implementación nueva, sería el viejo con parches. La coexistencia limpia exige que sean hermanas, no madre e hija: dos implementaciones separadas del mismo contrato.
¿Por qué re-expresar la lógica en vez de copiarla? Porque el objetivo de la migración no es tener dos copias idénticas del legacy —eso no mejora nada—, sino tener una implementación mejor: más legible, más fácil de cambiar, sin las rarezas que ya no se necesitan. ModernShipping con sus tarifas nombradas y sus métodos separados es más fácil de mantener que el legacy apelmazado. Pero —y este es el equilibrio delicado— tiene que producir el mismo resultado en los casos conocidos, incluidas las rarezas que sí siguen siendo comportamiento correcto (como que international no sea gratis). La lección 5 enseña a distinguir las rarezas que hay que conservar (comportamiento correcto) de los bugs que se pueden arreglar —pero eso es una decisión deliberada, no un accidente de la reimplementación—. En este paso, la meta es paridad: mismo resultado, mejor estructura.
Un detalle sobre por qué producción sigue en el legacy aunque el modern ya coincida en 5/5: cinco casos conocidos no son prueba suficiente. Coincidir en cinco pedidos elegidos no garantiza coincidir en los miles de pedidos reales, con sus combinaciones raras de zona, peso y total. Darle tráfico al modern con solo cinco casos verificados sería temerario. Por eso el flag se queda en OFF hasta la validación seria: el parallel-run que corre las dos sobre muchos más casos (lección 5) y el rollout gradual con fallback implícito (lección 4). La coexistencia de esta lección es el andamio; la confianza para conmutar se construye después.
Errores comunes
Hacer que ModernShipping herede de LegacyShipping. Qué pasa: para "reusar" la lógica que ya funciona, el equipo hace class ModernShipping(LegacyShipping) y solo sobrescribe algunos métodos. Por qué pasa: parece eficiente —no reescribir lo que ya está—. Cómo detectarlo: ModernShipping no compila sin LegacyShipping; borrar el legacy (paso 5) rompería el modern. Las dos implementaciones que debían ser hermanas independientes resultaron ser madre e hija. Cómo corregirlo: ModernShipping debe ser una implementación independiente del contrato, sin heredar del legacy. Si hay lógica genuinamente compartida y neutra (una función de utilidad sin rarezas del viejo), extráela a un helper que ninguna de las dos "posea". Pero la regla general es que la nueva se escribe nueva, cumpliendo el contrato desde cero, para que el día que borres el legacy no arrastre nada. Heredar del legacy es atarse a él justo cuando querías soltarte.
Darle tráfico al modern con solo unos casos verificados. Qué pasa: el modern coincide con el legacy en los cinco casos del ejemplo, y el equipo concluye "listo, es equivalente" y conmuta el flag. Por qué pasa: cinco SI seguidos dan una sensación de certeza que no corresponde a la evidencia. Cómo detectarlo: si estás por subir el flag y tu única validación son un puñado de casos elegidos a mano, no tienes datos suficientes. Los pedidos reales tienen combinaciones que tus cinco casos no cubren. Cómo corregirlo: la coexistencia de este paso es para construir y montar la nueva, no para autorizarla. La autorización viene de la validación seria —el parallel-run sobre muchos casos (lección 5)— y del rollout gradual que expone la nueva a poco tráfico primero (lección 4). Cinco casos verificados dicen "vale la pena seguir"; no dicen "conmuta al 100%".
Aprovechar la reimplementación para "arreglar" rarezas sin decidirlo. Qué pasa: al re-expresar la lógica, el equipo ve la regla "international no es gratis" y piensa "esto parece un bug, lo quito"—y ModernShipping empieza a diferir del legacy sin que nadie lo haya decidido. Por qué pasa: reescribir invita a "mejorar", y algunas rarezas del legacy parecen errores. Cómo detectarlo: el parallel-run (lección 5) muestra discrepancias que no son bugs del modern, sino cambios de comportamiento que alguien introdujo "de paso". Cómo corregirlo: en este paso, la meta es paridad, no mejora de reglas. Toda diferencia de comportamiento respecto al legacy debe ser una decisión explícita y separada, no un efecto colateral de reescribir. Si "international no es gratis" resulta ser un bug que el negocio quiere corregir, se corrige en un cambio aparte, documentado, después de que la migración esté hecha y estable —no colado dentro de la reimplementación, donde se confunde con una regresión—. Primero migra a paridad; mejora las reglas después.
Ejercicios
Ejercicio 1 — El motor con otra ingeniería. En la analogía del avión, el motor nuevo se monta al lado del viejo, apagado, y puede tener una ingeniería interna completamente distinta. (a) ¿Qué tiene que ser igual entre el motor nuevo y el viejo para que sirva? (b) ¿Qué puede ser distinto? (c) ¿Por qué es valioso poder encenderlo "en tierra" y comparar sus lecturas antes de darle un pasajero?
Ver solución
(a) Tiene que ser igual el acople (encaja en la misma brida/adaptador) y el empuje en las condiciones conocidas (produce la potencia que debe). En el código, eso es el contrato: la misma firma (cost(order) -> float) y el mismo resultado en los casos conocidos. Es lo que hace que el motor —o la implementación— sea intercambiable.
(b) Puede ser distinta toda la ingeniería interna: el diseño de la turbina, el sistema de inyección, los materiales. En el código, la estructura interna: ModernShipping usa tarifas nombradas, constantes y métodos separados donde el legacy tenía todo apelmazado. El cómo es libre mientras el qué (contrato y resultado) sea igual.
(c) Porque encenderlo en tierra y comparar sus lecturas contra el viejo te da evidencia de paridad sin arriesgar a nadie: si el motor nuevo da los mismos números que el viejo en las mismas condiciones, ganaste confianza sin haberle confiado un vuelo con pasajeros. En el código, correr las dos implementaciones sobre los mismos pedidos y comparar (la coexistencia de esta lección, y el parallel-run de la 5) valida la nueva sin exponerla a usuarios reales. Es el estado más seguro: lo nuevo verificado, sin tráfico todavía.
Ejercicio 2 — Paridad con estructura distinta. En el ejemplo, LegacyShipping y ModernShipping coinciden en 5/5 casos pese a estar escritas de forma muy distinta. (a) ¿Qué significa exactamente que "cumplen el mismo contrato"? (b) ¿Por qué es deseable que la estructura interna sea distinta, en vez de una copia exacta del legacy? (c) ¿Coincidir en 5/5 casos autoriza a conmutar el flag al 100%? Justifica.
Ver solución
(a) Que las dos tienen la misma firma pública (cost(order) -> float) y producen el mismo resultado para las mismas entradas, sin que los llamadores tengan que saber cuál es cuál. El contrato es la promesa observable desde afuera (qué recibe, qué devuelve); cumplir el mismo contrato es ser intercambiables desde el punto de vista de los llamadores.
(b) Porque el objetivo de la migración no es duplicar el legacy —eso no mejora nada—, sino tener una implementación mejor: ModernShipping, con tarifas nombradas y reglas separadas en métodos, es más legible y más fácil de cambiar que el legacy apelmazado. Si copiaras el legacy línea por línea, tendrías dos copias del mismo problema. La estructura distinta es el punto: migras a algo mejor manteniendo el mismo comportamiento observable.
(c) No. Cinco casos elegidos a mano no cubren la variedad de los pedidos reales —combinaciones raras de zona, peso y total que no están en la muestra—. Coincidir en 5/5 dice "vale la pena seguir", no "es equivalente en todo". Para autorizar el rollout hace falta la validación seria: un parallel-run sobre muchos más casos (lección 5) y un rollout gradual que exponga la nueva a poco tráfico primero, con la vieja como red (lección 4). La coexistencia de este paso construye la nueva; la confianza para conmutar se gana después, con más evidencia.
Ejercicio 3 — ¿Herencia o implementación independiente? Un compañero propone escribir ModernShipping así: class ModernShipping(LegacyShipping), sobrescribiendo solo el método que calcula el recargo por peso, "para reusar todo lo demás que ya funciona". (a) ¿Qué problema crea esto para el paso 5 (borrar el legacy)? (b) ¿Qué principio de la coexistencia limpia se viola? (c) ¿Cuándo sí es legítimo compartir código entre las dos implementaciones, y cómo se hace bien?
Ver solución
(a) Crea un problema fatal para el paso 5: si ModernShipping hereda de LegacyShipping, no puedes borrar el legacy sin romper el modern —el modern depende de la clase vieja para todo lo que no sobrescribió—. La migración nunca podría terminar de verdad: el legacy quedaría "vivo por debajo" del modern para siempre, que es justo lo que branch by abstraction quiere evitar.
(b) Se viola el principio de que las implementaciones deben ser hermanas independientes, no madre e hija: cada una cumple el contrato por su cuenta, sin depender de la otra. La herencia ata al modern al legacy, contaminándolo con su lógica y sus rarezas, y hace imposible el borrado limpio. ModernShipping debe implementar ShippingCalculator desde cero, no extender al legacy.
(c) Es legítimo compartir código cuando hay lógica genuinamente neutra y sin rarezas del viejo —por ejemplo, una función de utilidad round_currency(x) o una constante de negocio que ambas usan—. Se hace bien extrayendo esa lógica a un helper que ninguna de las dos "posea": una función o módulo aparte del que las dos dependen como iguales, no una clase base de la que una herede de la otra. La diferencia es de dirección de la dependencia: compartir un helper neutro está bien (las dos dependen de algo externo y estable); heredar del legacy está mal (el modern depende del viejo, que quieres borrar). Regla práctica: si borrar el legacy rompe el modern, la dependencia está mal puesta.
Resumen y siguiente paso
En esta lección hiciste el segundo paso de branch by abstraction: construir la implementación moderna detrás de la abstracción. Viste, con el segundo motor montado al lado del viejo, que la nueva implementación se levanta sin tocar la vieja, cumpliendo el mismo contrato pero con otra ingeniería interna. Y lo ejecutaste con la verificación de coexistencia: montaste ModernShipping con tarifas nombradas y reglas separadas, corriste las dos implementaciones lado a lado sobre los casos conocidos, y viste Coinciden en 5/5 casos conocidos con el flag aún en OFF —la nueva lista y verificada, sin recibir tráfico real—. Aprendiste la restricción que gobierna el paso: mismo contrato, distinta implementación; hermanas independientes, no madre e hija; paridad de comportamiento, con las rarezas correctas conservadas y las mejoras dejadas para después.
Antes de avanzar deberías poder: explicar qué significa "mismo contrato, distinta implementación" y por qué la estructura distinta es deseable; decir por qué ModernShipping no debe heredar de LegacyShipping; explicar por qué coincidir en cinco casos no autoriza el rollout; y distinguir conservar una rareza correcta de "arreglar de paso" un supuesto bug sin decidirlo.
La lección 4 hace el tercer paso: el feature flag como punto de conmutación. Ahora que las dos implementaciones coexisten detrás de la abstracción, vas a montar el flag que decide, en cada llamada, cuál corre —y a subirlo del 0 al 100% de forma gradual, con un bucket estable por pedido—. Vas a verificar la propiedad central del patrón: que conmutar la implementación no rompe a los llamadores. Y vas a entender la diferencia entre un flag (runtime, por llamada, se cambia en caliente) y una config (deploy-time, toda la app), que decide cómo y cuándo se hace el cambio.
Recursos
- Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Fowler describe el segundo movimiento: construir la implementación nueva detrás de la abstracción, coexistiendo con la vieja, antes de conmutar. El paso de esta lección. En inglés.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — la idea de construir lo nuevo al lado de lo viejo con el mismo contrato, sin tocar la implementación existente, aplicada aquí a una clase en vez de a un servicio. En inglés.
- Robert C. Martin, Clean Architecture (Prentice Hall, 2017) — el principio de que los detalles (implementaciones concretas como
LegacyShipping/ModernShipping) dependen de las políticas (la abstracciónShippingCalculator), y no al revés, que es lo que permite coexistir dos implementaciones sin que los llamadores dependan de ninguna. En inglés. - Paul Hammant, "branchbyabstraction.com" — branchbyabstraction.com. El paso a paso, incluida la fase de tener las dos implementaciones vivas a la vez detrás de la abstracción. En inglés.