Módulo 6: Patrones para comunicar entre partes

6. El costo escondido: el flujo que ya no puedes seguir con el dedo

Descripción

Al terminar esta lección vas a tener una cosa que casi ningún material sobre eventos te da: el precio, medido, con el recibo en la mano. Vas a vivir un incidente real de Boletia a las tres de la mañana, paso por paso, con el sistema desacoplado que construimos en las lecciones 3 y 5, y vas a ver exactamente en qué momento se rompe la habilidad más básica de un programador: seguir el flujo con el dedo.

Vas a salir con tres cosas. Primero, un inventario preciso de las cuatro habilidades que pierdes cuando pasas de llamadas directas a eventos —y son cuatro habilidades que usas todos los días sin darte cuenta de que las tienes—. Segundo, las cuatro mitigaciones que de verdad funcionan, con su código, porque el costo se puede reducir mucho si se hace a propósito y desde el principio. Y tercero, la lista de mitigaciones falsas: cosas que todo el mundo intenta, que se sienten responsables y que no sirven, porque envejecen mal o porque dependen de que alguien se acuerde.

Esta es, para mí, la lección más importante del módulo, y quiero decir por qué. Los patrones se enseñan casi siempre desde el momento en que se escriben, cuando el autor tiene todo el contexto en la cabeza y el diseño se ve limpio. Casi nunca se enseñan desde el momento en que se leen, que es donde el software pasa la mayor parte de su vida: alguien que no lo escribió, meses después, con presión y sin contexto. Un patrón que se escribe bien y se lee mal es una deuda con interés compuesto, y esta familia es la que más deuda de ese tipo genera.

Conexión con el módulo: las lecciones 2 a 5 construyeron el sistema desacoplado y midieron sus ganancias. Esta lección le pasa la factura. Es el complemento necesario de la lección 3: allí contamos que el flujo pasó de un archivo a siete, aquí vas a sentir lo que significa ese siete. Y prepara directamente la lección 7, que usa este costo como el argumento principal para no convertir en evento lo que no lo necesita. El proyecto de la lección 8 te va a exigir entregar un registro de qué escucha qué, precisamente para que no pierdas lo que aquí vas a ver perderse.

La casa donde nadie sabe qué prende cada interruptor

Piensa en una casa vieja que ha pasado por tres remodelaciones. En el pasillo hay una placa con cuatro interruptores. Nadie sabe qué hace cada uno.

Los subes todos y ves qué se prende. Uno enciende la luz del pasillo, claro. Otro no hace nada visible —resulta que alimenta la bomba de agua del techo, cosa que descubres tres días después cuando no hay presión—. El tercero enciende la luz del cuarto de servicio, que no se ve desde el pasillo. Y el cuarto está conectado a un tomacorriente del garaje que un electricista puso en 2011 y del que no quedó registro.

Fíjate en lo que pasó ahí. La instalación funciona perfectamente. Todos los cables están bien puestos, todo enciende lo que tiene que encender, no hay ningún cortocircuito. El problema no es de funcionamiento: es que no existe el plano. Y sin plano, cualquier cambio se hace a ciegas: quieres poner un foco nuevo y no sabes de qué circuito colgarlo; se cae la luz de una habitación y no sabes qué revisar; vendes la casa y el comprador hereda el misterio.

Ahora piensa en la casa de al lado, construida ayer, con su tablero de distribución rotulado: "circuito 3 — recámaras y pasillo". Los cables son igual de invisibles dentro de la pared. La diferencia no está en la instalación: está en que alguien escribió qué alimenta qué, y lo mantuvo actualizado.

Un sistema con eventos es una casa con cables dentro de las paredes. El desacoplamiento es real y es bueno: puedes agregar un circuito sin romper los demás. Pero si nadie rotula el tablero, cada cambio y cada falla se convierten en una expedición. Y aquí está la parte que hay que decir con todas sus letras: el rótulo no es opcional, es parte del costo del patrón. Si tu presupuesto para introducir eventos no incluye el trabajo de mantener el tablero rotulado, tu presupuesto está incompleto y el sistema se va a degradar sin que nadie tome una mala decisión.

Ejemplo trabajado: 3:14 de la mañana

