Módulo 3: Patrones para el comportamiento que varía
3. Las reglas de precio de Boletia como Strategy
Descripción
Al terminar esta lección vas a haber hecho, de principio a fin, el refactor más común de tu vida profesional: convertir un condicional grande en un conjunto de piezas intercambiables. No en abstracto, sino sobre el fragmento real de checkout/checkout.py que conociste en la lección 1, con las cuatro reglas de precio de Boletia.
Vas a salir con tres cosas. Primero, una secuencia de pasos pequeños y seguros que puedes repetir en cualquier condicional, donde después de cada paso el sistema sigue funcionando y las pruebas siguen pasando. Segundo, y esto es lo que hace distinta a esta lección de cualquier tutorial de Strategy: vas a atravesar el momento incómodo, ese en el que descubres que las cuatro reglas no necesitan los mismos datos y que el contrato bonito que imaginabas no les queda a todas. Y tercero, vas a cerrar con el recibo medido: cuántos archivos hay ahora, cuántos saltos de lectura, qué cuesta agregar el quinto tipo de boleto, y qué se perdió en el camino.
Esto importa por una razón de oficio. Escribir una Strategy desde cero, con el problema ya entendido, es fácil —lo hiciste en la lección 2—. Lo difícil, y lo que de verdad te van a pedir en un trabajo, es transformar código que ya está en producción, que gente usa, y que no puedes romper. Eso no es un ejercicio de diseño: es una operación quirúrgica en pasos, con una red de seguridad debajo.
Conexión con el módulo: la lección 1 te presentó este código y te enseñó a ver su eje de variación —el tipo de boleto—. La lección 2 te dio la anatomía de Strategy sobre un caso amable, los proveedores de pago, donde el contrato charge(order) salía natural. Esta lección aplica el mismo patrón al material central del módulo, donde el contrato no sale natural, y por eso enseña más. La lección 4 muestra un caso donde Strategy no es la respuesta correcta, y la lección 6 vuelve sobre este mismo código para preguntar si hacían falta las clases. La lección 8 te entrega el checkout completo y te pide juzgar cuáles de sus condicionales merecen este tratamiento.
Cuatro cajeros y un solo formulario
Imagina la caja de un supermercado grande. Hay cuatro formas distintas de calcular lo que paga alguien: el cliente común paga el precio de lista; el que trae tarjeta de socio tiene descuentos por producto; el empleado tiene un porcentaje sobre todo; y el del cupón aplica un descuento con condiciones y fecha de vencimiento.
Si el supermercado quisiera que un solo cajero supiera hacer las cuatro cosas de memoria, tendría un problema: cada regla nueva significa volver a capacitar a todos, y un error en la del cupón afecta a alguien que solo quería comprar leche. Lo que hacen en la práctica es distinto: la caja registra la compra y la regla que aplica se resuelve aparte, con su propio procedimiento y su propio manual. La caja no aprende reglas: sabe a quién preguntarle.
Hasta aquí es la lección 2 con otro decorado. Lo nuevo empieza ahora.
Supón que el supermercado decide crear un formulario único que sirva para los cuatro casos. ¿Qué campos lleva? El cliente común solo necesita el total. El socio necesita además su número de tarjeta y el detalle producto por producto. El empleado, su número de nómina. El del cupón necesita el código, y alguien tiene que verificar que no esté vencido ni usado, lo que exige consultar otro sistema.
Si pones todos los campos en la misma hoja, tienes doce casillas de las que cada caso llena tres. Si haces cuatro formularios distintos, no tienes un procedimiento común. Y si dejas fuera lo que no cabe, el caso del cupón queda cojo.
Ese es el problema real de esta lección, y no tiene una solución bonita: tiene tres soluciones con costos distintos. Lo vemos en la sección de profundización, después de haber ensuciado las manos.
Ejemplo trabajado: de un condicional de ochenta líneas a cuatro reglas
Este es el código con el que vamos a trabajar: el de la lección 1, ya crecido con lo que se le fue pegando encima —el cargo por servicio, el redondeo, los logs—.
# Archivo: checkout/checkout.py
# ADVERTENCIA: esta es la versión ANTES del refactor. No la copies como modelo.
SERVICE_FEE_RATE = 0.08 # cargo por servicio de Boletia
COURTESY_LIMIT_PER_EVENT = 50
def calculate_line_price(ticket, order, customer, now):
"""Devuelve cuánto se cobra por UN boleto dentro de una orden."""
if ticket.kind == "general":
price = ticket.base_price
# Compra grupal: a partir de 10 boletos, 5% de descuento.
if order.quantity >= 10:
price = price * 0.95
fee = price * SERVICE_FEE_RATE
total = price + fee
log.info("precio general ticket=%s total=%s", ticket.id, total)
return round(total, 2)
elif ticket.kind == "vip":
price = ticket.base_price * 1.35
# El VIP incluye lounge; se cobra como cargo fijo para que
# aparezca desglosado en la factura del organizador.
price = price + 150.0
# Los socios del club pagan 10% menos sobre el total del VIP.
if customer.is_member:
price = price * 0.90
fee = price * SERVICE_FEE_RATE
total = price + fee
log.info("precio vip ticket=%s total=%s", ticket.id, total)
return round(total, 2)
elif ticket.kind == "early_bird":
event = get_event(ticket.event_id)
cutoff = parse_date(event.early_bird_cutoff)
# Antes del corte, 30% de descuento. Después del corte el
# early-bird cuesta lo mismo que el general: así el organizador
# no tiene que despublicar boletos cuando pasa la fecha.
if now <= cutoff:
price = ticket.base_price * 0.70
else:
price = ticket.base_price
fee = price * SERVICE_FEE_RATE
total = price + fee
log.info("precio early_bird ticket=%s total=%s", ticket.id, total)
return round(total, 2)
elif ticket.kind == "courtesy":
# Las cortesías no se cobran, pero hay un tope por evento para
# que un organizador no regale el aforo entero.
issued = count_courtesies(ticket.event_id)
if issued >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(
f"El evento {ticket.event_id} ya emitió "
f"{COURTESY_LIMIT_PER_EVENT} cortesías"
)
# Sin cargo por servicio: una cortesía es cortesía.
log.info("precio courtesy ticket=%s total=0", ticket.id)
return 0.0
else:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
Antes de tocar una línea, tres observaciones que guían todo el refactor.
Hay duplicación real, y no es la que parece. Las líneas fee = price * SERVICE_FEE_RATE y total = price + fee aparecen tres veces idénticas. Pero fíjate en el detalle: cortesía no las tiene. Si extraes el cargo por servicio "porque está repetido" y lo aplicas a las cuatro, cambias el comportamiento y las cortesías empiezan a cobrar. La duplicación que ves no siempre es duplicación conceptual.
Una rama puede fallar y las otras no. Cortesía lanza TooManyCourtesies; las demás siempre devuelven un número. Cualquier contrato que escribamos tiene que admitir que "calcular un precio" a veces no termina en un precio.
Cada rama necesita datos distintos. Ya lo notaste en la lección 1, y ahora tiene consecuencias:
| Regla | Qué necesita de verdad |
|---|---|
| general | ticket.base_price, order.quantity |
| vip | ticket.base_price, customer.is_member |
| early_bird | ticket.base_price, now, y una consulta: get_event() |
| courtesy | ticket.event_id, y otra consulta: count_courtesies() |
Ninguna necesita las cuatro cosas. Dos hacen consultas externas. Guarda esta tabla: es el formulario del supermercado.
Paso 0: la red de seguridad.
Esto va primero y no es negociable. Antes de mover una línea de código que funciona, necesitas pruebas que capturen el comportamiento actual, incluidos sus defectos. Se llaman pruebas de caracterización: no dicen lo que el código debería hacer sino lo que hace, y su único trabajo es avisarte si lo cambiaste sin querer.
# Archivo: tests/test_pricing_characterization.py
# Estas pruebas capturan el comportamiento ACTUAL. Si alguna falla durante
# el refactor, es que cambiaste algo. Ninguna debería fallar hasta el final.
def test_general_without_group_discount():
ticket = make_ticket(kind="general", base_price=500.0)
# 500 + 8% de servicio = 540.00
assert calculate_line_price(ticket, make_order(quantity=2),
make_customer(), NOW) == 540.00
def test_general_with_group_discount():
ticket = make_ticket(kind="general", base_price=500.0)
# 500 * 0.95 = 475 ; 475 + 8% = 513.00
assert calculate_line_price(ticket, make_order(quantity=10),
make_customer(), NOW) == 513.00
def test_vip_member():
ticket = make_ticket(kind="vip", base_price=500.0)
# (500*1.35 + 150) * 0.90 = 742.50 ; + 8% = 801.90
assert calculate_line_price(ticket, make_order(),
make_customer(is_member=True), NOW) == 801.90
def test_early_bird_before_cutoff():
ticket = make_ticket(kind="early_bird", base_price=500.0, event_id=1)
# 500 * 0.70 = 350 ; + 8% = 378.00
assert calculate_line_price(ticket, make_order(), make_customer(),
now=BEFORE_CUTOFF) == 378.00
def test_courtesy_over_the_cap():
ticket = make_ticket(kind="courtesy", event_id=99) # ya tiene 50 emitidas
with pytest.raises(TooManyCourtesies):
calculate_line_price(ticket, make_order(), make_customer(), NOW)
Cinco pruebas —más la del caso feliz de cortesía, que se escribe igual—. No son elegantes ni pretenden serlo: son un arnés. Fíjate en que los comentarios dicen la aritmética esperada, porque dentro de tres pasos vas a necesitar saber de dónde salió cada número.
Si el código que vas a refactorizar no tiene pruebas y no puedes escribirlas, ese es el trabajo real y va antes que el patrón. Refactorizar sin red no es refactorizar: es reescribir con los dedos cruzados.
Paso 1: extrae cada rama a una función, sin cambiar nada más.
Movimiento puramente mecánico. Cada bloque elif se convierte en una función con nombre y el condicional queda llamándolas. Ninguna lógica cambia; ni siquiera arreglamos la duplicación todavía.
# Archivo: pricing/rules.py (nuevo)
def price_general(ticket, order, customer, now):
price = ticket.base_price
if order.quantity >= 10:
price = price * 0.95
fee = price * SERVICE_FEE_RATE
return round(price + fee, 2)
def price_vip(ticket, order, customer, now):
price = ticket.base_price * 1.35 + 150.0
if customer.is_member:
price = price * 0.90
fee = price * SERVICE_FEE_RATE
return round(price + fee, 2)
# price_early_bird y price_courtesy se extraen igual: el cuerpo del elif,
# tal cual estaba, con la misma firma. Sin cambiar una sola línea de lógica.
# Archivo: checkout/checkout.py
from pricing.rules import price_general, price_vip, price_early_bird, price_courtesy
def calculate_line_price(ticket, order, customer, now):
if ticket.kind == "general":
return price_general(ticket, order, customer, now)
elif ticket.kind == "vip":
return price_vip(ticket, order, customer, now)
elif ticket.kind == "early_bird":
return price_early_bird(ticket, order, customer, now)
elif ticket.kind == "courtesy":
return price_courtesy(ticket, order, customer, now)
else:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
Corre las pruebas. Todas pasan. Ese es el punto entero del paso 1: hiciste un cambio estructural grande sin arriesgar nada, porque mover código a una función es de las transformaciones más seguras que existen —tu editor probablemente la hace sola con "extraer función"—.
Dos cosas que notar. Los logs desaparecieron: los quité porque estorbaban la lectura, y en un refactor real no se hace eso. Quitar logs es un cambio de comportamiento —alguien puede estar leyéndolos en producción— y va en un commit aparte. Y las cuatro funciones tienen la misma firma aunque ninguna use los cuatro argumentos, porque el paso 1 no cambia firmas. Ese exceso de parámetros es el problema del formulario, y ahora está a la vista en vez de escondido.
Paso 2: mira las firmas y descubre el contrato.
Este es el paso que la gente se salta, y por eso escribe contratos malos. No inventes la interfaz: léela en lo que ya tienes. Ahora que las cuatro reglas son funciones separadas, ves de un vistazo que las cuatro devuelven un float, que ninguna necesita los cuatro argumentos, y —lo decisivo— que dos necesitan hablar con el mundo exterior (get_event, count_courtesies).
Esa última observación decide el diseño. price_early_bird y price_courtesy no son funciones puras: dependen de datos que hay que ir a buscar. Si las dejas ir a buscarlos ellas mismas, cada regla queda atada a la base de datos y probarlas exige montar datos reales o parchear módulos. Si en cambio les entregas lo que necesitan, se vuelven funciones que reciben números y devuelven números.
La decisión, entonces: el contexto se arma una vez, afuera, y se le pasa a la regla ya resuelto.
Paso 3: el objeto de contexto.
Aquí resolvemos el problema del formulario. En vez de cuatro parámetros sueltos que cada regla ignora a medias, definimos un objeto con lo que cualquier regla puede necesitar, armado por quien sí sabe consultar.
# Archivo: pricing/context.py
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PricingContext:
"""Todo lo que una regla de precio puede necesitar, ya resuelto.
Inmutable a propósito: una regla no debe poder modificar el contexto
y afectar a las siguientes.
"""
base_price: float
quantity: int # cuántos boletos lleva la orden
is_member: bool # ¿el comprador es socio del club?
now: datetime
early_bird_cutoff: datetime | None # ya consultado del evento
courtesies_issued: int # ya consultado del evento
Y quien lo arma:
# Archivo: pricing/context.py (continúa)
def build_context(ticket, order, customer, now) -> PricingContext:
"""Reúne en un solo lugar TODAS las consultas externas.
Las reglas no consultan nada: reciben datos. Así se prueban con números.
"""
event = get_event(ticket.event_id)
return PricingContext(
base_price=ticket.base_price,
quantity=order.quantity,
is_member=customer.is_member,
now=now,
early_bird_cutoff=parse_date(event.early_bird_cutoff),
courtesies_issued=count_courtesies(ticket.event_id),
)
Esto tiene un costo que hay que decir en voz alta, porque es el más discutible de la lección: ahora se consultan las dos cosas siempre, aunque el boleto sea general. ¿Es aceptable? La respuesta honesta es "casi siempre sí, hasta que se mida lo contrario". Dos lecturas por boleto en un checkout que ya hace una docena de operaciones no es donde está tu problema de rendimiento, y si algún día lo fuera, la salida es conocida: hacer los campos perezosos, sin tocar una línea de las reglas. Lo que no conviene es decidir al revés, complicando el diseño hoy por un problema que nadie midió. Eso es optimización prematura, la prima hermana de la abstracción prematura del módulo 2.
Paso 4: las reglas, ahora sobre el contexto.
# Archivo: pricing/rules.py
from typing import Protocol
from pricing.context import PricingContext
SERVICE_FEE_RATE = 0.08
COURTESY_LIMIT_PER_EVENT = 50
class PricingRule(Protocol):
"""El contrato: dado un contexto, cuánto se cobra por este boleto.
Puede lanzar PricingError si el boleto no se puede emitir.
"""
def price_for(self, context: PricingContext) -> float:
...
class GeneralPricing:
def price_for(self, context: PricingContext) -> float:
price = context.base_price
# Compra grupal: a partir de 10 boletos, 5% de descuento.
if context.quantity >= 10:
price = price * 0.95
return _with_service_fee(price)
class VipPricing:
def price_for(self, context: PricingContext) -> float:
# 35% sobre el precio base, más el cargo fijo del lounge.
price = context.base_price * 1.35 + 150.0
if context.is_member:
price = price * 0.90
return _with_service_fee(price)
class EarlyBirdPricing:
def price_for(self, context: PricingContext) -> float:
# Después del corte el early-bird cuesta como el general.
# Se compara con el corte del evento, que ya viene resuelto.
before_cutoff = (
context.early_bird_cutoff is not None
and context.now <= context.early_bird_cutoff
)
price = context.base_price * 0.70 if before_cutoff else context.base_price
return _with_service_fee(price)
class CourtesyPricing:
def price_for(self, context: PricingContext) -> float:
if context.courtesies_issued >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(
f"Ya se emitieron {COURTESY_LIMIT_PER_EVENT} cortesías para este evento"
)
# Sin cargo por servicio: una cortesía es cortesía.
return 0.0
def _with_service_fee(price: float) -> float:
"""El cargo por servicio de Boletia. Vive aquí y no en el contrato
porque NO se aplica a todas las reglas: cortesía no lo lleva.
"""
return round(price + price * SERVICE_FEE_RATE, 2)
Detente en _with_service_fee: es la decisión más fina del refactor. El cargo se repite en tres de las cuatro reglas, y la tentación es sacarlo y aplicarlo en el orquestador —"que las reglas devuelvan el precio y el sistema le sume el cargo"—. Sería más limpio, y estaría mal: cortesía no lleva cargo, así que el orquestador tendría que preguntar si esta regla lo lleva, que es un if por tipo de boleto reaparecido en otro lugar. El condicional que estamos eliminando, mudado a otra casa.
La solución es más modesta y más correcta: una función auxiliar que las reglas que lo llevan invocan explícitamente. La duplicación baja a una línea por regla y la decisión de llevarlo o no queda dentro de cada regla, que es donde el negocio la toma. Cuando dudes entre eliminar duplicación y mantener las decisiones dentro de quien las toma, gana lo segundo.
Paso 5: el punto de elección, y el checkout adelgaza.
# Archivo: pricing/calculator.py
from pricing.rules import (
GeneralPricing, VipPricing, EarlyBirdPricing, CourtesyPricing,
)
from pricing.context import build_context
RULES = {
"general": GeneralPricing(),
"vip": VipPricing(),
"early_bird": EarlyBirdPricing(),
"courtesy": CourtesyPricing(),
}
def calculate_line_price(ticket, order, customer, now) -> float:
rule = RULES.get(ticket.kind)
if rule is None:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
context = build_context(ticket, order, customer, now)
return rule.price_for(context)
Las reglas se instancian una sola vez, al importar el módulo. Podemos hacerlo porque no guardan estado: GeneralPricing() no recuerda nada entre llamadas, así que la misma instancia sirve para todas las órdenes. Guarda esa observación —"no guardan estado"—: es la que la lección 6 va a usar para preguntar si hacían falta las clases.
Y en checkout/checkout.py queda una sola línea: from pricing.calculator import calculate_line_price. El if por tipo de boleto salió del corazón del sistema.
Paso 6: ahora sí, las pruebas por regla.
Con el arnés de caracterización todavía en verde, agregas las pruebas que antes eran imposibles:
# Archivo: tests/test_pricing_rules.py
def ctx(**overrides):
"""Contexto por defecto; cada prueba cambia solo lo que le interesa."""
defaults = dict(
base_price=500.0, quantity=1, is_member=False,
now=datetime(2026, 6, 1), early_bird_cutoff=None, courtesies_issued=0,
)
return PricingContext(**{**defaults, **overrides})
def test_general_applies_group_discount_from_ten():
assert GeneralPricing().price_for(ctx(quantity=9)) == 540.00
assert GeneralPricing().price_for(ctx(quantity=10)) == 513.00
def test_vip_member_pays_less_than_non_member():
no_member = VipPricing().price_for(ctx(is_member=False))
member = VipPricing().price_for(ctx(is_member=True))
assert member < no_member
def test_early_bird_loses_discount_after_cutoff():
cutoff = datetime(2026, 5, 1)
before = EarlyBirdPricing().price_for(
ctx(now=datetime(2026, 4, 30), early_bird_cutoff=cutoff))
after = EarlyBirdPricing().price_for(
ctx(now=datetime(2026, 5, 2), early_bird_cutoff=cutoff))
assert before == 378.00
assert after == 540.00
def test_courtesy_raises_at_the_cap():
with pytest.raises(TooManyCourtesies):
CourtesyPricing().price_for(ctx(courtesies_issued=50))
Mira lo que cambió respecto a las pruebas del paso 0. No hay make_ticket, ni make_order, ni make_customer. No hay base de datos, ni parches, ni objetos falsos. Hay un diccionario de números y una llamada. Una prueba de la regla de early-bird ya no arrastra el checkout entero.
Y mira test_early_bird_loses_discount_after_cutoff. Con el código original era carísima de escribir —había que fabricar un evento con fecha de corte y controlar now—; ahora son cuatro líneas. Cuando una prueba se vuelve fácil de escribir, se escribe. Las pruebas que faltan en tu proyecto no faltan por descuido: faltan porque escribirlas costaba demasiado.
Qué esperar. Corramos el resultado con un caso de cada tipo:
ticket = Ticket(id=1, event_id=77, kind="general", base_price=500.0)
order = Order(id=8812, quantity=2, ...)
customer = Customer(id=5, is_member=False, ...)
print(calculate_line_price(ticket, order, customer, now=datetime(2026, 6, 1)))
# 540.0 ← 500 + 8% de cargo por servicio
ticket.kind = "vip"
print(calculate_line_price(ticket, order, customer, now=datetime(2026, 6, 1)))
# 891.0 ← (500*1.35 + 150) = 825 ; 825 + 8% = 891.00
ticket.kind = "courtesy"
print(calculate_line_price(ticket, order, customer, now=datetime(2026, 6, 1)))
# 0.0 ← sin cargo por servicio
Los números son exactamente los mismos que antes del refactor —por eso las pruebas de caracterización siguen en verde—. Ese es el criterio de éxito de un refactor, y conviene decirlo sin adornos: si el comportamiento cambió, no refactorizaste; hiciste otra cosa. Puede que esa otra cosa fuera buena, pero es un cambio funcional y va en otro commit.
Ahora la prueba real. Marketing pide un tipo nuevo: boleto de estudiante, 40% de descuento presentando credencial vigente. ¿Qué se toca?
pricing/rules.py ← se agrega class StudentPricing (o un archivo nuevo)
pricing/context.py ← un campo más: has_valid_student_id: bool
pricing/calculator.py ← una línea en el diccionario RULES
tests/test_pricing_rules.py ← las pruebas de la regla nueva
checkout.py no se abre. Las otras cuatro reglas no se abren. Y —esto es lo que más importa— ninguna prueba existente tiene que cambiar, porque ninguna regla existente cambió.
Compara con el punto de partida, donde el boleto de estudiante era un elif más dentro de la función que cobra, en el archivo donde nadie quiere equivocarse, y donde había que volver a probar el checkout completo por si acaso.
Fíjate también en la línea de context.py. Ese archivo sí se toca con cada regla que necesite un dato nuevo, y es el punto débil del diseño: es un lugar compartido que crece. Es un costo real, es honesto reconocerlo, y lo discutimos en la sección siguiente junto con las alternativas.
El contrato no sale gratis: tres formas de resolver el formulario
Volvamos al supermercado. Cada caso necesita datos distintos: ¿qué forma tiene el formulario común? En código hay tres respuestas, y conviene conocerlas todas porque vas a ver las tres en el mundo real.
Opción A: la unión de parámetros. El contrato recibe todo lo que cualquier implementación pueda necesitar.
def price_for(self, ticket, order, customer, now, event, courtesies_issued) -> float:
...
A favor: no hay tipos nuevos, es directo, se lee sin abrir otro archivo. En contra: cada implementación ignora la mitad de los argumentos y agregar un parámetro obliga a cambiar las cuatro implementaciones aunque a tres no les importe. Un cambio que conceptualmente afecta a una regla toca a todas. Cuándo sirve: con dos o tres implementaciones y una firma corta que no vaya a crecer. Es la opción más barata cuando el problema es chico, y no hay que despreciarla por eso.
Opción B: el objeto de contexto —la que usamos—. Un solo parámetro que agrupa todo.
def price_for(self, context: PricingContext) -> float:
...
A favor: la firma nunca cambia; agregar un dato es agregar un campo y las implementaciones que no lo usan ni se enteran. Y todas las consultas externas quedan reunidas en build_context en vez de repartidas por las reglas.
En contra: un tipo más que aprender, una superficie compartida que crece, y datos que se resuelven sin usarse.
Cuándo sirve: cuando las implementaciones necesitan conjuntos de datos distintos y esperas que el número crezca. Es la opción por defecto para este problema.
Opción C: inyectar en el constructor. Cada regla recibe lo suyo al construirse, y el método queda mínimo.
class EarlyBirdPricing:
def __init__(self, cutoff: datetime, clock):
self._cutoff = cutoff
self._clock = clock
def price_for(self, base_price: float) -> float:
...
A favor: cada regla declara exactamente lo que necesita, ni más ni menos. Es la más limpia y la que mejor se prueba. En contra: el punto de elección se complica, porque construir cada regla exige saber qué necesita cada una: ya no basta un diccionario de instancias creadas al importar. Eso es una Factory, y es el módulo 4. Cuándo sirve: cuando las reglas tienen configuración estable —una tasa, un umbral, un reloj— más que datos por transacción. Es común combinar C con B: configuración por constructor, datos de la transacción por contexto.
Aquí elegimos B por una razón concreta: los datos que las reglas de Boletia necesitan cambian por transacción, no por configuración. El corte de early-bird depende del evento que se está comprando; las cortesías emitidas cambian con cada compra. Meterlo en el constructor obligaría a reconstruir las reglas en cada llamada, que es justo lo que la opción C hace incómodo.
Y una advertencia sobre B, porque es su forma de envejecer mal: el contexto se vuelve un cajón de sastre. La señal de alarma es cuando build_context empieza a tener condicionales —"si el boleto es de cortesía, consulta también…"—. Si llegas ahí, el contexto dejó de ser un contrato común y se convirtió en el if original, disfrazado.
El recibo: qué se ganó y qué se pagó
El módulo 2 pide que toda abstracción presente su recibo. Aquí está el de este refactor, con números y no adjetivos.
Lo que se ganó.
| Antes | Después | |
|---|---|---|
| Agregar un tipo de boleto | Editar checkout.py, el archivo más delicado | Archivo nuevo + una línea de registro |
| Probar una regla sola | Construir ticket, order, customer y confiar en el if | Un diccionario de números y una llamada |
| Entender qué cobra un VIP | Leer 80 líneas y encontrar la rama | Abrir VipPricing: 6 líneas |
| Romper otra regla al tocar una | Alto: comparten función, variables y firma | Bajo: archivos distintos |
| Reglas que consultan la base | Dos, cada una por su cuenta | Cero: se consulta en build_context |
Hay una ganancia que no cabe en la tabla y que es la que más vale a largo plazo: quedó un lugar obvio donde va cada cosa. Cuando llegue la pregunta "¿dónde pongo la regla del boleto de temporada?", la respuesta es evidente. Un diseño bueno se nota menos en lo que permite que en las discusiones que ya no hay que tener.
Lo que se pagó.
- De un archivo a cuatro (
rules.py,context.py,calculator.py, más las pruebas nuevas). - Dos saltos de lectura. Para responder "¿cuánto cuesta un VIP?" ahora vas a
calculator.pyy luego arules.py. - Un concepto nuevo que explicar:
PricingContext, con quién lo arma y por qué existe. - Un punto compartido que crece:
context.pyse toca con cada regla que pida un dato nuevo. Es el eslabón débil. - Dos consultas que a veces sobran: el contexto resuelve el corte del evento y las cortesías aunque el boleto sea general.
- Se perdió la vista de conjunto. Antes, leer ochenta líneas seguidas mostraba las cuatro reglas una junto a otra; comparar VIP con general era mirar diez centímetros más abajo. Ahora hacen falta dos archivos abiertos. Si tu trabajo del día es auditar la política de precios completa, el condicional era mejor.
El veredicto, con sus condiciones. Aquí el refactor se gana su lugar: cuatro implementaciones reales con lógica de verdad distinta, en el archivo más delicado del sistema, con un eje que el negocio hace crecer un par de veces al año. Y la parte que exige el módulo 2 —cuándo no lo habría hecho—: con dos tipos de boleto, no; si los cuatro solo se diferenciaran en un porcentaje, tampoco, porque eso es una tabla de datos; y si el negocio llevara tres años sin agregar tipos, tampoco, porque un refactor solo devuelve cuando alguien vuelve a tocar el código. Si nadie va a volver, es trabajo bonito sin retorno, con el riesgo de romper algo de propina.
Errores comunes
Refactorizar sin red y descubrirlo tarde (de proceso). Qué pasa: alguien empieza directamente por el paso 4 —escribe las clases bonitas, borra el if—, ve que funciona en el caso feliz y da el trabajo por hecho. Semanas después alguien nota que los early-bird comprados después del corte siguen cobrando el descuento, porque en la traducción se perdió el else. Por qué pasa: las pruebas de caracterización se sienten como tiempo perdido porque prueban algo que ya funciona. Ese es exactamente el punto —no prueban el código, prueban tu refactor—, pero la sensación es difícil de resistir. Cómo detectarlo: si no puedes decir cuántas pruebas cubrían ese código antes de tocarlo, refactorizaste a ciegas. Cómo corregirlo: paso 0 siempre, con una regla que vale para todo tu trabajo futuro: si no hay pruebas, escribir las pruebas ES el primer commit del refactor, y va solo, sin ningún cambio estructural pegado.
Hacer todos los pasos en un solo movimiento (de proceso). Qué pasa: alguien entiende el destino y salta directo, tocando siete archivos a la vez. Al final algo falla, y como todo cambió al mismo tiempo no hay forma de saber cuál de los siete lo rompió. Por qué pasa: los pasos intermedios se ven tontos —el paso 1 deja el código peor, con las cuatro firmas infladas— y saltárselos parece eficiente. Cómo detectarlo: si tu refactor lleva más de una hora sin correr las pruebas, estás en modo salto. Cómo corregirlo: paso, pruebas, commit. Paso, pruebas, commit. El módulo 8 tiene una lección entera sobre esto: refactorizar no es saber a dónde llegar, es saber partir el camino en tramos donde nada se rompe.
Eliminar duplicación que no era duplicación (de diseño). Qué pasa: alguien ve fee = price * SERVICE_FEE_RATE tres veces y lo saca al orquestador. Las cortesías empiezan a cobrar cargo por servicio, y la solución de emergencia es un if en el orquestador preguntando si el tipo lleva cargo —el condicional que se estaba eliminando, dos archivos más allá—. Por qué pasa: "no te repitas" se enseña como regla absoluta, y tres líneas idénticas activan el reflejo antes que el razonamiento. Cómo detectarlo: pregunta si las tres copias cambiarían siempre juntas. Si es imaginable que una cambie sola —y aquí lo es— no son la misma decisión, solo se escriben igual hoy. Cómo corregirlo: la duplicación se juzga por el concepto, no por el texto. Cuando dudes, deja la repetición: duplicar dos veces es más barato que abstraer mal una vez, que es la lección 4 del módulo 2.
Ejercicios
Ejercicio 1 — Agrega el boleto de estudiante. Implementa StudentPricing: 40% de descuento sobre el precio base, con cargo por servicio, y solo si el comprador tiene una credencial de estudiante vigente registrada. Si no la tiene, se cobra como general. Escribe: el cambio en PricingContext, la clase, la línea del registro y dos pruebas. Después responde: ¿tuviste que abrir checkout.py?
Ver solución
# Archivo: pricing/context.py — un campo más
@dataclass(frozen=True)
class PricingContext:
...
has_valid_student_id: bool = False # con valor por defecto: las
# pruebas existentes no se rompen
# Archivo: pricing/rules.py
class StudentPricing:
def price_for(self, context: PricingContext) -> float:
# Sin credencial vigente no hay descuento: se cobra como general.
# La regla la decide el negocio, no el sistema de identidad.
if not context.has_valid_student_id:
return _with_service_fee(context.base_price)
return _with_service_fee(context.base_price * 0.60)
# Archivo: pricing/calculator.py
RULES = {
...
"student": StudentPricing(),
}
def test_student_with_valid_id_pays_less():
assert StudentPricing().price_for(ctx(has_valid_student_id=True)) == 324.00
# 500 * 0.60 = 300 ; 300 + 8% = 324.00
def test_student_without_id_pays_like_general():
without_id = StudentPricing().price_for(ctx(has_valid_student_id=False))
general = GeneralPricing().price_for(ctx(quantity=1))
assert without_id == general == 540.00
No abriste checkout.py. Tampoco tocaste ninguna otra regla ni ninguna prueba existente. Ese es el retorno concreto del refactor, y es medible: tres archivos tocados, cero código existente modificado.
Dos detalles que valen oro. El valor por defecto = False en el campo nuevo: sin él, todas las construcciones existentes de PricingContext —incluidas las de las pruebas— se romperían; con él, el cambio es aditivo. Es la diferencia entre un cambio que se revisa en dos minutos y uno que toca cuarenta archivos. Y la última prueba compara StudentPricing sin credencial contra GeneralPricing en vez de fijar un número: si mañana cambia el cargo por servicio, sigue siendo válida porque compara comportamientos. Las pruebas que fijan números mágicos se rompen con cada cambio de configuración; las que fijan relaciones sobreviven.
Ejercicio 2 — Diagnostica un contexto que envejeció mal. Un año después, PricingContext se ve así y build_context tiene condicionales. Di qué pasó, por qué es un problema y qué dos salidas hay.
@dataclass(frozen=True)
class PricingContext:
base_price: float
quantity: int
is_member: bool
now: datetime
early_bird_cutoff: datetime | None
courtesies_issued: int
has_valid_student_id: bool
season_pass_events: list[int]
corporate_agreement_rate: float | None
promo_code: str | None
promo_code_uses: int
is_reseller: bool
reseller_margin: float # …y cuatro campos más
def build_context(ticket, order, customer, now):
# ...
if ticket.kind == "season_pass":
season_events = fetch_season_events(ticket.id) # consulta cara
else:
season_events = []
if customer.is_corporate:
rate = fetch_corporate_rate(customer.company_id) # otra consulta cara
else:
rate = None
# ...
Ver solución
Qué pasó: el contexto se convirtió en un cajón de sastre —quince campos, de los que cada regla usa dos o tres— y build_context tiene ahora condicionales por tipo de boleto, que es exactamente el if que el refactor eliminó, mudado a otro archivo. Formalmente hay un patrón; en la práctica el eje de variación volvió a aparecer, y ahora vive en el lugar que se suponía neutral.
Por qué es un problema: son tres. Acoplamiento entre reglas que no se conocen: agregar un campo para el revendedor obliga a tocar el archivo del que dependen las nueve reglas, así que un cambio local se volvió global. Costo de construcción: cada cálculo dispara consultas que la mayoría de las veces no se usan; empezaron siendo dos y ahora son seis, dos de ellas caras. Y el peor: la señal se perdió. El if ticket.kind en build_context significa que hay lógica por tipo de boleto fuera de las reglas, así que quien lea solo las reglas está viendo la mitad de la historia —el escenario que produce bugs largos de encontrar—.
Salida 1 — partir el contexto por familias. No hay un contexto sino varios: BaseContext con lo común, más SeasonContext, CorporateContext. Cada regla declara cuál pide. Baja el acoplamiento y elimina las consultas inútiles, a costa de complicar la construcción.
Salida 2 — mover a inyección por constructor (la opción C). Cada regla recibe lo que necesita al construirse, y el contexto se reduce a los tres o cuatro datos verdaderamente comunes. Exige una Factory de verdad —el módulo 4— pero escala mejor.
Y una tercera, la que casi nunca se considera: revisar si esas quince reglas siguen siendo quince. A veces el contexto se hincha porque el negocio agregó excepciones que en realidad son la misma regla con parámetros distintos. La respuesta ahí no es más estructura: es menos. Refactorizar hacia afuera de un patrón es tan legítimo como hacia adentro, y el módulo 7 le dedica una lección.
Por qué funciona: un patrón bien aplicado también envejece, y la señal de alarma no es estética sino específica —condicionales por el mismo eje reapareciendo en el lugar neutral—. Reconocerla a tiempo vale más que haber aplicado bien el patrón el primer día.
Ejercicio 3 — Escribe el recibo del refactor. Redacta la descripción del PR de este refactor, como si tuvieras que defenderlo ante alguien que no está convencido. Máximo diez líneas. Tiene que incluir: qué problema resuelve con un ejemplo concreto, qué cuesta, cómo garantizaste que no cambió el comportamiento, y bajo qué condición no lo habrías hecho.
Ver solución
Una respuesta completa se parece a esto:
Extrae las reglas de precio de
checkout.pyapricing/.Problema. Cada tipo de boleto nuevo obligaba a editar
calculate_line_pricedentro decheckout.py, el archivo más delicado del sistema; en el último año se tocó cuatro veces por esa razón. Además, probar una regla exigía construir una orden completa, y por eso el corte de early-bird no tenía prueba.Qué hace. Cuatro clases
PricingRuleintercambiables, unPricingContextque reúne las consultas externas, y un diccionario de selección enpricing/calculator.py. Agregar un tipo pasa a ser un archivo nuevo más una línea, sin abrircheckout.py.Qué cuesta. Tres archivos donde había una función y dos saltos de lectura.
PricingContextes un punto compartido que va a crecer: si empieza a tener condicionales por tipo de boleto, hay que partirlo.Garantía. Pruebas de caracterización escritas antes de tocar nada, en verde en cada paso. El comportamiento —incluidos los redondeos— es idéntico. Los
log.infose quitaron en un commit aparte.Cuándo no lo habría hecho. Con dos tipos de boleto, o si la diferencia entre ellos fuera solo un porcentaje —eso es una tabla de datos, no comportamiento—, o si el negocio no agregara tipos nuevos.
Cinco secciones cortas. Fíjate en lo que no dice: no dice "código más limpio", no dice "mejores prácticas", no dice "aplicamos el patrón Strategy" como si el nombre fuera el argumento. El nombre aparece de pasada, porque el nombre no justifica nada: justifica el problema concreto, medido en veces que se tocó el archivo. La sección de garantía es la que convence a quien revisa —un refactor sin ella es una petición de fe—. Y la última es la que te distingue: cualquiera puede vender su propio cambio, pero muy poca gente escribe en su PR la condición bajo la cual no lo habría hecho. Esa frase demuestra que hubo una decisión y no un reflejo, y es exactamente lo que el proyecto de la lección 8 evalúa.
Por qué funciona: en tu trabajo real, el refactor no termina cuando el código funciona. Termina cuando alguien más lo aprueba. Escribir el recibo es parte del oficio, no un trámite posterior.
Resumen y siguiente paso
En esta lección hiciste el refactor completo: de un condicional de ochenta líneas en el corazón del checkout a cuatro reglas PricingRule intercambiables que se prueban solas.
Lo hiciste en pasos pequeños y seguros, y ese proceso vale más que el resultado: las pruebas de caracterización que capturan el comportamiento actual con todo y defectos; extraer cada rama a una función sin cambiar firmas; descubrir el contrato leyendo las firmas en vez de inventarlo; el PricingContext que reúne las consultas externas en un solo lugar; las reglas sobre el contexto; el punto de elección; y las pruebas por regla que antes eran imposibles de escribir.
Atravesaste el momento incómodo —que las cuatro reglas no necesitan los mismos datos— y viste las tres salidas con sus costos: la unión de parámetros, el objeto de contexto y la inyección por constructor. Elegiste la segunda con una razón concreta, y conociste su forma de envejecer mal. Y viste dos decisiones finas que separan un refactor pensado de uno mecánico: _with_service_fee como función auxiliar y no como paso del contrato, porque cortesía no lo lleva; y la duplicación de tres líneas que no era duplicación conceptual.
Cerraste con el recibo medido: lo que se ganó —agregar un tipo sin abrir checkout.py, probar una regla con un diccionario de números, un lugar obvio para cada cosa— y lo que se pagó —tres archivos, dos saltos, un concepto nuevo, un punto compartido que crece, y la vista de conjunto que se perdió—.
Antes de avanzar deberías poder: enumerar los pasos del refactor y decir por qué el orden importa; explicar por qué el contrato se descubre y no se inventa; y escribir el recibo de un refactor con su condición de "no lo habría hecho si…".
Ahora bien, Strategy no es la respuesta a todo. En Boletia hay un rincón —reports/— donde tres exportadores hacen lo mismo, en el mismo orden, y solo cambia el paso del formato. Si aplicas Strategy ahí, cada implementación va a repetir el esqueleto completo. Cuando lo que varía no es el algoritmo entero sino algunos pasos dentro de un orden fijo, hay otro patrón que encaja mejor. La lección 4 lo presenta, lo separa de Strategy en una frase, y dice de frente cuál es su riesgo —porque su mecanismo es la herencia, y la herencia tiene letra chica—.
Recursos
- Martin Fowler — Replace Conditional with Polymorphism — la ficha del refactor que acabas de hacer, con sus pasos mecánicos.
- Michael Feathers — Working Effectively with Legacy Code — el libro donde nacen las pruebas de caracterización del paso 0. Si tu trabajo diario es tocar código que ya existe, es más útil que cualquier catálogo de patrones.
- Refactoring Guru — Introduce Parameter Object — la ficha del movimiento que produjo
PricingContext, con sus contraindicaciones. - Python docs — dataclasses — la referencia de
@dataclass, incluido elfrozen=Trueque hace inmutable al contexto.