Módulo 4: Patrones para crear objetos
8. Proyecto: ordena la creación de proveedores
Descripción
Al terminar este proyecto vas a haber ordenado la creación dispersa de objetos en un rincón real de Boletia, y —esto es lo que se evalúa— vas a haber entregado por escrito qué decidiste no abstraer y bajo qué condición lo reconsiderarías. El código es la mitad del trabajo. La otra mitad es el documento, porque es lo único que demuestra que hubo criterio y no reflejo.
El encargo tiene trampas deliberadas. Hay un lugar donde una Factory se gana su lugar con claridad. Hay otro donde parece que se lo gana y no. Hay un Singleton que desmontar y una pila de sobre-ingeniería que quitar. Y hay al menos un rincón donde la respuesta correcta es no tocar nada. Si terminas el proyecto habiendo agregado un patrón en cada lugar donde cabía, no lo resolviste: caíste en la trampa que el módulo entero vino a prevenir.
Se juzga por la simplicidad de la solución, no por su sofisticación. Esa frase suena a consuelo hasta que uno la aplica: es más difícil entregar tres cambios bien justificados que doce cambios llamativos, porque los tres exigen haber decidido sobre los otros nueve.
Conexión con el módulo: este proyecto junta las tres preguntas que ordenaron todo. Qué construir —la Factory de las lecciones 2 y 3—, cómo construirlo —el Builder de la lección 4, que aquí aparece sobre todo como algo que hay que quitar—, y quién construye —la inyección de la lección 6—. Además desmontas un Singleton con el método de la lección 5, y aplicas el filtro de la lección 7 a cada decisión. Y el entregable escrito es exactamente el ejercicio 3 de esa lección, hecho en serio: las condiciones de reversión de todo lo que dejaste como estaba.
Ordenar un taller que heredaste
Heredas el taller de alguien que trabajó ahí veinte años. Está lleno: hay herramientas colgadas, herramientas en cajones, herramientas dentro de cajas de otras herramientas, y tres martillos en tres lugares distintos.
El impulso es vaciarlo todo y organizarlo desde cero, con cada cosa etiquetada y en su lugar. Y ese impulso, si lo sigues, te va a costar tres días y va a producir un taller donde no encuentras nada —porque el orden que inventaste es tuyo, no del trabajo—.
Lo que hace alguien con experiencia es distinto y bastante menos glamoroso. Primero observa: mira qué se usa todos los días, qué se usa una vez al año y qué no se ha usado nunca. Después toma tres tipos de decisión:
Lo que se usa todos los días y está en tres lugares distintos —los martillos— recibe un lugar fijo y etiquetado. Ahí sí vale la pena la organización: el costo de etiquetarlo se paga la primera semana.
Lo que se usa una vez al año se queda en su cajón, aunque el cajón esté desordenado. Ponerle un lugar de honor a algo que se toca en marzo es gastar espacio bueno en un problema que no existe.
Lo que no se ha usado nunca sale del taller. Sin ceremonia. Si algún día hace falta, se consigue.
Este proyecto es exactamente eso. Boletia es el taller heredado. Tu trabajo no es organizarlo entero: es identificar cuáles son los tres martillos y dejar todo lo demás donde está —diciendo por qué—.
Y hay un detalle de la analogía que vale la pena tener presente durante todo el proyecto. La persona que dejó el taller así no era incompetente. Cada herramienta terminó donde terminó por una razón que tenía sentido ese día. Tu trabajo tampoco es juzgar: es decidir con la información de hoy, y dejar anotado el porqué para quien venga después.
El encargo
Es martes. Te llega esto de la líder técnica de Boletia:
"El refactor de proveedores de pago quedó bien —agregamos PayPal en una tarde—. Ahora tengo el mismo problema en notificaciones y quiero que lo revises tú. Pero antes de que empieces: no quiero que apliques el mismo molde sin pensar. Hay rincones donde ese refactor no va a servir de nada y prefiero que me digas cuáles y por qué. Y de paso, si te topas con algo que sobre, quítalo.
Entrégame el código y un documento corto. En el documento me interesa más lo que NO tocaste que lo que tocaste."
Ese último renglón es el encargo real.
El código de partida
Estos son los seis rincones de Boletia sobre los que trabajas. Léelos todos antes de tocar cualquiera —es la regla de la lección 3 y del módulo 8: inventario primero—.
Rincón A — notifications/notifier.py
# El único lugar que "oficialmente" manda notificaciones.
def notify(customer, message):
email = EmailChannel(smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER)
email.send(customer.email, message)
if customer.phone:
sms = SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER)
sms.send(customer.phone, message)
if customer.push_token:
push = PushChannel(app_credentials=settings.PUSH_CREDENTIALS)
push.send(customer.push_token, message)
Rincón B — admin/broadcast.py
# Cuando se cancela un evento, hay que avisarle a todos los compradores.
# Este archivo lo escribió otra persona, seis meses después que el rincón A.
def broadcast_cancellation(event, channel_kind):
orders = load_orders_for(event)
for order in orders:
customer = load_customer(order.customer_id)
message = f"El evento {event.name} fue cancelado. Te reembolsaremos en 5 días."
if channel_kind == "email":
EmailChannel(
smtp_host=settings.SMTP_HOST,
smtp_user=settings.SMTP_USER,
).send(customer.email, message)
elif channel_kind == "sms":
if customer.phone:
SmsChannel(
api_key=settings.SMS_API_KEY,
sender=settings.SMS_SENDER,
).send(customer.phone, message)
elif channel_kind == "push":
if customer.push_token:
PushChannel(
app_credentials=settings.PUSH_CREDENTIALS,
).send(customer.push_token, message)
else:
raise ValueError(f"Canal inválido: {channel_kind}")
Rincón C — api/routes.py (fragmento)
# El endpoint que le permite al organizador mandar un aviso a sus asistentes.
def post_event_announcement(request):
data = request.json
kind = data.get("channel", "email")
if kind not in ("email", "sms", "push"):
return response(400, {"error": f"Canal no soportado: {kind}"})
# La interfaz necesita saber qué canales ofrecer en el desplegable.
# Esta lista se copió a mano del archivo de arriba.
...
Rincón D — reminders/scheduler.py
# Tarea programada: recuerda a los compradores 24 horas antes del evento.
# Siempre por correo, porque un SMS a las 3 de la mañana sería un problema.
def send_reminders(events_starting_tomorrow):
for event in events_starting_tomorrow:
for order in load_orders_for(event):
customer = load_customer(order.customer_id)
channel = EmailChannel(smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER)
channel.send(customer.email, f"Mañana es {event.name}. ¡Te esperamos!")
Rincón E — seating/cache.py
# El caché de mapas de asientos.
class SeatMapCache:
_maps = {}
@classmethod
def get(cls, event_id):
if event_id not in cls._maps:
cls._maps[event_id] = load_seat_map(event_id)
return cls._maps[event_id]
Rincón F — notifications/templates.py
# Alguien intentó ordenar el armado de mensajes hace un año.
class MessageTemplateFactory:
def __init__(self):
self._templates = {}
self._templates["confirmation"] = ConfirmationTemplateProvider()
def register(self, name, provider):
self._templates[name] = provider
def create(self, name):
provider = self._templates.get(name)
if provider is None:
raise ValueError(f"Plantilla no registrada: {name}")
return provider.provide()
class TemplateProvider(ABC):
@abstractmethod
def provide(self): ...
class ConfirmationTemplateProvider(TemplateProvider):
def provide(self):
return MessageTemplateBuilder().with_file("confirmation.txt").build()
class MessageTemplateBuilder:
def __init__(self):
self._file = None
def with_file(self, name):
self._file = name
return self
def build(self):
return MessageTemplate(load_file(self._file))
# Y el único uso en todo el sistema:
template = MessageTemplateFactory().create("confirmation")
Ejemplo trabajado: el inventario, hecho bien
Antes de escribir código, hagamos juntos el inventario. Este es el paso que decide la calidad de todo lo demás, y es el que más gente se salta.
Primero, las búsquedas. Igual que en la lección 3, el conocimiento está escrito de formas distintas, así que una sola búsqueda no alcanza:
# 1. Las clases concretas: quién las instancia.
grep -rn 'EmailChannel(\|SmsChannel(\|PushChannel(' --include="*.py" .
# 2. Los nombres, tal como aparecen en los datos.
grep -rn '"email"\|"sms"\|"push"' --include="*.py" .
# 3. Las credenciales: destapa lugares que las otras dos no ven.
grep -rn 'SMTP_HOST\|SMS_API_KEY\|PUSH_CREDENTIALS' --include="*.py" .
Segundo, la tabla. Esto es lo que sale:
| Rincón | ¿Construye canales? | ¿Decide cuál? | Notas |
|---|---|---|---|
A notifier.py | Los tres | Sí, por lo que el cliente tenga | El caso "oficial" |
B broadcast.py | Los tres | Sí, por un parámetro | Duplica A casi entero |
C routes.py | Ninguno | Solo valida la lista | Copia de la lista, a mano |
D scheduler.py | Solo correo | No decide, siempre correo | Fijo por una razón de negocio |
E cache.py | No aplica | No aplica | Singleton, otro problema |
F templates.py | No aplica | No aplica | Sobre-ingeniería, otro problema |
Tercero, y es lo importante: la pregunta del filtro.
¿Cuántos archivos tengo que tocar para agregar un cuarto canal —digamos WhatsApp?
Contémoslos con honestidad:
- A: sí, hay que agregar la construcción y su condición.
- B: sí, otra rama del
elif. - C: sí, agregar
"whatsapp"a la tupla de validación. - D: no. Manda correo siempre, a propósito.
- E, F: no tienen nada que ver.
Tres archivos. La señal 1 de la lección 7 se cumple con claridad: el mismo conocimiento —qué canales existen y cómo se construyen— vive en tres lugares escritos de tres formas distintas. Esto sí pide una Factory.
Qué esperar de este inventario. Vamos por lo que revela y por lo que evita.
Lo primero: nota que el rincón D construye un canal y sin embargo no cuenta para la señal. Eso es lo que separa un inventario bueno de una lista de coincidencias de grep. El recordatorio manda correo siempre, y no por descuido: manda correo porque un SMS a las tres de la mañana es un problema de negocio. Ese archivo no participa de la decisión, así que no suma al conteo. Si lo hubieras contado, tendrías cuatro lugares en vez de tres —lo cual no cambia el veredicto aquí, pero en un caso más ajustado te llevaría a abstraer algo que no lo pedía—.
Lo segundo: el rincón C no construye nada, y aun así cuenta. Tiene una copia de la lista de canales, mantenida a mano. Es exactamente el mismo tipo de duplicación de conocimiento que viste en VALID_PROVIDERS en la lección 1: no se parece al código de A ni al de B, pero sabe lo mismo que ellos.
Lo tercero, y es lo que hace útil la tabla: E y F aparecieron en el inventario y no tienen nada que ver con el problema principal. Eso está bien. Un inventario honesto encuentra cosas que no venías a buscar. Lo que no está bien es meterlas todas en el mismo cambio: son problemas distintos, con justificaciones distintas, y merecen entregas separadas.
Y lo cuarto, que es el aprendizaje que se transfiere a cualquier trabajo: el inventario no es contar coincidencias, es clasificarlas. La pregunta que clasifica es siempre la misma: ¿este lugar participa de la decisión, o solo la consume?
Lo que hay que entregar
Tres cosas. Ninguna es opcional, y la tercera es la que se lee primero.
Entregable 1 — El código refactorizado
Los archivos que decidas cambiar, con los cambios aplicados. Con dos condiciones que vienen de la lección 3:
- Cada paso deja el sistema funcionando. Si tu refactor solo corre al final, no es un refactor.
- El código viejo se borra, no se comenta ni se esconde detrás de una bandera sin fecha de retiro.
Entregable 2 — Las pruebas que antes no se podían escribir
Al menos dos, y tienen que ser pruebas que el código original hacía imposibles. Es la mejor evidencia de que el refactor sirvió para algo. Dos candidatas obvias:
- Que un cliente sin teléfono no reciba SMS.
- Que el aviso masivo de cancelación llegue a todos los compradores, sin mandar un solo correo real.
Entregable 3 — El documento DECISIONES.md
El corazón del proyecto. No más de una página. Esta es la estructura, y conviene respetarla porque es la que se usa en la descripción de un pull request real:
# Ordenar la creación de canales de notificación
## Qué cambié y por qué
Una entrada por cambio. En cada una: el problema concreto (con números),
qué hice, y qué cuesta la solución.
## Qué NO abstraje y por qué
Una entrada por cada lugar donde el reflejo decía "patrón" y decidí que no.
En cada una: por qué no, y **la condición concreta y observable** bajo la
cual cambiaría de opinión.
## Qué quité
Lo que borré, qué capacidad se pierde, y por qué no hace falta hoy.
## Cómo se mide la mejora
La frase de una línea que defiende el cambio en una revisión.
Idealmente en archivos tocados, no en adjetivos.
Presta atención a la sección "Qué NO abstraje". En un proyecto normal esa sección se omite; aquí es la que más peso tiene. La razón la viste en la lección 7: una decisión de no abstraer, escrita sin su condición de reversión, es indistinguible de no haber pensado.
Cómo se evalúa
No hay porcentajes ni rúbrica de puntos. Estas son las cinco preguntas que se le hacen a la entrega, en orden de importancia:
1. ¿El documento explica lo que NO se tocó, con condiciones verificables? Es la primera pregunta a propósito. Una condición como "si esto crece mucho" no sirve: no se puede verificar. "Cuando un segundo archivo necesite construir un canal por su nombre" sí.
2. ¿La solución es la mínima que resuelve el problema? Cada archivo nuevo, cada clase nueva y cada capa tiene que estar pagando algo concreto. Si hay una abstracción que no puedes justificar en una frase, sobra.
3. ¿Se detectaron las trampas? Hay al menos dos lugares donde el reflejo entrenado dice "aplica el patrón" y la respuesta correcta es no. Detectarlas vale más que ejecutar bien el refactor principal.
4. ¿El comportamiento se conserva? Un refactor que cambia lo que el sistema hace no es un refactor. Las pruebas nuevas ayudan a demostrarlo.
5. ¿Las pruebas nuevas prueban algo que antes no se podía probar? Si tus pruebas nuevas también pasarían con el código viejo, no demuestran nada sobre el refactor.
Errores comunes en este proyecto
Aplicarle el molde de la lección 3 a todo lo que se parezca (de criterio). Qué pasa: el refactor de proveedores de pago funcionó, y notificaciones se parece mucho, así que se copia entero —contrato, clases, factory con registro, available_channels()— sin revisar si cada pieza se gana su lugar aquí. Lo que se produce suele ser correcto en el rincón principal y excesivo en los demás. Por qué pasa: reconocer un patrón conocido es la habilidad que el módulo entrenó, y reconocerlo produce confianza. Cómo detectarlo: si tu solución tiene la misma cantidad de archivos que la de la lección 3, revisa si de verdad necesitabas los mismos. Cómo corregirlo: pregunta pieza por pieza. ¿Necesitas available_channels()? Sí, porque el rincón C valida contra una lista. ¿Necesitas un Protocol formal? Depende de si vas a usar un verificador de tipos. ¿Necesitas una clase Notifier además de la factory? Eso hay que pensarlo. Copiar la forma de una solución anterior es distinto de aplicar el criterio que la produjo.
Meter los cuatro problemas en un solo cambio (de proceso). Qué pasa: el inventario encontró seis rincones, así que se arregla todo junto —factory de canales, Singleton desmontado, sobre-ingeniería borrada— en un pull request de novecientas líneas que nadie puede revisar. Si algo sale mal en producción, no hay forma de saber cuál de los cuatro cambios fue. Por qué pasa: se descubrieron los cuatro problemas al mismo tiempo, y se siente natural resolverlos al mismo tiempo. Cómo detectarlo: si tu descripción del cambio necesita viñetas para enumerar temas distintos, son cambios distintos. Cómo corregirlo: uno por entrega, en orden de riesgo creciente. Primero lo que solo quita —borrar el rincón F no puede romper casi nada—. Después la factory de canales. Después el Singleton, que es el más delicado porque toca comportamiento de caché. El documento puede ser uno solo; los cambios, no.
Escribir el documento al final, como trámite (de proceso). Qué pasa: se hace todo el refactor, y al terminar se redacta el DECISIONES.md describiendo lo que se hizo. El resultado se nota: la sección "qué no abstraje" queda con dos líneas genéricas, porque las decisiones de no hacer algo no se recuerdan —solo se recuerda lo que sí se hizo—. Por qué pasa: escribir se siente como documentación, y la documentación se hace al final. Cómo detectarlo: si tu sección de "qué no toqué" es más corta que la de "qué cambié", el documento se escribió al revés. Cómo corregirlo: abre el archivo durante el inventario, antes de tocar código, y anota cada decisión en el momento en que la tomas —incluidas las de no hacer nada, que son las que se evaporan—. El documento entonces no es un resumen: es el registro de tu razonamiento, que es lo que se pidió.
Ejercicios
Los tres ejercicios son las tres etapas del proyecto. Haz cada uno antes de leer su solución: las soluciones son una referencia con la que comparar, no la respuesta única.
Ejercicio 1 — Ordena la creación de canales (rincones A, B, C, D). Decide qué hacer con cada uno, escribe el código, y anota en el documento la justificación de cada decisión. Presta atención especial al rincón D.
Ver solución
El diagnóstico. Tres lugares participan de la decisión (A, B, C) y uno no (D). La señal 1 se cumple: Factory.
La factory:
# Archivo: notifications/factory.py
class UnknownChannelError(ValueError):
def __init__(self, name: str):
self.name = name
super().__init__(
f"Canal de notificación desconocido: {name!r}. "
f"Disponibles: {', '.join(available_channels())}"
)
# Guardamos CÓMO construir, no el objeto construido: así nada lee credenciales
# al importar y la lista sigue siendo consultable.
_CHANNELS: dict[str, callable] = {
"email": lambda: EmailChannel(
smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER),
"sms": lambda: SmsChannel(
api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER),
"push": lambda: PushChannel(app_credentials=settings.PUSH_CREDENTIALS),
}
def available_channels() -> list[str]:
"""Única fuente de la verdad sobre qué canales existen."""
return sorted(_CHANNELS)
def get_channel(name: str) -> NotificationChannel:
try:
build = _CHANNELS[name]
except KeyError:
raise UnknownChannelError(name) from None
return build()
Se eligió diccionario y no condicional porque el rincón C necesita preguntar la lista. Con un if, esa lista se escribiría a mano en un segundo lugar, que es el problema que vinimos a resolver.
Rincón A — el notificador. Aquí hay una decisión de diseño que va más allá de la factory: qué canales aplican depende de los datos del cliente. Eso se puede resolver de dos formas. La directa:
# Archivo: notifications/notifier.py
def channels_for(customer) -> list[str]:
"""Qué canales se pueden usar con este cliente. Una sola regla, un solo lugar."""
kinds = ["email"] # todo cliente tiene correo
if customer.phone:
kinds.append("sms")
if customer.push_token:
kinds.append("push")
return kinds
def destination_for(customer, kind: str) -> str:
return {"email": customer.email, "sms": customer.phone, "push": customer.push_token}[kind]
def notify(customer, message, channels=None) -> None:
"""Avisa por todos los canales disponibles para este cliente."""
channels = channels or {k: get_channel(k) for k in channels_for(customer)}
for kind, channel in channels.items():
channel.send(destination_for(customer, kind), message)
El parámetro channels=None es el truco de la lección 5 y la 6: por defecto se comporta como antes, y las pruebas pueden pasar canales falsos sin tocar nada global.
Rincón B — el aviso masivo. Ahora es esto:
# Archivo: admin/broadcast.py
def broadcast_cancellation(event, channel_kind, channel=None) -> None:
channel = channel or get_channel(channel_kind) # falla fuerte si no existe
message = f"El evento {event.name} fue cancelado. Te reembolsaremos en 5 días."
for order in load_orders_for(event):
customer = load_customer(order.customer_id)
destination = destination_for(customer, channel_kind)
if destination: # el cliente puede no tener teléfono
channel.send(destination, message)
De veinte líneas con tres ramas a siete. Y fíjate en algo que el refactor hizo visible: en el código original, el canal se construía dentro del bucle —un EmailChannel nuevo por cada comprador, con su conexión SMTP—. Para un evento de cinco mil boletos eso son cinco mil conexiones. Ahora se construye una vez. Ese bug de rendimiento estaba ahí desde siempre y nadie lo veía, porque la construcción estaba mezclada con el envío.
Rincón C — el endpoint. La validación desaparece:
def post_event_announcement(request):
kind = request.json.get("channel", "email")
try:
channel = get_channel(kind)
except UnknownChannelError as err:
return response(400, {"error": str(err)})
...
def get_available_channels(request):
# La interfaz arma su desplegable desde aquí, no desde una lista copiada.
return response(200, {"channels": available_channels()})
Rincón D — el recordatorio. NO SE TOCA, y esta es la respuesta que más pesa.
def send_reminders(events_starting_tomorrow, channel=None):
# Correo, siempre, a propósito: un SMS a las 3 AM es un problema de negocio.
channel = channel or EmailChannel(
smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER)
...
Lo único que se agrega es el parámetro con default, para poder probarlo. No se le pone get_channel("email"), y la razón es de fondo: usar la factory aquí comunicaría que el canal es una decisión abierta, cuando en realidad es una regla de negocio fija. Sería una abstracción que miente sobre la variabilidad del sistema. Si mañana alguien quiere mandar recordatorios por SMS, esa va a ser una discusión de producto —a qué hora, con qué consentimiento—, no un cambio de parámetro.
Y la entrada correspondiente en el documento:
No abstraje el recordatorio (
reminders/scheduler.py). Manda correo siempre, y eso es una decisión de negocio deliberada, no una limitación. Usar la factory ahí sugeriría que el canal es configurable, y no lo es. Solo le agregué el canal como parámetro con valor por defecto, para poder probarlo sin SMTP. Lo reconsideraría si producto define reglas de horario y consentimiento para recordatorios por SMS —ese día el canal pasa a ser una decisión real y la factory aplica—.
Y una entrada más, sobre algo que sí es tentador y no hice:
No creé una clase
Notifierni un contratoNotificationChannelformal conProtocol. Los tres canales ya tienen el mismo métodosend(destination, message), así que el contrato existe de hecho. Declararlo agregaría un archivo sin cambiar ninguna capacidad, porque hoy no usamos verificador de tipos en CI. Lo agregaría el día que activemosmypy, que ya está en el plan del trimestre.
Ahorro medido: agregar WhatsApp pasa de tocar tres archivos existentes —con la validación del endpoint fácil de olvidar— a crear un archivo nuevo y agregar una línea al registro. Esa es la frase de la sección "cómo se mide la mejora".
Ejercicio 2 — Desmonta el Singleton y quita lo que sobra (rincones E y F). Son dos entregas separadas. Escribe el plan de cada una y las entradas del documento.
Ver solución
Rincón F primero, porque es el que menos riesgo tiene: se borra.
# Archivo: notifications/templates.py — DESPUÉS
def load_template(name: str) -> MessageTemplate:
"""Carga una plantilla de mensaje por nombre."""
return MessageTemplate(load_file(f"{name}.txt"))
# Uso:
template = load_template("confirmation")
Tres clases, una clase abstracta y unas 35 líneas reemplazadas por tres. Qué había: una Factory con un registro de una entrada —no decide nada—, un register() que nadie llama nunca, una clase abstracta con un solo implementador, un "provider" que solo reenvía, y un Builder con un solo with_x que es una asignación.
Entrada del documento:
Quité la pila de
templates.py(factory + clase abstracta + provider + builder, 35 líneas) y la reemplacé por una función de tres. La factory tenía una sola plantilla registrada, así que no decidía nada; el builder tenía un solo campo; la clase abstracta tenía un solo implementador. Se pierde la capacidad de registrar plantillas desde afuera en tiempo de ejecución, que nunca se usó y que ningún requerimiento pide. La repondría si vendiéramos integraciones donde un cliente registre sus propias plantillas —y ese día la escribiría con la información de ese día, que va a ser mejor que la de hoy—.
Rincón E después, porque toca comportamiento. El SeatMapCache tiene dos problemas distintos y conviene separarlos, porque uno es de diseño y otro es un bug de negocio.
Problema 1 (diseño): es un Singleton. _maps es un diccionario mutable a nivel de clase, compartido por todo el proceso, sin dueño y sin forma de sustituirlo en pruebas.
Problema 2 (bug real): el caché nunca expira. Si un organizador cambia su mapa de asientos, el sistema sirve el viejo hasta que alguien reinicie. Eso se reporta como "cambié los asientos y no se ve", y en desarrollo no se reproduce porque ahí el proceso se reinicia todo el tiempo.
El plan, con el método de la lección 5:
# Paso 1 — El estado pasa de la clase a la instancia. Y de paso, el TTL.
class SeatMapCache:
def __init__(self, loader=load_seat_map, ttl_seconds: int = 300):
self._maps: dict[int, tuple[float, SeatMap]] = {}
self._loader = loader
self._ttl = ttl_seconds
def get(self, event_id) -> SeatMap:
entry = self._maps.get(event_id)
if entry and (time.monotonic() - entry[0]) < self._ttl:
return entry[1]
seat_map = self._loader(event_id)
self._maps[event_id] = (time.monotonic(), seat_map)
return seat_map
def invalidate(self, event_id) -> None:
"""Para que el panel pueda tirar el caché al guardar cambios."""
self._maps.pop(event_id, None)
# Paso 2 — Instancia global temporal, para no romper a los llamadores.
_default_cache = SeatMapCache() # se borra en el paso 4
# Paso 3 — La instancia real se crea en el arranque y se pasa.
def build_services() -> Services:
return Services(
seat_cache=SeatMapCache(ttl_seconds=int(os.environ.get("SEAT_CACHE_TTL", "300"))),
...
)
# Paso 4 — Cuando ningún llamador use _default_cache, se borra.
Entrada del documento:
Desmonté el
SeatMapCachesingleton, en cuatro pasos que dejan el sistema funcionando. El estado pasó de la clase a la instancia, se crea una vez enbuild_services()y se pasa. Costo: las tres funciones que lo usan reciben un parámetro más. Se gana: las pruebas de asientos dejan de contaminarse entre sí —hoy dependen del orden— ywarm_cache.pydeja de mentir (era un proceso aparte, así que nunca calentó nada).Aparte, y esto es un bug, no un refactor: agregué expiración (
ttl_seconds, cinco minutos) einvalidate(). El caché no expiraba nunca, así que un cambio de mapa de asientos no se veía hasta reiniciar. Quitar el Singleton no arreglaba esto —un caché por instancia con el mismo defecto tiene el mismo bug—; solo lo dejó a la vista. Lo entrego por separado y con su propia prueba.
Esa última distinción es lo que se busca en este ejercicio: quitar un anti-patrón no arregla los bugs que ese anti-patrón hacía difíciles de ver; solo los deja a la vista. Reportarlos por separado, con su propia prueba, es lo que hace confiable una entrega.
Ejercicio 3 — Escribe el DECISIONES.md completo. Junta todo en una página. Recuerda: la sección de lo que NO tocaste es la que se lee primero.
Ver solución
# Ordenar la creación de canales de notificación
## Qué cambié y por qué
**Factory de canales (`notifications/factory.py`).** El conocimiento de qué canales
existen y cómo se construye cada uno vivía en tres lugares con tres formas distintas:
`notifier.py` (construía los tres), `admin/broadcast.py` (un `elif` por canal) y
`api/routes.py` (una tupla de validación copiada a mano). Agregar un canal tocaba
esos tres, y el de la validación era el más fácil de olvidar.
Ahora hay un registro consultable y un error propio que lista las opciones reales.
**Cuesta:** un archivo más y un salto de lectura al rastrear una notificación.
**`broadcast.py` construía el canal dentro del bucle.** Un `EmailChannel` por cada
comprador: para un evento de 5.000 boletos, 5.000 conexiones SMTP. No era el objetivo
del refactor, pero se hizo visible al separar la construcción del envío. Ahora se
construye una vez.
**Canales como parámetro con valor por defecto** en `notify`, `broadcast_cancellation`
y `send_reminders`. Es lo que permite probar sin SMTP. Los llamadores no cambiaron.
## Qué NO abstraje y por qué
**El recordatorio (`reminders/scheduler.py`).** Manda correo siempre, y es una decisión
de negocio deliberada —un SMS a las 3 AM es un problema, no una funcionalidad—. Usar
la factory ahí comunicaría que el canal es configurable cuando no lo es: sería una
abstracción que miente sobre la variabilidad del sistema. Solo le agregué el parámetro
con default, para poder probarlo.
**Lo reconsideraría cuando producto defina reglas de horario y consentimiento para
recordatorios por SMS.** Ese día el canal pasa a ser una decisión real.
**Una clase `Notifier` y un `Protocol` formal.** Los tres canales ya comparten
`send(destination, message)`: el contrato existe de hecho. Declararlo agrega un archivo
sin cambiar ninguna capacidad, porque hoy no corremos verificador de tipos en CI.
**Lo agregaría el día que activemos `mypy`**, que está en el plan del trimestre.
**La elección de destinatario (`destination_for`).** Quedó como un diccionario de tres
entradas en `notifier.py`. Se podría mover a cada canal (`channel.destination_for(customer)`),
y probablemente sea mejor diseño. No lo hice porque obligaría a que los tres canales
conozcan la clase `Customer`, y hoy solo reciben un texto.
**Lo revisaría si aparece un canal cuyo destinatario no salga directo de `Customer`**
—por ejemplo, WhatsApp, que usa el teléfono con formato internacional—.
## Qué quité
**La pila de `notifications/templates.py`** (factory + clase abstracta + provider +
builder, ~35 líneas → 3). La factory tenía una sola plantilla registrada, el `register()`
no se llamaba desde ningún lado, la clase abstracta tenía un implementador y el builder
un solo campo. **Se pierde** el registro dinámico de plantillas, que nunca se usó.
**Lo repondría si vendiéramos integraciones donde el cliente registre las suyas.**
## Entregas separadas
**`SeatMapCache`** va aparte: es un Singleton (estado de clase compartido, imposible
de sustituir en pruebas) **y** tiene un bug independiente —el caché nunca expira, así
que un cambio de mapa de asientos no se ve hasta reiniciar—. Quitar el Singleton no
arregla el bug; solo lo deja a la vista. Van en dos cambios, cada uno con su prueba.
## Cómo se mide la mejora
**Agregar un canal nuevo pasa de tocar tres archivos existentes —uno de ellos, la
validación del endpoint, fácil de olvidar y sin fallo visible— a crear un archivo
nuevo y agregar una línea al registro.** Y el aviso masivo de un evento de 5.000
boletos pasa de abrir 5.000 conexiones SMTP a abrir una.
## Pruebas nuevas
- `test_a_customer_without_phone_gets_no_sms` — verifica una **ausencia**; era
imposible con canales construidos por dentro.
- `test_broadcast_reaches_every_buyer` — recorre 5.000 órdenes falsas sin mandar
un solo correo real. Antes habría tardado minutos y habría enviado correos de verdad.
- `test_unknown_channel_fails_and_lists_the_options` — el error nombra los canales
reales, tomados del registro.
Por qué este documento funciona, y vale la pena verlo pieza por pieza porque es el formato que vas a usar en el trabajo:
La sección de "qué no abstraje" es la más larga, y esa proporción es deliberada. Tres decisiones de no hacer algo, cada una con su condición verificable. Alguien puede leer "cuando activemos mypy" y saber exactamente cuándo revisar.
Los números aparecen en todos lados. Tres archivos, 5.000 conexiones, 35 líneas → 3. Un documento con adjetivos —"quedó más limpio", "es más mantenible"— no se puede discutir y por eso no convence. Uno con números se verifica.
El bug del bucle se reporta aunque no era el objetivo. Encontrar cosas que no venías a buscar es lo normal en un refactor, y decirlo es lo que hace confiable la entrega.
Se separan los problemas distintos. El Singleton y su bug van aparte, y el documento explica por qué son dos cosas y no una.
Ninguna entrada dice "porque es una buena práctica". Cada una nombra el problema concreto y lo que cuesta la solución. Esa es la diferencia entre criterio y reflejo, y es lo único que este proyecto evalúa.
Resumen y siguiente paso
Este proyecto cerró el módulo aplicando sus tres preguntas sobre un rincón real de Boletia con trampas deliberadas. Empezaste por el inventario —tres búsquedas distintas, porque el conocimiento se escribe de formas distintas— y viste que la parte difícil no es contar coincidencias sino clasificarlas: la pregunta que decide es ¿este lugar participa de la decisión, o solo la consume?.
Con el inventario en la mano, aplicaste el filtro de la lección 7 a cada rincón. Tres lugares participaban de la decisión, así que la Factory se ganó su lugar. Uno construía un canal y no participaba —el recordatorio manda correo por una razón de negocio— y ahí la respuesta correcta fue no tocarlo, porque una factory ahí habría mentido sobre la variabilidad del sistema. Desmontaste un Singleton con el método incremental de la lección 5, separando el problema de diseño del bug de negocio que escondía. Y borraste una pila de cinco abstracciones que solo llamaban a un constructor de un parámetro.
Y entregaste el documento, que es lo que de verdad se evalúa. Con la sección de "qué no abstraje" más larga que la de "qué cambié", cada decisión con su condición de reversión verificable, y la mejora medida en archivos tocados y conexiones abiertas en vez de adjetivos.
Antes de avanzar deberías poder: hacer el inventario de una decisión repartida en un sistema que no conoces; distinguir un lugar que participa de la decisión de uno que solo la consume; entregar el refactor en cambios separados por tipo de problema; y escribir el documento de decisiones durante el trabajo y no al final, que es lo único que hace que la sección de lo que no tocaste tenga contenido.
El módulo 5 cambia de familia. Hasta aquí resolviste quién decide qué se construye; con la factory de la lección 3 hasta llegaste a esconder detrás de un contrato tres APIs que no se parecen en nada entre sí. Pero recuerda lo que dejamos anotado como pendiente: esas APIs siguen siendo incompatibles por dentro. El int(round(order.total * 100)) de Stripe sigue viviendo en algún lado, y el día que Stripe cambie su librería, ese archivo se rompe. Contener el desorden no es lo mismo que traducirlo.
Eso es el módulo 5: patrones para estructurar y adaptar. Adapter para que dos interfaces que no se entienden se entiendan. Facade para poner una puerta simple a algo complicado. Decorator para agregar comportamiento —un reintento, un registro, un caché— sin tocar lo original. Es la familia con mejor relación valor/costo en código real, porque el código real siempre tiene un vecino desprolijo. Y va a traer, como todos los módulos de esta guía, su propia pregunta incómoda: ¿no bastaba con escribir una función que envuelva?
Recursos
- Martin Fowler — Refactoring: Improving the Design of Existing Code — el libro de referencia sobre refactorizar en pasos pequeños y seguros. El capítulo 2, sobre cuándo refactorizar y cómo justificarlo ante un jefe, es el más útil para este proyecto.
- Sandi Metz — The Wrong Abstraction — el ensayo que sostiene la sección "qué no abstraje" de tu documento. Léelo antes de escribirla.
- Kent Beck — Tidy First? — un libro corto sobre hacer cambios de orden pequeños y separados de los cambios de comportamiento. Es exactamente la disciplina de las entregas separadas de este proyecto.
- Refactoring Guru — Creational Patterns — para releer la familia completa ahora que la aplicaste. La sección de "relaciones con otros patrones" al pie de cada ficha es la que más valor tiene en esta segunda lectura.