Suena el teléfono. Es el fundador de Boletia. El organizador del festival más grande del año está furioso: lleva dos días sin recibir los avisos de venta y se enteró porque un asistente le preguntó por qué su panel mostraba menos boletos de los que había vendido.

Tú entras al sistema. Vamos a hacer el diagnóstico dos veces: primero con el sistema de la lección 1, el de las veinte líneas feas, y después con el sistema desacoplado de la lección 3. Cronometrando.

Diagnóstico con llamadas directas.

Abres checkout/checkout.py. Bajas a la sección 4. Ahí está:

    organizer = repository.get_customer(event.organizer_id)
    email_channel.send(organizer.email, build_organizer_alert(order, event))

Dos líneas, y todas las hipótesis a la vista: o event.organizer_id apunta a otro cliente, o el correo del organizador está vacío o mal escrito, o el envío falló. Consultas la base de datos, ves que el organizador tiene un correo válido, y buscas en el registro de errores por smtp. Encuentras: el correo del organizador está en una lista de rebotados desde hace dos días, porque su buzón se llenó.

Un archivo. Tres hipótesis. Ocho minutos. El sistema feo se dejó diagnosticar en ocho minutos.

Diagnóstico con eventos.

Abres checkout/checkout.py. Bajas a la sección 4. Ahí está:

    bus.publish(OrderCompleted(
        order_id=order.id,
        customer_id=order.customer_id,
        event_id=event.id,
        ticket_ids=tuple(t.id for t in tickets),
        subtotal_mxn=order.subtotal,
        service_fee_mxn=order.fee,
        total_charged_mxn=order.total,
        occurred_at=now(),
    ))

Y ahora dime: ¿el organizador recibe un aviso? Este archivo no lo sabe. No hay nada aquí que lo diga. Podría ser que sí, podría ser que la funcionalidad nunca existió, podría ser que existió y alguien la quitó el mes pasado. Estás mirando el código que produce el hecho y no tienes ninguna manera de saber qué pasa después. Ese es el momento exacto en que se rompe el dedo sobre la pantalla, y es el tema de esta lección.

Vas a bus/wiring.py —si sabes que existe— y encuentras las suscripciones. Bien, send_organizer_alert está registrada. Ahora tienes que verificar tres cosas que en el sistema anterior no eran preguntas:

  1. ¿wire_everything() se llamó? Si alguien tocó app.py y la llamada quedó dentro de un if de configuración, el sistema arranca sin suscripciones y no se queja de nada: publicar al vacío es un caso normal.
  2. ¿El manejador corrió y falló? El bus aísla y registra. Buscas en el registro. Encuentras cuatro mil líneas de "Falló send_organizer_alert manejando OrderCompleted" y ninguna dice para qué orden, porque el registro que escribimos en la lección 2 solo pone el nombre del manejador y el del evento.
  3. ¿Es el manejador o es el canal? El manejador llama a EmailChannel().send(...), que a su vez está dentro de notify() en el caso del comprador pero no en el del organizador. Hay que abrir tres archivos para reconstruir la cadena.

Y si además pasaste a comandos en cola, como en la lección 5, se agrega una cuarta pregunta: ¿el trabajo se encoló y nunca se ejecutó? Ahora hay que mirar la tabla de trabajos pendientes, ver si hay filas atascadas, y averiguar si el proceso que los consume está vivo. El proceso que los consume, por cierto, no está en la misma máquina.

Cuatro archivos, una tabla de base de datos, un proceso aparte y seis hipótesis. Cuarenta minutos, a las tres de la mañana. Y con suerte.

Qué esperar de esta comparación. Lo primero: fíjate en que la causa raíz era la misma en los dos casos —el buzón del organizador estaba lleno—. El sistema desacoplado no causó el problema. Lo que hizo fue multiplicar por cinco el tiempo de encontrarlo, y agregar tres hipótesis que no tienen nada que ver con el negocio sino con la maquinaria. Eso es el costo, en su forma más pura: no fallas nuevas, sino diagnósticos más largos de las mismas fallas.

Lo segundo, y quiero que lo mires con atención porque es lo que hace que este costo sea traicionero: se paga en el peor momento. No se paga mientras escribes, cuando tienes tiempo y contexto. Se paga cuando hay un incidente, de noche, con alguien esperando, y con la mitad de tu capacidad de razonar. Un costo que se paga solo en los peores momentos es sistemáticamente subestimado, porque nadie está tomando notas cuando lo paga.

