Módulo 8: Refactorizar con criterio (capstone)
3. Detectar el patrón que quiere emerger
Descripción
Al terminar esta lección vas a saber reconocer, con señales concretas y no con intuición, que un código está pidiendo estructura. Vas a tener cinco señales con su anatomía —qué es cada una, cómo se ve, qué te está diciendo y qué la distingue de un falso positivo—, y la prueba que las une: la prueba del eje. Y vas a aprender la parte que separa a quien aplica patrones de quien los deja emerger: un procedimiento de cuatro tiempos en el que el nombre del patrón se elige al final, cuando ya no hay nada que elegir porque el código ya te lo dijo.
Esto importa porque la dirección "agregar estructura" es donde más daño hace el entusiasmo. Alguien reconoce un if de cuatro ramas, dice "Strategy", y escribe una interfaz, cuatro clases y una fábrica. A veces acierta. Cuando falla, falla de una manera cara y difícil de revertir: las cuatro ramas no eran cuatro comportamientos sino cuatro pasos de un mismo cálculo, o eran cuatro casos que van a desaparecer cuando se limpien los datos, o eran del mismo eje pero el contrato quedó mal porque se diseñó mirando dos de las cuatro. El módulo 2 te dio el freno —cada abstracción se paga—. Esta lección te da la precisión: no basta con saber que aquí hace falta estructura, hay que acertar en cuál y en qué eje.
La diferencia entre imponer y dejar emerger se puede decir en una frase, y conviene tenerla a mano toda la lección: imponer es partir del patrón y buscarle dónde encajar; dejar emerger es partir del código, hacerle transformaciones mecánicas y seguras, y descubrir que lo que queda ya tiene forma de algo con nombre. El resultado se ve parecido en los casos fáciles. En los difíciles —que son la mayoría— no se parece en nada.
Conexión con el módulo: la lección 2 te dio permiso para tocar, porque te enseñó a averiguar por qué el código está como está; esta lección usa ese permiso para la primera de las dos direcciones. La lección 4 hace la contraria: reconocer la estructura que sobra. Las dos comparten la misma prueba —¿la estructura corresponde a la variación?— y por eso conviene estudiarlas juntas. La lección 5 te da la disciplina para ejecutar lo que aquí diagnostiques, en pasos que nunca dejan el sistema roto; de hecho, el procedimiento de cuatro tiempos de esta lección es también la primera mitad del refactor de la lección 5. La lección 6 te enseña a defenderlo, y la unidad de medida que vas a usar allá —cuántos archivos hay que tocar para agregar uno— sale de la señal número dos de esta lección. Y la 7 decide si vale la pena.
El camino que la gente pisa en el pasto
Vas caminando por un parque y ves esto: hay una banqueta de concreto que rodea el jardín en ángulo recto, y hay un sendero de tierra pelada que lo cruza en diagonal, de esquina a esquina. Nadie lo construyó. Lo hicieron miles de pisadas, una por una, cada una eligiendo el camino corto sin pensar en las demás.
En arquitectura y diseño urbano esos senderos tienen nombre —caminos del deseo— y una regla asociada que es exactamente la de esta lección: cuando los arquitectos con oficio diseñan un campus, muchas veces no ponen las banquetas de entrada. Ponen el pasto, dejan pasar un año, miran dónde lo pisó la gente, y pavimentan encima de las huellas. El resultado es un campus donde nadie corta por el pasto, porque las banquetas están donde la gente ya caminaba.
Compara las dos maneras de trabajar. El arquitecto que impone dibuja las banquetas en el plano, con simetría y ángulos rectos, antes de que exista una sola pisada; su campus se ve bien en el plano y la mitad de las veces termina con senderos de tierra cruzándolo en diagonal y letreros de "no pise el pasto" que nadie respeta. El arquitecto que deja emerger espera las huellas y las pavimenta: su plano final es menos elegante y funciona mejor, porque el trazado lo decidió el uso real y no la simetría.
En código pasa lo mismo, con dos diferencias importantes que conviene tener claras.
La primera: las huellas ya están ahí. No tienes que esperar un año. El historial de tu repositorio es literalmente el registro de por dónde ha pisado la gente durante años: qué archivos se tocan juntos, qué cambio obligó a modificar cuatro lugares, cuántas veces se agregó un caso al mismo condicional. Todo el trabajo de esta lección consiste en aprender a ver las huellas en un código que a primera vista solo se ve feo.
La segunda: pavimentar cuesta. Un sendero pavimentado en el lugar equivocado es peor que el pasto, porque hay que romperlo para moverlo. Por eso la regla no es "donde veas una huella, pavimenta": es "donde la huella esté marcada, sea profunda y siga creciendo, pavimenta". Cuántas pisadas hacen falta ya lo sabes del módulo 2 —la regla de tres— y esta lección te enseña a distinguir una huella de una mancha en el pasto.
Las cinco señales de que el código pide estructura
Cada señal tiene la misma anatomía: qué es, cómo se ve, qué te está diciendo y —lo más importante— cuál es su gemela falsa, el caso que se ve igual y no significa lo mismo.
Señal 1 — El condicional que crece por el mismo eje
Qué es. Un if/elif que ha ganado ramas con el tiempo, y todas las ramas nuevas responden a la misma pregunta: "¿de qué tipo es esto?".
Cómo se ve.
if ticket.kind == "general": ...
elif ticket.kind == "vip": ...
elif ticket.kind == "early_bird":...
elif ticket.kind == "courtesy": ...
Qué te está diciendo. Que existe un concepto sin nombre. En el código de arriba, el concepto es "regla de precio de un tipo de boleto": existe de verdad en el negocio, tiene cuatro variantes, y en el código no tiene ni nombre ni casa. Cuando un concepto del negocio no tiene representación en el código, aparece disuelto en condicionales.
Su gemela falsa. Un condicional que no crece y que no responde a "de qué tipo es esto", sino a una condición del momento:
if now <= cutoff: # ← esto NO es un eje
price = base_price * 0.70
else:
price = base_price
Ese if vive dentro de una rama y es parte del cálculo del early-bird. No va a ganar ramas nunca: una fecha o pasó o no pasó. Convertirlo en dos clases sería ceremonia pura. La diferencia mecánica: el eje se pregunta por una categoría —un valor de un conjunto que puede crecer—; la gemela falsa se pregunta por una condición binaria del contexto.
Señal 2 — Agregar un caso obliga a tocar varios archivos
Qué es. El mismo conocimiento —"qué proveedores de pago existen"— escrito en varios lugares, cada uno con su forma.
Cómo se ve. No se ve en un archivo: se ve en el diff de un cambio pasado. Busca el commit donde se agregó el proveedor más reciente y cuenta los archivos.
git show <commit-que-agrego-mercadopago> --stat
boletia/checkout/checkout.py | 12 ++++++
boletia/admin/refunds.py | 9 +++++
boletia/reports/reconciliation.py | 7 +++++
boletia/api/routes.py | 5 ++++
4 files changed, 33 insertions(+)
Qué te está diciendo. Esto es lo que en el módulo 7 llamamos shotgun surgery: un cambio conceptualmente único que se dispersa como perdigones. Y te está diciendo algo más preciso que "está feo": te está dando la unidad de medida con la que vas a justificar el trabajo en la lección 6. Antes: cuatro archivos. Después: uno. Esa frase gana discusiones.
Su gemela falsa. Que un cambio toque cuatro archivos porque de verdad son cuatro cosas distintas. Agregar un proveedor toca el cobro, la devolución, la conciliación y la validación: eso son cuatro usos legítimos de un mismo concepto. La señal no es "toca cuatro archivos", es "los cuatro archivos repiten la misma lista". La pregunta de control: si agrego el caso nuevo y me olvido de uno de los cuatro, ¿el sistema queda inconsistente en silencio? Si la respuesta es sí, es la señal. Si el olvido rompe ruidosamente, es mucho menos grave.
Señal 3 — La duplicación con variación
Qué es. Tres o cuatro bloques que hacen lo mismo con una diferencia pequeña en el medio.
Cómo se ve. En reports/ de Boletia, los tres exportadores:
def export_csv(event_id):
rows = load_attendees(event_id) # igual
rows = sort_by_name(rows) # igual
content = to_csv(rows) # ← lo único distinto
return write_file(content, ".csv") # igual
def export_pdf(event_id):
rows = load_attendees(event_id) # igual
rows = sort_by_name(rows) # igual
content = to_pdf(rows) # ← lo único distinto
return write_file(content, ".pdf") # igual
Qué te está diciendo. Que hay un esqueleto común y un paso que varía. Eso es un patrón con nombre —Template Method, si va con herencia; una función que recibe el paso variable, si va con funciones— pero el nombre viene después. Lo que la señal te dice ahora es que el conocimiento "cómo se exporta un reporte" está escrito tres veces, y que un cambio en el orden de los pasos hay que hacerlo tres veces.
Su gemela falsa, y es la más peligrosa de la lección: la duplicación que solo se parece. Vuelve al pricing de Boletia:
fee = price * SERVICE_FEE_RATE
total = price + fee
Esas dos líneas aparecen idénticas en tres de las cuatro reglas de precio. Y 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 regla que salva: antes de unificar dos bloques parecidos, pregunta si van a cambiar juntos. Si mañana el cargo por servicio de los VIP sube al 10% y el de los generales no, esos bloques nunca fueron el mismo bloque: se parecían por casualidad.
Señal 4 — El módulo que cambia por tres razones distintas
Qué es. Un archivo que aparece en commits que no tienen nada que ver entre sí.
Cómo se ve. El historial te lo dice de un vistazo:
git log --oneline --since="1 year ago" -- boletia/checkout/checkout.py
c1a9e02 Agrega proveedor de pago MercadoPago
8f3b117 Nuevo tipo de boleto: cortesía con tope por evento
2d77a90 Avisa al organizador por correo cuando se vende un boleto
b04c6e1 Descuento de compra grupal a partir de 10 boletos
9ae2f30 Registra evento en analítica al pagar
Cinco commits, cinco motivos completamente distintos: cobrar, poner precio, notificar, promocionar, medir. Ese archivo tiene cinco razones para cambiar.
Qué te está diciendo. Que hay varias responsabilidades conviviendo, y que cada persona que toque una de ellas va a tener que abrir el archivo más delicado del sistema. La consecuencia práctica no es filosófica: es que los conflictos al integrar cambios se concentran ahí, y que cualquier error en un cambio de analítica puede tumbar una venta.
Su gemela falsa. El orquestador. Una función cuyo trabajo legítimo es coordinar pasos —checkout llama a precio, cobro, asientos y avisos— va a aparecer en commits de todos esos temas, y eso está bien: coordinar es su responsabilidad. La distinción es fina y es la clave de todo el refactor: el problema no es que checkout llame a cinco cosas, es que sepa por dentro cómo funciona cada una. Un orquestador sano tiene cinco llamadas y ninguna rama; el checkout de Boletia tiene cinco llamadas y dos cadenas de condicionales metidas entre medio.
Señal 5 — El parámetro que enciende y apaga comportamiento
Qué es. Una función que recibe banderas booleanas o un mode de texto, y que por dentro se parte en dos o tres funciones distintas.
Cómo se ve.
def export_report(event_id, fmt, include_totals=False,
anonymize=False, split_by_day=False):
...
Qué te está diciendo. Que hay varias operaciones distintas disfrazadas de una sola con parámetros. La señal fuerte es cuando los parámetros no se combinan: si anonymize=True solo tiene sentido con fmt="csv", no son opciones, son casos.
Su gemela falsa. Los parámetros que sí son opciones ortogonales de verdad —include_totals puede ir con cualquier formato—. Esos no piden estructura, piden como mucho un objeto de opciones. La pregunta de control: ¿hay combinaciones que no tienen sentido o que están prohibidas? Si sí, tienes casos disfrazados de opciones.
La prueba que une las cinco: la prueba del eje
Todas las señales apuntan a lo mismo, y hay una sola prueba que confirma que encontraste el eje correcto. Se hace en tres preguntas:
- ¿Todas las ramas contestan la misma pregunta? El eje de
Ticket.kindcontesta "¿cuánto cuesta este boleto?". Si una rama contestara "¿hay que enviar factura?", no es el mismo eje. - ¿Las ramas cambian por separado? Si marketing puede cambiar la regla de VIP sin tocar la de general, son cosas distintas. Si siempre que cambia una cambian todas, no son cuatro cosas: son una con parámetros.
- ¿La lista puede crecer? Y crecer de verdad, con evidencia: dos tipos de boleto agregados en dos años es evidencia; "el año que viene quizá vendamos abonos" es una predicción, y ya sabes del módulo 2 lo que valen.
Si las tres son "sí", encontraste un eje y el trabajo tiene sentido. Si una es "no", detente: es muy probable que estés a punto de pavimentar donde nadie camina.
Ejemplo trabajado: dejar que el patrón emerja del checkout
Vamos a hacerlo sobre el bloque de precios del checkout de Boletia, y vamos a hacerlo sin decir el nombre de ningún patrón hasta el final. Esa restricción no es un juego: es el método.
Este es el punto de partida, dentro de la función checkout:
# Archivo: checkout/checkout.py (fragmento, dentro de checkout())
subtotal = 0.0
for ticket in tickets:
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
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "vip":
# 35% sobre el base, más el cargo fijo del lounge.
price = ticket.base_price * 1.35 + 150.0
if customer.is_member:
price = price * 0.90
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "early_bird":
cutoff = parse_date(event.early_bird_cutoff)
# Después del corte, el early-bird cuesta como el general.
price = ticket.base_price * 0.70 if now <= cutoff else ticket.base_price
subtotal += round(price + price * SERVICE_FEE_RATE, 2)
elif ticket.kind == "courtesy":
# Tope por evento: un organizador no puede regalar el aforo entero.
if count_courtesies(ticket.event_id) >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(ticket.event_id)
subtotal += 0.0 # sin cargo por servicio: una cortesía es cortesía
else:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
Tiempo 0 — Confirma el eje y pon la red.
La prueba del eje, contestada con datos: las cuatro ramas contestan "¿cuánto cuesta este boleto?" (sí); marketing cambió la regla de VIP en marzo y la de early-bird en septiembre, por separado (sí); se agregó cortesía hace catorce meses y hay una conversación abierta sobre abonos de temporada (sí, con evidencia). Es un eje.
Y la red, que ya sabes de la lección 2 que no es opcional: pruebas de caracterización que fijan el comportamiento actual, incluidos sus defectos.
# Archivo: tests/test_pricing_characterization.py
# Fijan 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():
# 500 + 8% de servicio = 540.00
assert price_of(kind="general", base=500.0, quantity=2) == 540.00
def test_general_with_group_discount():
# 500 * 0.95 = 475 ; 475 + 8% = 513.00
assert price_of(kind="general", base=500.0, quantity=10) == 513.00
def test_vip_member_pays_less():
# (500*1.35 + 150) * 0.90 = 742.50 ; + 8% = 801.90
assert price_of(kind="vip", base=500.0, is_member=True) == 801.90
def test_early_bird_loses_discount_after_cutoff():
assert price_of(kind="early_bird", base=500.0, when=BEFORE_CUTOFF) == 378.00
assert price_of(kind="early_bird", base=500.0, when=AFTER_CUTOFF) == 540.00
def test_courtesy_is_free_and_capped():
assert price_of(kind="courtesy", base=500.0) == 0.0
with pytest.raises(TooManyCourtesies):
price_of(kind="courtesy", base=500.0, issued=COURTESY_LIMIT_PER_EVENT)
Fíjate en que los comentarios traen la aritmética. Dentro de tres pasos vas a necesitar saber de dónde salió cada número.
Tiempo 1 — Extrae, sin diseñar nada.
Cada rama se convierte en una función con nombre. Movimiento puramente mecánico: tu editor lo hace solo con "extraer función". No cambias lógica, no arreglas la duplicación, no tocas las firmas.
# Archivo: pricing/rules.py (nuevo)
def price_general(ticket, order, customer, now, event):
price = ticket.base_price
if order.quantity >= 10:
price = price * 0.95
return round(price + price * SERVICE_FEE_RATE, 2)
def price_vip(ticket, order, customer, now, event):
price = ticket.base_price * 1.35 + 150.0
if customer.is_member:
price = price * 0.90
return round(price + price * SERVICE_FEE_RATE, 2)
def price_early_bird(ticket, order, customer, now, event):
cutoff = parse_date(event.early_bird_cutoff)
price = ticket.base_price * 0.70 if now <= cutoff else ticket.base_price
return round(price + price * SERVICE_FEE_RATE, 2)
def price_courtesy(ticket, order, customer, now, event):
if count_courtesies(ticket.event_id) >= COURTESY_LIMIT_PER_EVENT:
raise TooManyCourtesies(ticket.event_id)
return 0.0
Corre las pruebas. Verdes. Este paso no tiene ningún riesgo y ya produjo el 60% del valor: el corazón del checkout adelgazó, y las cuatro reglas de negocio dejaron de estar disueltas.
Tiempo 2 — Mira las firmas. Aquí es donde el patrón empieza a hablar.
Este es el paso que casi todo el mundo se salta, y por eso escribe contratos malos. No inventes la interfaz: léela en lo que ya tienes. Pon las cuatro firmas una debajo de otra y mira qué usa de verdad cada función:
| Función | Usa de verdad | Consulta afuera |
|---|---|---|
price_general | base_price, quantity | no |
price_vip | base_price, is_member | no |
price_early_bird | base_price, now, corte del evento | sí (event) |
price_courtesy | event_id | sí (count_courtesies) |
Tres hechos saltan solos, y los tres deciden el diseño:
- Ninguna usa los cinco parámetros. La firma común que quedó del paso 1 es un formulario donde cada quien llena tres casillas y deja dos en blanco.
- Dos consultan al mundo exterior. Eso significa que 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.
- Una puede fallar. Cortesía lanza una excepción; las otras siempre devuelven un número. Cualquier contrato que escribas tiene que admitir que "calcular un precio" a veces no termina en un precio.
Ninguno de esos tres hechos se veía en el código original. Aparecieron porque extrajiste primero. Eso es, literalmente, lo que significa "dejar que el patrón emerja": las decisiones de diseño se toman sobre evidencia que el propio refactor produjo.
Tiempo 3 — Escribe el contrato que las firmas te dictaron.
De los tres hechos salen tres decisiones, cada una con su razón:
# 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
is_member: bool
now: datetime
early_bird_cutoff: datetime | None # ya consultado del evento
courtesies_issued: int # ya consultado del evento
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),
)
Y 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 TooManyCourtesies 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
if context.quantity >= 10:
price = price * 0.95
return _with_service_fee(price)
class VipPricing:
def price_for(self, context: PricingContext) -> float:
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:
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 señal 3 en acción. La tentación es sacar el cargo al orquestador, y estaría mal —cortesía no lo lleva, así que el orquestador tendría que preguntar si esta regla lo lleva, y eso es el if por tipo de boleto reaparecido en otra casa—. Cuando dudes entre eliminar duplicación y mantener las decisiones dentro de quien las toma, gana lo segundo.
Tiempo 4 — El punto de elección, y recién ahora el nombre.
# 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}")
return rule.price_for(build_context(ticket, order, customer, now))
Y en el checkout queda una línea: subtotal += calculate_line_price(ticket, order, customer, now).
Ahora sí, mira lo que tienes y ponle nombre: un contrato común, varias implementaciones intercambiables, y un punto que elige cuál usar en tiempo de ejecución. Eso es una Strategy. No la impusiste: la encontraste. Y el nombre, que llega al final, sirve para dos cosas —comunicarlo en la revisión y buscar sus problemas conocidos— pero no decidió nada del diseño.
Qué esperar de este ejemplo. Cuatro observaciones.
Primera: el orden hizo el diseño. Si hubieras empezado por "aquí va una Strategy", habrías escrito la interfaz primero, y lo más probable es que la firma hubiera sido price_for(ticket, order, customer, now) —los parámetros que estaban a mano— con las reglas consultando la base de datos por dentro. Funciona, y es peor: las reglas quedan atadas a la base y las pruebas del ejercicio siguiente serían imposibles de escribir. El PricingContext no salió de ningún catálogo de patrones: salió de mirar la tabla de firmas.
Segunda: pudiste parar en cualquier tiempo. Después del tiempo 1 el sistema ya estaba mejor y funcionando; después del tiempo 2, también. Esa propiedad es el tema entero de la lección 5, y aquí ya la usaste.
Tercera: mira lo que se ganó, en una unidad defendible. Antes, agregar un tipo de boleto significaba encontrar los condicionales sobre Ticket.kind repartidos por el código —el checkout, el reporte de ventas por tipo, la validación de la petición— y no olvidarse de ninguno. Ahora significa una clase nueva y una entrada en el diccionario. De "encuentra los cuatro lugares" a "toca uno". Esa es la frase de la lección 6.
Cuarta, y es la que más se subestima: las pruebas que antes eran carísimas ahora son de cuatro líneas.
# 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_early_bird_loses_the_discount_after_the_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
Sin base de datos, sin objetos falsos, sin montar una orden. 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. Y eso es un beneficio del refactor que casi nunca aparece en la justificación, aunque suele ser el más duradero.
Emerger no es lo mismo que aparecer solo
Conviene precisar la metáfora, porque "dejar que el patrón emerja" suena a esperar sentado y no lo es. Emerger significa que la forma final la decide la evidencia que produce el propio refactor, no un catálogo consultado de antemano. Tú haces trabajo activo todo el tiempo; lo que cambia es el orden en que tomas las decisiones.
Compara los dos órdenes:
| Imponer | Dejar emerger | |
|---|---|---|
| Primer paso | Elegir el patrón | Confirmar el eje con datos |
| Segundo | Escribir la interfaz | Extraer cada rama, sin diseñar |
| Tercero | Encajar el código en la interfaz | Leer las firmas y ver qué necesita cada una |
| Cuarto | Descubrir en el camino que un caso no encaja, y forzarlo | Escribir el contrato que las firmas dictaron |
| Nombre del patrón | Al principio, y guía todo | Al final, y solo sirve para comunicar |
| Cuando un caso no encaja | Se retuerce el caso | Se corrige el contrato |
La fila que más importa es la última. En el método de imponer, cuando CourtesyPricing no encaja —porque no lleva cargo por servicio y porque puede fallar—, la reacción típica es meterle un parámetro a la interfaz. En el método de emerger, ese caso es información: te dice que el contrato tiene que admitir un fallo y que el cargo no puede vivir en el esqueleto.
De ahí una regla que vale para el resto de tu carrera: diseña el contrato mirando el caso más raro, no el más común. En el módulo 4 lo viste con CashProvider, que no cobra sino que genera una referencia: por ser el que menos se parece a los otros dos, es el que prueba si el contrato está bien. Un contrato diseñado con los dos casos fáciles se rompe con el tercero.
Cuánta estructura, y en qué peldaño
Reconocer el eje no te dice cuánta estructura poner. Entre "un if" y "una arquitectura de plugins" hay varios peldaños, y casi siempre el correcto es de los bajos:
- Un
ifdonde está. (Lo que había.) - Un
ifextraído a su propia función. - Un diccionario de funciones.
- Un diccionario de objetos con un método, más un objeto de contexto. ← lo que hicimos
- Una interfaz formal con registro explícito.
- Configuración externa que elige la implementación.
- Descubrimiento dinámico de módulos. ← lo que tiene
plugins/
El peldaño 4 resolvió el problema de pricing. El 3 habría bastado si las reglas no necesitaran contexto ni pudieran fallar. Nadie necesita el 7 salvo que código de terceros tenga que enchufarse sin que tú despliegues. La pregunta correcta no es "¿qué patrón uso?" sino "¿cuál es el peldaño más bajo que resuelve esto?", y esa pregunta se contesta mucho mejor después de haber extraído que antes.
Errores comunes
Nombrar el patrón antes de extraer (de método). Qué pasa: alguien ve cuatro ramas, dice "Strategy", abre un archivo nuevo y escribe la interfaz. Después traduce cada rama a una clase que cumpla esa interfaz. El resultado casi siempre tiene el mismo defecto: la firma de la interfaz es la que estaba a mano en el punto de llamada, así que las implementaciones terminan consultando la base de datos por dentro y quedan imposibles de probar. Por qué pasa: nombrar produce una sensación fuerte de haber entendido, y la interfaz es la parte del patrón que uno recuerda del diagrama. Cómo detectarlo: si escribiste una línea de la interfaz antes de haber extraído las ramas a funciones, lo hiciste al revés. Cómo corregirlo: los cuatro tiempos, en orden. Y una regla de bolsillo: la interfaz es la última cosa que se escribe, no la primera. Si te cuesta resistirlo, prohíbete decir el nombre del patrón en voz alta hasta el tiempo 4; el resto sale solo.
Confundir un condicional interno con un eje (de diagnóstico). Qué pasa: alguien cuenta los if de una función, encuentra nueve, y concluye que ahí hay nueve comportamientos que piden nueve clases. Termina con una jerarquía absurda donde BeforeCutoffEarlyBirdPricing y AfterCutoffEarlyBirdPricing son dos clases distintas. Por qué pasa: se cuenta sintaxis en vez de conceptos. Un if es una construcción del lenguaje; un eje es una categoría del negocio, y no todos los if marcan una categoría. Cómo detectarlo: aplica la prueba del eje. Si la rama no puede crecer —una fecha o pasó o no pasó, un cliente o es socio o no lo es—, no es un eje. Cómo corregirlo: primero identifica el eje, después mira cuántas ramas tiene. El eje de pricing es el tipo de boleto y tiene cuatro ramas; los if de dentro de cada regla no son ejes, son la regla.
Unificar duplicación que solo se parece (de riesgo). Qué pasa: alguien ve dos bloques idénticos, los extrae a una función común, y semanas después uno de los dos tiene que cambiar. Entonces le agrega un parámetro a la función común. Después otro. Al cabo de un año hay una función con cinco banderas booleanas que nadie entiende y que sirve a dos casos que nunca fueron el mismo. Por qué pasa: la duplicación es visible y la diferencia conceptual no. Y porque "no te repitas" se enseña como regla absoluta, sin su condición, que es: no repitas conocimiento, no líneas. Cómo detectarlo: pregunta "si mañana cambia una de las dos copias, ¿debe cambiar la otra?". Si la respuesta honesta es "no necesariamente", no las unifiques. Cómo corregirlo: si ya lo hiciste, el camino de vuelta es duplicar a propósito —volver a separar las dos copias— y recién después decidir. Duplicar a propósito es una técnica legítima y el módulo 2 la trata en serio; una abstracción equivocada cuesta más que la duplicación que quería evitar.
Ejercicios
Ejercicio 1 — ¿Eje o falso positivo? Para cada caso, aplica la prueba del eje —misma pregunta, cambian por separado, la lista puede crecer— y decide si pide estructura. Justifica en dos líneas.
(a) En notifications/notifier.py: if customer.phone: ... / if customer.push_token: ..., para decidir por qué canales avisarle a un cliente.
(b) En api/routes.py: if fmt == "csv" ... elif fmt == "pdf" ... elif fmt == "xlsx", repetido también en reports/exporter.py y en admin/panel.py.
(c) En checkout/checkout.py: if order.total > 10000: ..., la verificación antifraude de MercadoPago que investigaste en la lección 2.
(d) En pricing/rules.py: if customer.is_member: price = price * 0.90, dentro de la regla del VIP.
(e) En admin/refunds.py: if order.provider == "stripe" ... elif "mercadopago" ... elif "cash", para decidir cómo se devuelve dinero.
Ver solución
(a) No es un eje, o al menos no ese. Los dos if no contestan "de qué tipo es esto" sino "¿este cliente tiene este dato?". Son condiciones de disponibilidad, y la lista de canales no crece con ellos. Ahora bien: sí hay un problema ahí, y es otro —el checkout conoce por nombre a todos los interesados en una compra—, pero su eje no es el if del teléfono. Este caso es valioso porque enseña que un rincón puede necesitar estructura por una razón distinta a la que salta primero.
(b) Eje clarísimo, con la señal 2 encima. Misma pregunta (¿cómo se exporta esto?), las ramas cambian por separado (el generador de PDF cambió sin tocar el de CSV), la lista creció el año pasado. Y está repetido en tres archivos, así que agregar un formato obliga a encontrar los tres. Es el caso de libro.
(c) No es un eje. Es una condición de un umbral, con dos resultados posibles, y no va a crecer nunca —o el monto pasa el umbral o no—. Además ya sabes de la lección 2 que es una cerca del tipo 1. Lo que necesita no es estructura: necesita un nombre para el número mágico, un comentario y una prueba. Convertirla en clases sería exactamente la clase de ceremonia que este módulo quiere evitar.
(d) No es un eje, y por la misma razón que (c): es parte del cálculo del VIP, no una categoría. La prueba del eje falla en la tercera pregunta —"socio" no es una lista que crezca—. Si mañana hubiera cinco niveles de membresía con reglas propias, cambiaría la respuesta; hoy no.
(e) Eje, y es el mismo eje que el del checkout. Este es el detalle que más importa del ejercicio: refunds.py no tiene un problema propio, tiene el mismo problema que el checkout, escrito otra vez. La señal 2 en su forma pura. Cuando encuentres dos condicionales sobre el mismo campo en dos archivos, no son dos refactorizaciones: es una.
Por qué funciona: de los cinco, solo dos son ejes, y los dos que lo son resultan estar repetidos en varios archivos. Esa es la forma típica del problema real: los ejes verdaderos casi nunca están en un solo lugar.
Ejercicio 2 — Ordena el refactor y di qué se rompe si te saltas un paso. Aquí están los cuatro tiempos, desordenados, más dos distractores que no deberían estar. Ordena los correctos, descarta los distractores y explica qué pasa si haces cada paso antes de tiempo.
(i) Escribir PricingContext y build_context. (ii) Extraer cada rama a una función con la misma firma. (iii) Escribir la interfaz PricingRule y las cuatro clases. (iv) Escribir las pruebas de caracterización. (v) Crear el diccionario RULES y adelgazar el checkout. (vi) Renombrar Ticket.kind a un enum para que no sea texto libre.
Ver solución
El orden: (iv) → (ii) → (i) → (iii) → (v). El distractor es (vi).
- (iv) primero, sin excepción. Sin la red, cada paso siguiente es a ciegas. Y tienen que ser pruebas de comportamiento: si las escribes mencionando las clases nuevas, no prueban lo que había, prueban lo que vas a hacer. Una prueba escrita después del refactor confirma tu resultado, no protege el original.
- (ii) segundo, y es el paso más seguro que existe. Extraer una función no cambia nada. Si lo hicieras después de (i), estarías escribiendo el contexto sobre lo que crees que necesita cada rama en vez de sobre lo que viste.
- (i) tercero. El contexto solo se puede diseñar bien después de tener la tabla de firmas: sin ella, o te sobran campos o te faltan. Este es el paso donde más se nota el método.
- (iii) cuarto. La interfaz es la consecuencia del contexto y de las firmas, no la premisa. Si la escribes primero, hereda los parámetros que estaban a mano en el punto de llamada.
- (v) último. Cambiar el punto de llamada es lo que mueve el tráfico al camino nuevo, y conviene hacerlo cuando el camino nuevo ya está probado.
Por qué (vi) es un distractor. Convertir Ticket.kind en un enum es probablemente una buena idea, y no es este refactor. Toca el modelo de datos, obliga a migrar valores existentes, y afecta a todo el que lea ese campo —el reporte de ventas por tipo, la validación de la petición, el panel—. Mezclarlo aquí produce un cambio que nadie puede revisar, porque no se distingue lo que se movió de lo que se transformó. Va en su propio trabajo, después. Un refactor que empieza a crecer hacia los lados es un refactor que no se va a terminar.
Por qué funciona: los cinco pasos correctos tienen una precondición concreta cada uno, y saltársela produce un fallo específico y predecible. Poder decir qué se rompe es lo que convierte el procedimiento en algo defendible en una revisión, y no en una preferencia de estilo.
Ejercicio 3 — El eje del otro rincón. Ahora hazlo tú con el bloque de proveedores de pago del checkout. No escribas el código: escribe el diagnóstico, en cuatro puntos. (a) ¿Cuál es el eje y qué evidencia lo confirma? (b) ¿Qué señales de las cinco están presentes? (c) ¿Qué te dice la tabla de firmas después de extraer, si sabes que Stripe cobra en centavos enteros, MercadoPago en float con descripción, y el efectivo no cobra sino que genera una referencia? (d) ¿Qué peldaño usarías y por qué no el siguiente?
Ver solución
(a) El eje es Order.provider. Evidencia: las tres ramas contestan la misma pregunta ("¿cómo se cobra esto?"); cambian por separado (Stripe cambió de versión de API sin que MercadoPago se enterara); y la lista creció dos veces en dos años. La prueba del eje pasa las tres.
(b) Señales 1, 2 y 4. La 1 es el condicional por categoría. La 2 es la más fuerte aquí y la que vale para justificar: el mismo if está repetido en cuatro o cinco archivos —cobro, devolución, conciliación, validación—, así que agregar un proveedor significa encontrarlos todos, y olvidarse de uno deja el sistema inconsistente en silencio (la conciliación simplemente no cuadra al mes siguiente). La 4 aparece porque el checkout cambia por razones de pago además de por razones de precio y de notificación.
(c) La tabla de firmas te dice que el contrato no puede ser "lo que devuelva cada API". Las tres devuelven cosas incompatibles: un objeto de la librería de Stripe, otro de MercadoPago, y un diccionario armado a mano. Si el contrato no fija también la forma del resultado, quien llama va a tener que saber cuál le tocó, y el if reaparece en el punto de llamada. Además, el efectivo no termina en "cobrado" sino en "pendiente": el resultado necesita un estado, no un booleano. Ese es el caso raro que mejora el diseño, y es la razón por la que hay que mirarlo antes de escribir la interfaz.
(d) Peldaño 4, quizá 5. Un contrato común, tres implementaciones, y un diccionario de constructores como punto de elección. No el 6 ni el 7: los tres proveedores los escribe tu equipo, en tu repositorio, y la lista cabe en cinco líneas visibles. Nada de esto necesita configuración externa ni descubrimiento dinámico. Y hay un detalle de por qué diccionario y no if: queremos poder preguntar qué proveedores existen, porque la validación de la petición y el menú de formas de pago necesitan esa lista, y con un if habría que escribirla a mano en un segundo lugar —que es el problema que estamos resolviendo—.
Por qué funciona: acabas de hacer, sin escribir código, el diagnóstico completo de la mitad del proyecto final. Y fíjate en el contraste con el rincón plugins/, que tiene exactamente el peldaño 7 para un eje con una implementación. El mismo sistema, dos errores opuestos, y la misma prueba para detectarlos.
Resumen y siguiente paso
En esta lección aprendiste a reconocer que un código pide estructura, con señales en vez de intuición. Las cinco: el condicional que crece por el mismo eje; agregar un caso obliga a tocar varios archivos —la señal que mejor se defiende, porque trae su unidad de medida—; la duplicación con variación, con su gemela peligrosa, la duplicación que solo se parece; el módulo que cambia por razones distintas, con su gemela legítima, el orquestador; y el parámetro que enciende y apaga comportamiento, sospechoso cuando las combinaciones no son válidas.
Y la prueba que las une: la prueba del eje. Todas las ramas contestan la misma pregunta, cambian por separado, y la lista puede crecer con evidencia y no con predicciones. Si una de las tres falla, estás a punto de pavimentar donde nadie camina.
Aplicaste el procedimiento de cuatro tiempos al checkout de Boletia y viste por qué el orden hace el diseño. Cero: confirma el eje y pon la red. Uno: extrae cada rama sin diseñar nada. Dos: mira la tabla de firmas, que es donde el patrón empieza a hablar —qué usa cada regla de verdad, cuáles consultan afuera, cuál puede fallar—. Tres: escribe el contrato que esas firmas te dictaron, no el que estaba en tu cabeza. Cuatro: el punto de elección, y recién ahí el nombre. El PricingContext no salió de ningún catálogo: salió de la tabla del tiempo 2.
Te llevas dos reglas que valen más allá de este caso: diseña el contrato mirando el caso más raro, no el más común —el que no encaja es el que mejora el diseño, si llega antes de que la interfaz esté escrita—; y pregunta cuál es el peldaño más bajo que resuelve el problema, porque entre un if y un descubrimiento dinámico de módulos hay siete escalones y casi siempre el correcto es el cuarto.
Antes de avanzar deberías poder: enunciar las cinco señales con su gemela falsa; aplicar la prueba del eje a un condicional cualquiera; y explicar por qué la interfaz se escribe al final y no al principio.
Ahora toca el movimiento contrario, el que casi nadie enseña. La lección 4 se ocupa de reconocer la estructura que hay que quitar: la interfaz con un solo implementador, la capa que solo reenvía llamadas, la configuración para algo que nunca varió. Vas a ver que la prueba es la misma de esta lección leída al revés, que el procedimiento es simétrico pero no idéntico —quitar exige una certeza que agregar no necesita: saber que nadie más usa lo que vas a borrar—, y vas a volver al rincón plugins/ de Boletia con la lista de lo que hay que revisar antes de tocar una línea.
Recursos
- Refactoring: Improving the Design of Existing Code (Martin Fowler) — el catálogo de los movimientos que ejecutaste:
Extract Function,Replace Conditional with Polymorphism,Introduce Parameter Object. Cada uno con su procedimiento paso a paso y sus condiciones de seguridad. - The Wrong Abstraction (Sandi Metz) — el argumento contra unificar duplicación que solo se parece. Es la defensa del tercer error común de esta lección y conviene releerlo cada vez que te tiente extraer un bloque repetido.
- Shotgun Surgery (Refactoring Guru) — el nombre formal de la señal 2, con las refactorizaciones asociadas. Útil para nombrarla en una revisión de código.
- Beck's Design Rules / "Make the change easy, then make the easy change" (Kent Beck) — las cuatro reglas del diseño simple, en el orden que importa. Es la formulación más corta que existe de "primero prepara el terreno, después haz el cambio", que es el método de esta lección.