Módulo 4: Branch by abstraction
La capa de abstracción como un seam
Descripción
En la lección anterior viste el ciclo completo de branch by abstraction y lo corriste en miniatura. Ahora empezamos a construirlo por pasos, y el primero es el más importante y el que más gente se salta: insertar la capa de abstracción. Antes de escribir una línea de la implementación nueva, antes de conmutar un solo flag, necesitas un lugar donde la decisión "¿vieja o nueva?" pueda tomarse. Ese lugar, cuando no hay una frontera de red, se inserta en el código: es una interfaz —un Protocol— entre los llamadores y la implementación. Es lo que en la literatura de código legacy se llama un seam: un punto donde puedes cambiar el comportamiento del programa sin editar en ese punto, porque insertaste una costura por donde meter la mano.
Lo maravilloso de la abstracción —y la razón por la que es el primer paso— es que se puede insertar sin cambiar absolutamente nada. En el momento en que la metes, del otro lado solo está LegacyShipping, que envuelve la lógica vieja tal cual, sin tocar una fórmula. Los llamadores pasan a pedirle .cost() a la abstracción en vez de llamar la función directa, pero el resultado es idéntico. El sistema se comporta exactamente igual. Lo único que cambió es que ahora existe algo que antes no existía: un punto de conmutación por el que pasan todas las llamadas al cálculo de envío, donde mañana podrás decidir cuál implementación corre. Instalaste la válvula de paso cerrada en "vieja"; girarla es todo lo que sigue.
Esta lección lo ejecuta con una prueba de transparencia. Vamos a comparar, caso por caso, el resultado de la función de envío vieja llamada directamente contra el resultado de la misma lógica a través de la abstracción, y a verificar que son idénticos —incluidos los casos borde—. Esa verificación es lo que te da la confianza para insertar la abstracción en producción sin miedo: matemáticamente, no cambiaste el comportamiento.
Conexión con el módulo. La lección 1 te dio el ciclo completo; esta hace su primer paso: insertar la abstracción como punto de conmutación, con cero riesgo. La lección 3 hace el segundo paso (construir ModernShipping detrás de esta misma abstracción); la 4 hace el tercero (conmutar con el flag) usando esta abstracción con el flag ya distinto de "vieja". Todo lo que sigue en el módulo asume que este paso está hecho: sin la abstracción no hay dónde decidir vieja vs nueva. Fíjate en la frontera: la abstracción de aquí es un punto de conmutación interno —vive dentro del código, dentro del proceso—. El facade del módulo 3 era un proxy externo, interpuesto en la frontera de red de un endpoint. Aquí el cálculo de envío no es un endpoint: es una función interna, así que el punto de conmutación se inserta como una interfaz en el código, no como un proxy en la red.
Una analogía: la válvula de paso antes de cambiar la tubería
Imagina que tienes que cambiar un tramo de tubería vieja de tu casa —oxidada, que gotea— por una nueva. La restricción, como siempre, es que la casa está habitada: no puedes cortarle el agua a todo el edificio durante los días que dure el cambio. La cocina tiene que seguir teniendo agua, el otro baño también.
Lo primero que hace un buen plomero no tiene nada que ver con la tubería nueva: instala una válvula de paso justo antes del tramo que va a cambiar. Una llave que aísla ese pedazo del resto de la instalación. Cuando la instala, no cambia nada para la casa: el agua sigue pasando por la tubería vieja a través de la válvula abierta, con la misma presión, el mismo caudal. Nadie en la casa nota que ahora hay una válvula ahí. Pero para el plomero cambió todo: ahora tiene un punto donde puede cerrar el paso de ese tramo, cambiar la tubería detrás de la válvula, y volver a abrir —sin tocar el agua del resto de la casa—.
La válvula de paso es la abstracción. El tramo de tubería detrás de ella es la implementación (LegacyShipping hoy, ModernShipping mañana). Y el hecho de que instalar la válvula no cambie el agua de nadie es exactamente la prueba de transparencia que vamos a ejecutar. La válvula no transporta agua distinta ni la filtra ni le cambia la presión: solo abre o cierra el paso a lo que hay del otro lado. Una abstracción que hace más que eso —que valida, transforma, "aprovecha para arreglar de paso"— es una válvula que le cambia el sabor al agua: dejó de ser transparente, y perdiste la propiedad que la hacía segura de instalar.
Ejemplo trabajado: la prueba de transparencia de la abstracción
Vamos a montar la abstracción y a demostrar que insertarla es transparente. Partimos de la situación real: la lógica de envío vive hoy como una función suelta (legacy_shipping_fn), llamada desde muchos lugares del monolito. Insertamos el Protocol ShippingCalculator y una clase LegacyShipping que envuelve exactamente el cuerpo de esa función, sin cambiar una línea de cálculo. Los llamadores pasan a recibir la abstracción. Luego pasamos un conjunto de pedidos por los dos caminos —función directa y vía abstracción— y verificamos que los resultados coinciden. El conjunto incluye los casos borde que una reimplementación futura podría romper: el peso exacto de 1 kg (la frontera del recargo), el envío gratis, y el borde tácito de que el envío gratis no aplica a international.
from typing import Protocol
# === ANTES: la logica de envio es una FUNCION SUELTA, llamada desde muchos lugares
# del monolito (checkout, carrito, panel de admin, emails de confirmacion...). ===
def legacy_shipping_fn(order):
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)
# === DESPUES: insertamos una ABSTRACCION (una interfaz) como punto de conmutacion.
# LegacyShipping envuelve la MISMA logica, sin cambiar una sola linea de calculo. ===
class ShippingCalculator(Protocol):
def cost(self, order: dict) -> float: ...
class LegacyShipping:
def cost(self, order: dict) -> float:
# Exactamente el cuerpo de legacy_shipping_fn, ahora detras de la interfaz.
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)
# Los llamadores ahora reciben la abstraccion en vez de llamar la funcion directa.
def checkout_total(order, calc: ShippingCalculator):
return round(order["items_subtotal"] + calc.cost(order), 2)
# Casos, incluidos los BORDE: peso exacto de 1kg, envio gratis, y el borde tacito
# international-no-gratis que la reimplementacion futura podria romper.
ORDERS = [
{"id": 1, "zone": "local", "weight_kg": 0.5, "order_total": 20.0, "items_subtotal": 20.0},
{"id": 2, "zone": "national", "weight_kg": 1.0, "order_total": 30.0, "items_subtotal": 30.0},
{"id": 3, "zone": "international", "weight_kg": 3.0, "order_total": 80.0, "items_subtotal": 80.0},
{"id": 4, "zone": "local", "weight_kg": 1.0, "order_total": 60.0, "items_subtotal": 60.0},
{"id": 5, "zone": "national", "weight_kg": 4.0, "order_total": 55.0, "items_subtotal": 55.0},
]
# --- Prueba de transparencia: la abstraccion devuelve EXACTO lo que la funcion vieja. ---
calc = LegacyShipping()
print("Prueba de transparencia: funcion directa vs a traves de la abstraccion\n")
print(f"{'order':>6}{'zone':>16}{'directo':>10}{'via abstraccion':>18}{'igual?':>9}")
print("-" * 59)
all_equal = True
for o in ORDERS:
direct = legacy_shipping_fn(o)
through = calc.cost(o)
same = direct == through
all_equal = all_equal and same
print(f"{o['id']:>6}{o['zone']:>16}{direct:>10}{through:>18}{('SI' if same else 'NO'):>9}")
print("-" * 59)
print(f"\nTodas las respuestas identicas: {all_equal}")
print("\n Insertaste la abstraccion sin cambiar un solo resultado (cero riesgo),")
print(" y ahora existe el punto donde maniana el flag elegira legacy vs modern.")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
Prueba de transparencia: funcion directa vs a traves de la abstraccion
order zone directo via abstraccion 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
-----------------------------------------------------------
Todas las respuestas identicas: True
Insertaste la abstraccion sin cambiar un solo resultado (cero riesgo),
y ahora existe el punto donde maniana el flag elegira legacy vs modern.
Lee la columna igual?: SI en las cinco filas. Con envío pagado (local a 5.0, national a 10.0), con envío international que no se hace gratis pese a superar los 50.0 (fila 3: 29.0, no 0.0), y con los dos casos de envío gratis (filas 4 y 5, a 0.0), el resultado a través de la abstracción es idéntico al de la función directa. Y la línea de abajo lo confirma en una sola verificación: Todas las respuestas identicas: True. Eso es la transparencia: la abstracción no reimplementó nada, solo envolvió la lógica que ya existía y la expuso detrás de una interfaz.
Fíjate en la fila 3, la del borde tácito. international con order_total de 80.0 supera el umbral de envío gratis (50.0), pero no se hace gratis: la regla del legacy dice "gratis solo si NO es international". El resultado, 29.0 (base 25.0 + recargo de 4.0 por los 2 kg extra), sale idéntico por los dos caminos porque LegacyShipping copió esa regla sin quitarla. Guárdate esta fila: es precisamente el caso donde una reimplementación descuidada patinaría —olvidar "y no international"—, y es la discrepancia que atraparemos en la lección 5. Aquí, con la abstracción envolviendo el legacy tal cual, la rareza se preserva y la transparencia da True.
El punto de la lección está en la combinación de esas salidas: cambiaste la estructura del código —ahora todas las llamadas al envío pasan por una abstracción que no estaba— sin cambiar ni un resultado. En términos de despliegue, esto es oro: puedes insertar la abstracción, verificar que la prueba de transparencia da True, y saber que no rompiste nada. Insertar la abstracción es el paso de menor riesgo de toda la migración, y es el que habilita todos los demás.
Profundización: qué es un seam, y qué NO debe hacer la abstracción
El término seam viene de Michael Feathers, en Working Effectively with Legacy Code: un seam es un lugar donde puedes alterar el comportamiento de tu programa sin editar en ese lugar. Suena paradójico, pero es justo lo que acabas de crear. checkout_total llama a calc.cost(order). Para cambiar qué cálculo corre, no tienes que editar checkout_total: cambias qué objeto es calc. El punto donde checkout_total pide .cost() es el seam —la costura por donde metes la mano para conmutar la implementación—. En branch by abstraction, insertar la abstracción es, literalmente, crear el seam donde vas a conmutar.
Aquí está la estructura, antes y después de insertar la abstracción:
ANTES (sin abstraccion):
checkout ─────> legacy_shipping_fn(order) (llamada directa, sin punto de conmutacion)
carrito ─────> legacy_shipping_fn(order)
admin ─────> legacy_shipping_fn(order)
DESPUES (abstraccion insertada, del otro lado solo el legacy):
checkout ──┐
carrito ──┼──> ShippingCalculator.cost() ──> LegacyShipping (el seam: aqui se conmutara)
admin ──┘ (modern aun no existe)
La regla de oro de la abstracción en este paso es de disciplina: la abstracción no debe hacer nada más que exponer la operación. No valida, no transforma el resultado, no agrega lógica, no cachea, no "aprovecha para arreglar de paso" el formato. En cuanto la abstracción empieza a hacer cosas, deja de ser transparente —la prueba de transparencia daría False— y pierdes la propiedad que la hace segura. Una abstracción que solo delega se puede insertar sin miedo; una que además transforma es un cambio de comportamiento disfrazado de refactor, y ahí es donde los branch by abstraction se rompen. Toda la lógica nueva vive en ModernShipping (lección 3), nunca en la abstracción ni en el LegacyShipping que envuelve el viejo.
Y hay un segundo requisito, más sutil, que la lección de errores desarrolla: la abstracción no debe tener fugas. Es decir, su interfaz no debe exponer detalles internos del legacy que aten a los llamadores a él. Si ShippingCalculator.cost() devolviera, digamos, un objeto interno del legacy con su estructura particular, los llamadores dependerían de esa estructura, y cuando ModernShipping no la tuviera, se romperían. La abstracción debe hablar en términos neutros —recibe un order, devuelve un float— para que cualquier implementación detrás pueda cumplirla sin filtrar cómo lo hace por dentro.
Errores comunes
La abstracción con fugas que expone el legacy. Qué pasa: al diseñar la interfaz, el equipo la modela calcada a cómo funciona hoy el legacy —devuelve el objeto interno del cálculo viejo, o expone un método que solo tiene sentido en la implementación vieja—. Por qué pasa: es lo más rápido; la implementación que existe es la vieja, y modelar la interfaz "como ya es" ahorra pensar. Cómo detectarlo: cuando intentas escribir ModernShipping detrás de la misma abstracción, descubres que tienes que arrastrar detalles del legacy que no tienen sentido en la nueva —un campo interno, un formato heredado— solo para cumplir la interfaz. La abstracción tiene una fuga: los llamadores, a través de ella, siguen atados al legacy. Cómo corregirlo: diseña la interfaz en términos neutros y mínimos —lo que el negocio necesita ("dame el costo de este pedido"), no cómo lo calcula el viejo—. cost(order) -> float no expone nada del legacy: cualquier implementación puede cumplirla. Una buena abstracción es la que ModernShipping puede implementar sin heredar una sola rareza de LegacyShipping.
Aprovechar la abstracción para "mejorar de paso" el resultado. Qué pasa: al insertar la abstracción, el equipo ve una oportunidad —"ya que todo pasa por aquí, redondeemos distinto", "agreguemos este campo que siempre faltó"—. Por qué pasa: la abstracción es un punto tentador; toca todas las llamadas, parece el lugar perfecto para cambios transversales. Cómo detectarlo: la prueba de transparencia deja de dar True. Si el resultado vía abstracción difiere del de la función directa aunque sea en un decimal, la abstracción dejó de ser transparente. Cómo corregirlo: en este paso, cero cambios de comportamiento. La abstracción solo delega. Las mejoras al formato o los cálculos nuevos son trabajo de ModernShipping —ahí sí, porque la implementación nueva puede comportarse distinto y tú controlas con el flag qué porcentaje de llamadas la ve—. Mantén la transparencia como un test que corre cada vez que insertas o tocas la abstracción: si se pone en rojo, la abstracción se ensució.
Saltarse la prueba de transparencia "porque la abstracción solo delega". Qué pasa: el equipo asume que insertar una interfaz es trivialmente transparente y no lo verifica. Por qué pasa: "solo envuelve la función vieja, ¿qué puede salir mal?". Cómo detectarlo: diferencias que nadie notó —al copiar el cuerpo de la función al método, alguien "limpió" un round, o cambió un >= por un >, o reordenó una condición—. Estas diferencias son invisibles hasta que un caso borde las delata en producción. Cómo corregirlo: la prueba de transparencia no es opcional, es barata. Compara la función directa contra la abstracción para un conjunto de casos que cubra los bordes (peso exacto de 1 kg, envío gratis, el international que no se hace gratis) y verifica igualdad total. Es un test de una tarde que te ahorra un incidente. Que la abstracción "solo delegue" es justo lo que hay que probar, no asumir —sobre todo cuando copiaste lógica de un lado a otro—.
Ejercicios
Ejercicio 1 — La válvula transparente. En la analogía de la tubería, el plomero instala una válvula de paso antes del tramo que va a cambiar. (a) ¿Qué "prueba de transparencia" haría para asegurarse de que instalar la válvula no cambió el agua de la casa? (b) Da un ejemplo de algo que la válvula podría "hacer de paso" y que rompería la transparencia. (c) ¿Por qué esa mejora, aunque sea buena, no debe hacerla la válvula en este paso?
Ver solución
(a) Compararía, para los grifos de la casa, el agua antes y después de instalar la válvula: misma presión, mismo caudal, misma temperatura, misma calidad. Si abre la cocina y el otro baño y todo sale igual que antes —incluidos los casos raros, como abrir dos grifos a la vez o el chorro a máxima presión—, la válvula es transparente. Es exactamente la comparación función directa == vía abstracción del ejemplo, con los casos borde incluidos.
(b) La válvula podría "aprovechar para" instalar un filtro que siempre le pareció buena idea, o un reductor de presión, o un mezclador que cambia la temperatura. Cualquiera de esas cosas hace que el agua después-de-la-válvula difiera del agua directa: rompe la transparencia. En el código, el equivalente es que la abstracción redondee distinto, agregue un campo, o normalice el resultado.
(c) Porque en este paso el objetivo es instalar el punto de conmutación sin cambiar la experiencia de nadie —eso es justo lo que lo hace seguro de desplegar—. Las mejoras (el filtro, el formato nuevo) son legítimas, pero pertenecen a la tubería nueva (ModernShipping), donde puedes controlar con el flag a qué porcentaje de llamadas llegan. Si la válvula mejora el agua, toda la casa ve el cambio de golpe, sin gradualidad y sin poder volver atrás: es el big-bang que queríamos evitar, colado por la puerta de atrás.
Ejercicio 2 — Diagnostica la transparencia. Un equipo inserta la abstracción y corre la prueba de transparencia. Para cuatro de cinco pedidos da SI, pero para el pedido international (fila 3) da NO: la función directa devuelve 29.0 y la abstracción devuelve 0.0. (a) ¿La abstracción es transparente? (b) ¿Cuál es la causa más probable, dado lo que sabes de la regla tácita del legacy? (c) ¿Por qué este error es más peligroso que uno que fallara en todas las filas?
Ver solución
(a) No. Basta una fila en NO para que la abstracción no sea transparente. La transparencia es igualdad total en todos los casos, no "en la mayoría".
(b) La causa más probable es que, al copiar la lógica del legacy al método cost, alguien omitió la condición and order["zone"] != "international" del envío gratis. Sin ese trozo, el pedido international con total 80.0 supera el umbral de 50.0 y se hace gratis (0.0), cuando la regla del legacy dice que international nunca es gratis (29.0). Es exactamente la regla tácita que la fila 3 existe para proteger: el LegacyShipping de esta lección la copió bien, pero un descuido al transcribirla la perdería.
(c) Porque un error que falla en todas las filas se nota de inmediato —el sistema se rompe entero, alguien lo ve el primer día—. Un error que falla solo en un caso borde (el international con envío gratis potencial) pasa desapercibido: la mayoría de los pedidos siguen dando bien, y la abstracción parece transparente hasta que llega el pedido raro, quizás semanas después, y cobra de menos. Los errores de paridad más caros son siempre los de los bordes tácitos, precisamente porque se esconden. Por eso la prueba de transparencia debe cubrir los casos borde a propósito, no solo el camino feliz.
Ejercicio 3 — Diseña la interfaz sin fugas. Un compañero propone que ShippingCalculator tenga este método: cost_breakdown(order) -> LegacyCostRecord, donde LegacyCostRecord es la clase interna que el cálculo viejo usa para representar el desglose (con campos como raw_base, surcharge_cents, legacy_flags). (a) ¿Por qué esta interfaz tiene una fuga? (b) ¿Qué problema tendrás cuando escribas ModernShipping? (c) Propón una interfaz sin fugas que sirva tanto al legacy como al modern.
Ver solución
(a) Tiene una fuga porque expone LegacyCostRecord —una clase interna del legacy— en la interfaz pública. Los llamadores que usen cost_breakdown dependerán de esa clase y de sus campos (raw_base, surcharge_cents, legacy_flags). La abstracción, que debía ser neutra, está filtrando cómo calcula el viejo: los llamadores quedan atados al legacy a través de ella.
(b) Cuando escribas ModernShipping, tendrás que fabricar un LegacyCostRecord aunque tu implementación nueva no piense en esos términos —quizás modelas el desglose de otra forma, con otros nombres, en otra unidad—. Estarás obligado a arrastrar la estructura del viejo solo para cumplir la interfaz con fugas, contaminando la implementación nueva con las rarezas de la vieja. La abstracción, en vez de liberarte del legacy, te encadena a él.
(c) Una interfaz neutra y mínima: cost(order) -> float, como la del ejemplo (o, si de verdad se necesita el desglose, cost_breakdown(order) -> dict con claves neutras acordadas: {"base": float, "surcharge": float, "total": float}, no la clase interna del legacy). Lo clave es que la interfaz hable en términos del dominio (lo que el negocio necesita saber del costo), no de la implementación (cómo lo calcula el viejo por dentro). Así, LegacyShipping la cumple traduciendo su LegacyCostRecord a esos términos neutros, y ModernShipping la cumple con su propia estructura, sin que ninguno de los dos filtre sus internos a los llamadores. Una abstracción sin fugas es la que las dos implementaciones pueden cumplir sin compartir un solo detalle interno.
Resumen y siguiente paso
En esta lección hiciste el primer paso de branch by abstraction: insertar la capa de abstracción como un seam. Viste, con la válvula de paso que se instala antes de cambiar la tubería, que la abstracción es un punto de conmutación que decide qué implementación corre sin cambiar el agua de nadie. Y lo ejecutaste con la prueba de transparencia: comparaste, caso por caso, la función de envío directa contra la misma lógica vía abstracción —incluidos los casos borde, como el international que no se hace gratis— y verificaste Todas las respuestas identicas: True. Ese es el paso de menor riesgo de toda la migración: cambiaste la estructura del código sin cambiar un solo resultado, y a cambio obtuviste el seam donde todo lo demás será posible. Y aprendiste la disciplina que lo mantiene seguro: la abstracción solo delega —no transforma— y no tiene fugas —habla en términos neutros, no expone los internos del legacy—.
Antes de avanzar deberías poder: explicar qué es un seam y por qué insertar la abstracción crea uno; enunciar la regla de oro de la abstracción (solo delega, sin fugas) y por qué romperla rompe la transparencia; correr una prueba de transparencia que cubra los casos borde y saber por qué el borde tácito es el más peligroso; y diseñar una interfaz neutra que sirva tanto al legacy como al modern.
La lección 3 hace el segundo paso: construir la implementación moderna detrás de la abstracción. Ahora que la abstracción está puesta y es transparente, vas a levantar un ModernShipping con una estructura interna distinta —tarifas nombradas, reglas separadas— pero el mismo contrato que el LegacyShipping. Vas a ejecutar la coexistencia de los dos y a verificar que la nueva cumple el contrato que la abstracción espera. El legacy quedará intacto; el nuevo vivirá a su lado; y la abstracción —la que acabas de insertar— será el seam que mañana elija a cuál llamar.
Recursos
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004), cap. "Sensing and Separation" y el catálogo de seams — la fuente del concepto de seam que esta lección aplica: un punto donde alterar el comportamiento sin editar en ese punto. La referencia directa de por qué insertar la abstracción crea el lugar donde conmutar. En inglés.
- Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Fowler describe la abstraction layer como el primer movimiento del patrón: se inserta envolviendo la implementación existente, sin cambiar comportamiento. El paso de esta lección. En inglés.
- Paul Hammant, "branchbyabstraction.com" — branchbyabstraction.com. El paso a paso del patrón, empezando por introducir la abstracción sobre el código que ya existe. En inglés.
- Robert C. Martin, "The Dependency Inversion Principle" — el principio que sostiene por qué los llamadores deben depender de la abstracción (
ShippingCalculator) y no de la implementación concreta (LegacyShipping): la "D" de SOLID, que hace posible conmutar la implementación sin tocar a los llamadores. En inglés.