Y lo tercero, la buena noticia: casi todo ese costo es evitable, y es evitable con trabajo que se hace una vez. Las cuatro mitigaciones que vienen habrían reducido esos cuarenta minutos a diez. Ninguna es complicada. Todas exigen decidirlas al principio, porque agregarlas después, cuando ya hay quince suscriptores, es mucho más caro.

Las cuatro habilidades que pierdes

Vale la pena nombrarlas por separado, porque las usas todos los días sin saber que las tienes y solo las extrañas cuando ya no están.

1. Leer el flujo completo en un lugar. Antes, la pregunta "¿qué pasa cuando se completa una compra?" se contestaba leyendo una función de arriba abajo. Ahora se contesta juntando información de varios archivos, y solo si sabes cuáles. Esta es la pérdida grande y es de la que se derivan casi todas las demás.

2. Encontrar quién usa algo. Tu editor tiene una función llamada "buscar usos" o "buscar referencias" y la usas sin pensar. Esa función sigue el grafo de llamadas del programa. El bus rompe ese grafo: si buscas usos de send_organizer_alert, el editor te va a mostrar una línea en wiring.py y nada más, porque nadie la llama por su nombre. Y al revés: si estás en checkout.py y quieres saber quién reacciona, no hay ninguna referencia que seguir. La herramienta no está rota; es que la información que buscas ya no está en el código, está en los datos que el código construye al arrancar.

3. Leer la pila de llamadas. Con despacho síncrono la pila sobrevive, pero llena de ruido: checkout → publish → handler, con el bus en medio de todo. Con despacho asíncrono o con comandos en cola, la pila se corta: el error del manejador ocurre en otro proceso, en otro momento, y su pila no menciona la compra que lo originó. La pregunta "¿de dónde venía esto?" deja de tener respuesta técnica y pasa a depender de que alguien haya puesto un identificador de correlación.

4. Confiar en que lo que lees es todo lo que pasa. Esta es la más sutil y la más peligrosa. Antes, si leías la función completa, sabías que habías leído todo. Ahora, leer no te da certeza: siempre puede haber un suscriptor que no viste, registrado desde un módulo que no sabías que existía. Pasas de "sé lo que hace este código" a "sé lo que hace este código, más lo que hagan quienes escuchen". Y esa incertidumbre tiñe cada cambio que hagas después.

Las cuatro mitigaciones que funcionan

Mitigación 1 — Un registro central que se pueda leer y que no se pueda desactualizar.

El wiring.py de la lección 3 ya es media solución. La otra media es hacer que el sistema pueda contarte su propio cableado, para que la información no dependa de que alguien la escriba:

# Archivo: bus/bus.py — agregado al EventBus

    def describe(self) -> str:
        """Imprime el cableado completo. Se corre a mano o al arrancar.

        Esta función es el tablero rotulado de la casa. Su valor no está
        en que sea sofisticada —son diez líneas— sino en que la información
        sale del bus real y no de un documento que alguien mantiene a mano.
        Un documento se desactualiza; esto no puede.
        """
        lines = []
        for event_type, handlers in sorted(
            self._subscribers.items(), key=lambda kv: kv[0].__name__
        ):
            lines.append(f"{event_type.__name__}:")
            for handler in handlers:
                module = handler.__module__
                doc = (handler.__doc__ or "").strip().split("\n")[0]
                lines.append(f"  - {module}.{handler.__name__}  # {doc}")
        return "\n".join(lines)

Con eso, python -m bus.describe da:

OrderCompleted:
  - inventory.subscribers.mark_tickets_sold        # Marca los boletos como vendidos...
  - notifications.subscribers.send_buyer_confirmation  # Confirmación al comprador...
  - notifications.subscribers.send_organizer_alert     # Aviso al organizador. Solo correo.
  - billing.subscribers.issue_invoice              # Emite la factura y se la manda...
  - analytics.subscribers.track_order_paid         # Registra la venta y refresca...
  - loyalty.subscribers.add_loyalty_points         # Un punto por cada diez pesos.

Los cuarenta minutos del incidente empiezan a bajar aquí: la pregunta "¿el organizador recibe un aviso?" se contesta en quince segundos, y con la certeza de que la respuesta sale del sistema vivo.

Y el remate, que es lo que hace que esto no se degrade:

# Archivo: tests/test_wiring.py

def test_the_wiring_matches_the_documented_map():
    """El mapa versionado tiene que coincidir con el cableado real.

    Si alguien agrega un suscriptor y no actualiza docs/event-map.txt,
    esta prueba falla y le dice exactamente qué correr para arreglarlo.
    Así el documento no puede envejecer: el sistema no lo permite.
    """
    wire_everything()
    documented = Path("docs/event-map.txt").read_text().strip()
    assert bus.describe() == documented, (
        "El cableado cambió. Corre `python -m bus.describe > docs/event-map.txt` "
        "y súbelo con tu cambio."
    )

Es una prueba de doce líneas y hace algo que ninguna cantidad de buena voluntad logra: convierte mantener el mapa en un requisito mecánico en vez de en una disciplina. Toda la diferencia entre documentación que sirve y documentación que miente está en si el sistema la verifica.

Mitigación 2 — Trazas con un identificador que atraviesa todo.

El problema del registro con cuatro mil líneas inútiles se arregla con una idea vieja y barata: cada operación lleva un identificador único que viaja con ella por todas partes.

# Archivo: bus/events.py

@dataclass(frozen=True)
class OrderCompleted:
    order_id: int
    # ... el resto de los campos ...
    trace_id: str            # el hilo que conecta todo lo que causó esta compra
# Archivo: bus/bus.py

    def publish(self, event):
        name = type(event).__name__
        handlers = self._subscribers[type(event)]
        trace = getattr(event, "trace_id", "-")
        self._logger.info("publish %s trace=%s handlers=%d", name, trace, len(handlers))

        for handler in handlers:
            started = time.monotonic()
            try:
                handler(event)
                self._logger.info(
                    "  ok %s trace=%s ms=%d",
                    handler.__name__, trace, (time.monotonic() - started) * 1000,
                )
            except Exception:
                # El nombre del manejador Y el identificador de traza.
                # Sin el segundo, el registro dice que algo falló pero no
                # para quién, y es exactamente igual de inútil que nada.
                self._logger.exception(
                    "  FALLÓ %s trace=%s", handler.__name__, trace,
                )

Y el registro pasa de esto:

ERROR  Falló send_organizer_alert manejando OrderCompleted
ERROR  Falló send_organizer_alert manejando OrderCompleted
ERROR  Falló send_organizer_alert manejando OrderCompleted

a esto:

INFO   publish OrderCompleted trace=7f3a9b handlers=6
INFO     ok mark_tickets_sold trace=7f3a9b ms=12
INFO     ok send_buyer_confirmation trace=7f3a9b ms=340
ERROR    FALLÓ send_organizer_alert trace=7f3a9b
         SMTPRecipientsRefused: 552 mailbox full — organizer@festival.mx
INFO     ok issue_invoice trace=7f3a9b ms=88
INFO     ok track_order_paid trace=7f3a9b ms=5
INFO     ok add_loyalty_points trace=7f3a9b ms=3

Ese bloque contesta, de un vistazo, las seis hipótesis del incidente: se publicó, había seis suscriptores registrados, corrieron los seis, uno falló, y el mensaje del error dice literalmente cuál es la causa raíz. Cuarenta minutos se convierten en dos.

El identificador de traza tiene que nacer en la petición HTTP —en api/routes.py— y viajar en el evento, en el comando encolado y en cada línea de registro. Si el comando va a una cola, el identificador viaja con él, y así el trabajo que se ejecuta media hora después en otro proceso sigue estando conectado con la compra que lo originó. Es la única forma de reparar la habilidad número 3 de la lista de arriba.

Mitigación 3 — Nombres que digan qué pasa, no cómo está hecho.

Esta no cuesta nada y se descuida siempre. Tres reglas:

  • Nada de funciones anónimas en el cableado. bus.subscribe(OrderCompleted, lambda e: ...) produce un registro que dice <lambda> y un mapa inútil. Toda suscripción es una función con nombre.
  • El nombre del manejador dice qué hace, no que es un manejador. send_organizer_alert, no on_order_completed ni handle_order. Cuando cinco manejadores del mismo evento se llaman on_order_completed en cinco módulos distintos, el registro y el mapa dejan de servir.
  • La primera línea del comentario del manejador es su descripción en el mapa. Ya lo aprovecha describe(). Es una razón concreta para escribirla bien.

Mitigación 4 — Un techo para la cantidad de eventos.

La menos técnica y la que más decide el resultado a dos años. Un acuerdo de equipo, escrito en el propio bus/events.py:

"""Eventos de dominio de Boletia.

ACUERDO DEL EQUIPO (revisado en cada trimestre):
  - Máximo 8 tipos de evento en todo el sistema. Hoy hay 4.
  - Un evento nuevo requiere al menos DOS suscriptores previstos.
    Con uno solo, es una llamada directa disfrazada.
  - Un evento que se queda con un solo suscriptor durante un trimestre
    se retira y se convierte en llamada directa.
"""

Ese comentario no lo hace cumplir ninguna herramienta, y aun así funciona, porque convierte "¿agregamos un evento?" en una conversación explícita con un criterio compartido, en vez de en una decisión individual invisible. Un bus con cuatro tipos de evento se puede tener en la cabeza. Uno con cuarenta, no, y no hay mitigación técnica que arregle eso.

Las mitigaciones falsas

Estas se intentan siempre, se sienten responsables y no funcionan. Vale la pena conocerlas para no gastar esfuerzo ahí.

Un documento escrito a mano. Alguien crea docs/arquitectura-de-eventos.md con una tabla preciosa de qué escucha qué. Dura dos meses. Después alguien agrega un suscriptor con prisa, no actualiza el documento, y a partir de ese momento el documento es peor que no tener nada: da confianza falsa y manda a la gente en direcciones equivocadas. Un documento sobre el código que el código no verifica siempre termina mintiendo. Si vas a escribir el mapa, genéralo desde el sistema y verifícalo con una prueba, como en la mitigación 1.

Un comentario en el checkout que liste los suscriptores. Es la misma trampa, más cerca del código y por eso más creíble. Envejece igual de mal, con el agravante de que quien lo lea va a confiar en él más que en un documento aparte.

Confiar en "buscar usos" del editor. No funciona a través del bus, y no es un defecto del editor: la relación no existe en el código, existe en la estructura de datos que el código construye al arrancar. Ninguna herramienta estática la puede ver.

Buscar por texto el nombre del evento. Ayuda un poco y falla justo cuando más lo necesitas: encuentra los subscribe escritos de la forma esperada y se pierde los que están dentro de un decorador, los que arman el nombre dinámicamente y los que se registran desde un módulo que solo se importa con cierta configuración. Una búsqueda que funciona el 90% de las veces es, en un incidente, una fuente de conclusiones equivocadas.

"Que cada quien documente su suscriptor." Reparte la información en tantos lugares como suscriptores hay, que es exactamente el problema que estamos tratando de resolver.

El patrón detrás de las cinco: todas dependen de que un humano se acuerde, y todas fallan silenciosamente cuando alguien no se acuerda. Las cuatro que funcionan tienen en común lo contrario: o salen del sistema vivo, o hay una prueba que las hace fallar.

El costo de aprendizaje, que nadie cuenta

Hay un último costo que no aparece en ninguna medición técnica y que en un equipo chico pesa más que todos los anteriores.

Boletia tiene seis personas. Antes del refactor, alguien que entraba al equipo podía leer checkout.py y entender la compra en una tarde: era feo, era largo, pero era lineal y completo. Después del refactor tiene que entender qué es un bus, qué es un evento, dónde está el cableado, por qué el inventario se quedó adentro y los avisos no, qué es la cola de comandos y por qué hay un proceso aparte. Eso no es una tarde: es dos o tres días, y es un tema del que va a dudar durante meses.

Ese costo se paga una vez por cada persona que toque el sistema, incluidas todas las futuras. En un equipo de sesenta personas donde el checkout es de un equipo y las notificaciones de otro, se amortiza rápido: la ganancia de que dos equipos no se pisen es enorme. En un equipo de seis donde todos tocan todo, puede que nunca se amortice.

Ese cálculo —cuánta gente, qué tan separada, cuántos interesados nuevos por año— es lo que decide de verdad si esta familia de patrones te conviene. No es un cálculo técnico. Es la lección 7 completa, y por eso viene inmediatamente después de esta.

Errores comunes

Introducir el bus sin introducir el tablero (de criterio). Qué pasa: se hace el refactor, queda bonito, se despliega, y las mitigaciones quedan "para después". Después no llega nunca, porque no hay dolor todavía. El dolor llega a los ocho meses, con quince suscriptores, cuando agregar describe() y las trazas ya es un proyecto en vez de una tarde. Por qué pasa: porque el refactor tiene un resultado visible y las mitigaciones no; nadie aplaude un identificador de traza. Cómo detectarlo: si tu cambio agrega un bus y no agrega ni una línea de registro nueva ni una forma de listar el cableado, está incompleto. Cómo corregirlo: trátalas como parte del mismo cambio, no como mejora futura. La regla que yo usaría: el bus y su describe() se escriben en el mismo commit. Son doce líneas más; no hay excusa.

Registrar que algo falló sin registrar para qué (conceptual). Qué pasa: el bus atrapa la excepción y escribe "falló el manejador X". Meses después hay miles de esas líneas y ninguna sirve, porque no dicen para qué orden, para qué cliente ni en qué momento del flujo. Por qué pasa: porque al escribir el except uno piensa en "que quede registro", no en "que alguien pueda actuar sobre este registro a las tres de la mañana". Cómo detectarlo: mira una línea de error de tu sistema y pregúntate si con ella sola puedes empezar a investigar. Si necesitas otra cosa para saber de qué caso se trata, la línea no sirve. Cómo corregirlo: identificador de traza y de la entidad principal en cada línea, siempre. Un registro sin identificador de correlación es un ruido con sello de responsabilidad.

Creer que el desacoplamiento del código desacopla el entendimiento (conceptual). Qué pasa: se declara que el sistema quedó "modular" porque ningún archivo importa a otro. Pero para responder cualquier pregunta de negocio hay que abrir siete archivos, así que en la práctica nadie entiende una parte sin entender el todo — exactamente lo contrario de lo que se buscaba. Por qué pasa: por confundir dos cosas que se llaman igual. El acoplamiento de compilación —quién importa a quién— bajó de verdad. El acoplamiento conceptual —cuánto necesitas saber del resto para entender una parte— puede haber subido. Cómo detectarlo: dale una pregunta de negocio a alguien que no escribió el código y cronométralo. Es la única medición honesta que conozco para esto. Cómo corregirlo: no se corrige quitando el patrón; se corrige con el tablero rotulado. El objetivo no es que nadie necesite ver el todo, es que ver el todo cueste quince segundos y no cuarenta minutos.

Ejercicios

Ejercicio 1 — Reconstruye el flujo con las herramientas de cada diseño. Llega una pregunta del área legal: "¿en qué momento exacto se emite la factura de una compra y qué pasa si falla?". Escribe los pasos que darías para contestarla (a) en el sistema de la lección 1, (b) en el sistema desacoplado sin mitigaciones, y (c) en el sistema desacoplado con las cuatro mitigaciones. Estima el tiempo de cada uno.

Ver solución

(a) Llamadas directas. Abres checkout.py, buscas billing, encuentras el bloque. Ves que corre después de las notificaciones y antes de analítica, y que si falla, la excepción sube y la petición devuelve 500 —con el cobro ya hecho—. Un archivo, respuesta completa incluyendo el comportamiento ante fallas. Cinco minutos.

(b) Desacoplado sin mitigaciones. Abres checkout.py y encuentras un publish. Buscas dónde está el cableado —si nadie te dijo que existe wiring.py, esto solo ya te toma un rato—. Encuentras issue_invoice. Abres billing/subscribers.py y lees la lógica. Ahora la segunda mitad de la pregunta: ¿qué pasa si falla? Hay que abrir bus/bus.py y leer el publish para descubrir que atrapa y registra. Y para saber si el orden importa, hay que leer las seis suscripciones y razonar si alguna depende de otra. Cuatro archivos y una conclusión con dudas. Treinta minutos, y con la incomodidad de no estar seguro de que no hay otro suscriptor en algún lado.

(c) Desacoplado con mitigaciones. Corres python -m bus.describe y ves las seis suscripciones con su descripción, incluida issue_invoice # Emite la factura y se la manda al comprador. Abres ese archivo y lees la lógica. Para el comportamiento ante fallas, buscas en los registros un caso real por el identificador de traza y ves la secuencia completa con tiempos. Dos archivos y un comando. Ocho minutos, y con certeza.

Lo que muestra la comparación: el sistema desacoplado con mitigaciones sigue siendo un poco más lento de leer que el acoplado —ocho minutos contra cinco— y eso es honesto y esperable. El desastre no es el patrón: es el patrón sin tablero. La diferencia entre (b) y (c) es más grande que la diferencia entre (a) y (c), y (c) cuesta una tarde de trabajo, una sola vez.

Ejercicio 2 — Diseña la prueba que evita el peor bug. El bug más difícil de esta arquitectura es que wire_everything() no se llame, o que se llame a medias, y el sistema arranque sin suscripciones sin quejarse de nada. Escribe una prueba que lo haga imposible, y explica por qué el bus no debería quejarse solo cuando publica sin suscriptores.

Ver solución
# Archivo: tests/test_wiring.py

REQUIRED = {
    OrderCompleted: {
        "mark_tickets_sold", "send_buyer_confirmation", "send_organizer_alert",
        "issue_invoice", "track_order_paid", "add_loyalty_points",
    },
    OrderCancelled: {"release_tickets", "notify_cancellation"},
}


def test_every_required_subscription_is_wired():
    """Falla si alguien borra una suscripción o si wire_everything() no
    registra todo. Es la red que atrapa el bug más silencioso del diseño:
    un sistema que arranca sin escuchar a nadie y no se queja."""
    fresh = build_app_bus()          # el mismo arranque que usa producción
    for event_type, expected in REQUIRED.items():
        actual = {h.__name__ for h in fresh.subscribers_of(event_type)}
        assert expected == actual, (
            f"{event_type.__name__}: faltan {expected - actual}, "
            f"sobran {actual - expected}"
        )

Dos detalles del diseño de la prueba. Primero, usa el mismo camino de arranque que producción (build_app_bus()), no una versión de prueba: si la prueba arma su propio bus, verifica algo que no es lo que corre. Segundo, verifica igualdad y no solo inclusión, así que también avisa cuando sobra un suscriptor. Enterarte de que alguien agregó una reacción a la compra sin decirlo es igual de valioso que enterarte de que quitó una.

Por qué el bus no debe quejarse al publicar sin suscriptores. Porque publicar al vacío es un caso legítimo y frecuente: un evento puede existir para que alguien lo escuche en el futuro, o los suscriptores pueden depender de la configuración —un entorno de pruebas sin notificaciones, por ejemplo—. Si el bus lanzara una excepción o una advertencia cada vez, tendrías ruido constante en desarrollo y en pruebas, y el ruido constante es cómo se aprende a ignorar las alertas. La distinción es la de la lección 5: publicar un hecho que nadie escucha es normal; encolar una orden que nadie ejecuta es un bug. La verificación de que las suscripciones esperadas existen es un asunto de las pruebas de arranque, no del bus en tiempo de ejecución.

Ejercicio 3 — Ponle precio al costo. Tu jefe pregunta si vale la pena mantener la arquitectura de eventos o si conviene volver a llamadas directas. Arma un argumento con números estimados: inventa cifras plausibles para (a) incidentes al año que involucran el flujo de compra, (b) minutos extra de diagnóstico por incidente, (c) interesados nuevos al año, (d) horas ahorradas por interesado nuevo. Concluye.

Ver solución

Números plausibles para una Boletia de seis personas —son hipótesis para estructurar la discusión, no datos, y lo primero que diría en esa conversación es que habría que medirlos de verdad:

Costo. Unos ocho incidentes al año tocan el flujo de compra. Sin mitigaciones, unos treinta minutos extra de diagnóstico cada uno: cuatro horas al año. Más el costo de aprendizaje: dos personas nuevas al año, dos días cada una para entender el sistema completo, de los cuales tal vez medio día es atribuible a la arquitectura de eventos: ocho horas al año. Total, algo así como doce horas al año.

Beneficio. Unos tres interesados nuevos al año. Con llamadas directas, cada uno son unas seis horas: escribir el cambio en checkout.py, pedir revisión al equipo que lo cuida, esperar, corregir, desplegar con el riesgo de tocar el corazón del sistema. Con eventos, unas dos horas: archivo nuevo, línea en el cableado, sin coordinación. Ahorro de cuatro horas por interesado: doce horas al año.

Conclusión con esos números: empatan. Y esa es exactamente la respuesta más útil que puedes dar, porque muestra que la decisión no es obvia y depende de tres variables que sí se pueden mover:

  1. Si aplicas las mitigaciones, el costo de diagnóstico baja de treinta minutos extra a cinco. Doce horas de costo se vuelven cinco. El balance se inclina claramente a favor de los eventos, y el trabajo de conseguirlo es una tarde.
  2. Si el número de interesados nuevos baja a uno al año, el beneficio se desploma y conviene volver atrás. Esa es la señal de la lección 7.
  3. Si el equipo crece y se separa, el beneficio por interesado sube mucho, porque el costo real de la llamada directa no son las seis horas de trabajo: es la coordinación entre dos equipos.

Por qué funciona: te obliga a expresar una decisión de diseño en la única unidad que hace comparables las dos opciones, que es tiempo de gente. Y te muestra que la respuesta correcta a "¿eventos o llamadas directas?" casi nunca es una propiedad del patrón: es una propiedad de tu equipo y de tu ritmo de cambio.

Resumen y siguiente paso

En esta lección le pasaste la factura al diseño desacoplado. Viviste el mismo incidente dos veces —el organizador que no recibió su aviso— y viste que la causa raíz era idéntica mientras que el tiempo de encontrarla se multiplicaba por cinco, con tres hipótesis nuevas que no tenían que ver con el negocio sino con la maquinaria.

Nombraste las cuatro habilidades que se pierden: leer el flujo completo en un lugar, encontrar quién usa algo, leer la pila de llamadas, y confiar en que lo que lees es todo lo que pasa. Y aprendiste las cuatro mitigaciones que de verdad funcionan —un describe() que sale del sistema vivo y una prueba que lo verifica, trazas con identificador de correlación, nombres explícitos, y un techo acordado para la cantidad de eventos— junto con las cinco que no funcionan, todas con el mismo defecto: dependen de que un humano se acuerde.

Y viste el costo que ninguna medición técnica captura: el de aprendizaje, que se paga una vez por cada persona que toque el sistema, y que en un equipo chico puede no amortizarse nunca.

Antes de avanzar deberías poder: explicar por qué "buscar usos" del editor deja de funcionar con un bus; escribir el describe() y la prueba que lo mantiene honesto; distinguir una mitigación real de una falsa por la pregunta "¿esto depende de que alguien se acuerde?"; y argumentar el costo del patrón en horas de gente.

Lo que viene es la consecuencia natural de todo esto. Si el costo es real y se paga en los peores momentos, la pregunta importante deja de ser "¿cómo implemento Observer?" y pasa a ser "¿cuándo no debería?". La lección 7 es la vacuna del módulo: los casos donde una llamada directa es más honesta, más fácil de depurar y —esto pesa más de lo que parece— más fácil de borrar. Y las señales concretas de que sí conviene el evento, para que la decisión deje de ser una cuestión de gusto.

Recursos

  • Martin Fowler — What do you mean by "Event-Driven"? — la sección final, sobre las desventajas, coincide punto por punto con esta lección y viene de alguien que ha visto muchos sistemas así.
  • Django — Signals — su advertencia sobre preferir una llamada directa cuando el emisor y el receptor son conocidos está escrita por gente que mantiene el framework y ve los reportes de bugs.
  • Python — logging — para el registro estructurado de la mitigación 2. Vale la pena mirar LoggerAdapter y extra, que es la forma limpia de meter el identificador de traza en cada línea sin repetirlo a mano.
  • OpenTelemetry — Traces — la versión industrial del identificador de correlación. No hace falta adoptarla en un sistema del tamaño de Boletia, pero su modelo mental —una traza, varios tramos, un padre— es exactamente el que quieres tener en la cabeza.