Módulo 6: Patrones para comunicar entre partes

2. Observer: avisar sin saber a quién

Descripción

Al terminar esta lección vas a poder hacer cuatro cosas con Observer. Primero, reconocerlo en código ajeno, que es más fácil de lo que parece porque tiene una huella muy distintiva: un lugar donde se guarda una lista de "interesados" y otro donde se recorre esa lista. Segundo, escribirlo en su forma mínima en Python —y te va a sorprender lo corto que es—. Tercero, entender por qué funciona, que no es "porque desacopla" sino algo mucho más preciso: porque invierte la dirección de la dependencia, y vas a poder dibujar esa inversión con flechas. Y cuarto, conocer las cuatro decisiones que el patrón te obliga a tomar y que casi ninguna explicación menciona, aunque son las que deciden si tu implementación va a envejecer bien o mal.

Observer es el patrón que más lejos ha llegado fuera de su propio nombre. Cada vez que escribes element.addEventListener("click", ...) en un navegador estás usando Observer. Las señales de Django son Observer. Los hooks de WordPress son Observer. Los sistemas de "webhooks" son Observer con la red en medio. Casi todo lo que hoy se llama "arquitectura orientada a eventos" es este patrón, escalado hasta el punto de necesitar infraestructura propia. Aprenderlo bien aquí, en su versión de doce líneas, te da la llave para entender todas esas versiones grandes.

Y hay una razón práctica para aprenderlo en su versión chica: la mayoría de los programadores conoce Observer solo a través de una librería o un framework, es decir, ya envuelto, con su magia puesta y sus decisiones tomadas por otro. Cuando lo escribes tú mismo en doce líneas, esas decisiones quedan expuestas —qué pasa si un manejador falla, en qué orden corren, si corren en el mismo hilo— y entiendes de golpe por qué el framework que usas se comporta como se comporta.

Conexión con el módulo: la lección 1 te dejó el problema —el checkout de Boletia terminó conociendo medio sistema— y el vocabulario: hecho, publicador, suscriptor, suscripción. Esta lección te da el mecanismo que resuelve ese problema, en abstracto y con el bus más pequeño que se puede escribir. La lección 3 lo aplica sobre el caso real de Boletia, con sus tres suscriptores y sus campos que pueden ser None, y mide el resultado. La lección 4 desarma lo que aquí solo vamos a nombrar: que el acoplamiento no desapareció, se mudó a la forma del evento. Y la lección 6 vuelve sobre las cuatro decisiones de esta lección, pero desde el otro lado: desde alguien que a las tres de la mañana trata de entender por qué no llegó un correo.

La revista que llega sola

Piensa en cómo funcionaba una suscripción a una revista, de las que llegaban por correo a la casa.

La editorial imprime el número de marzo. No conoce a sus lectores; no tiene idea de quién es cada uno ni por qué se suscribió. Lo único que tiene es una lista de direcciones, y esa lista no la escribió la editorial: la escribió cada suscriptor cuando llenó el cupón y lo mandó. La editorial imprime y entrega la pila al correo. Se acabó su trabajo.

Fíjate en tres cosas de ese arreglo, porque son exactamente las tres del patrón.

La editorial no cambia cuando cambian los lectores. Se suscriben doscientos nuevos este mes; la editorial hace lo mismo que hacía. Se dan de baja cincuenta; la editorial hace lo mismo. El proceso de imprimir la revista es completamente indiferente a quién la recibe. Compara con la alternativa: una editorial que tuviera una lista escrita dentro de su proceso de impresión, y que cada vez que entra un lector nuevo tuviera que modificar ese proceso. Suena absurdo dicho así. Es exactamente lo que hace el checkout de Boletia.

Quien se suscribe es quien toma la iniciativa. La editorial nunca fue a buscar a nadie. Cada lector decidió que le interesaba y se anotó. Esa dirección —del interesado hacia la fuente, y no al revés— es el corazón del asunto y la vamos a dibujar en un rato con flechas.

Hay un lugar donde vive la lista. No está en la cabeza del editor ni en la de los lectores: está en un fichero, una base de datos de suscripciones. Ese fichero es una pieza aparte, y quién lo mantiene y qué tan visible es va a resultar, en la lección 6, el detalle más importante de todos.

Ahora fíjate en lo que se perdió, porque la analogía también sirve para eso. La editorial ya no sabe si su revista llegó. Si el número de marzo se perdió en el correo para trescientos lectores, la editorial no se entera; se enteran los lectores, que reclaman. Y la editorial no controla el orden: no puede decidir que el señor de la esquina la reciba antes que la señora del centro. Publicó, y perdió el control.

Ese es el trato completo. Con eso ya puedes leer el patrón; vamos al código.

Ejemplo trabajado: el bus de eventos más pequeño que se puede escribir

Voy a escribir Observer entero, funcionando, y después lo desarmamos. Prepárate para una decepción productiva: es más corto de lo que esperas.

Paso 1: el hecho. Un evento es un dato, no un objeto con comportamiento. Es la descripción de algo que ya pasó. En Python, la forma natural de escribir "un dato con campos y sin comportamiento" es una dataclass, y la marcamos frozen=True para que nadie pueda modificarla.

# Archivo: bus/events.py
from dataclasses import dataclass


@dataclass(frozen=True)
class OrderCompleted:
    """Se completó el pago de una orden. Ya pasó; no se puede deshacer.

    frozen=True hace que la instancia sea inmutable. Es importante: un hecho
    del pasado no debería poder cambiar mientras tres suscriptores lo leen.
    Sin frozen, el primer manejador podría modificar el evento y el segundo
    recibiría algo distinto — un bug precioso de encontrar a las 3 de la mañana.
    """
    order_id: int
    customer_id: int
    event_id: int              # el concierto; ojo con la palabra "event" en Boletia
    ticket_ids: tuple[int, ...]
    total: float
    occurred_at: str

Dos detalles que parecen menores y no lo son. El nombre está en pasado: OrderCompleted, no CompleteOrder ni NotifyOrderCompleted. Un hecho informa, no ordena. Y ticket_ids es una tuple y no una list, porque una lista sería mutable y rompería la inmutabilidad que acabamos de pedir. Volveremos sobre las dos decisiones en la lección 4, que trata enteramente sobre cómo se diseña un evento.

Paso 2: el bus. Aquí está el patrón completo.

# Archivo: bus/bus.py
from collections import defaultdict


class EventBus:
    """El lugar donde se juntan quienes publican y quienes escuchan.

    No sabe nada de Boletia. No conoce órdenes, ni boletos, ni clientes.
    Solo sabe guardar funciones en un diccionario y llamarlas después.
    """

    def __init__(self):
        # La clave es el TIPO de hecho; el valor, la lista de interesados.
        # defaultdict(list) nos ahorra preguntar si la clave existe.
        self._subscribers = defaultdict(list)

    def subscribe(self, event_type, handler):
        """Registra a un interesado. handler es cualquier cosa llamable
        que reciba el evento como único argumento."""
        self._subscribers[event_type].append(handler)

    def publish(self, event):
        """Le entrega el hecho a todos los interesados en su tipo.

        Si nadie está suscrito, no pasa nada y no es un error: publicar
        al vacío es un caso normal, no una falla.
        """
        for handler in self._subscribers[type(event)]:
            handler(event)

Eso es Observer. Trece líneas de código real. Puedes copiarlo a un proyecto tuyo hoy mismo.

Paso 3: los suscriptores. Un suscriptor es una función que recibe el hecho y hace algo con él. Nada más.

# Archivo: notifications/subscribers.py
from bus.events import OrderCompleted
from data import repository
from notifications.notifier import notify


def send_buyer_confirmation(order_completed: OrderCompleted) -> None:
    """Le manda la confirmación al comprador por los canales que tenga."""
    customer = repository.get_customer(order_completed.customer_id)
    order = repository.get_order(order_completed.order_id)
    # Reusamos notify(), que ya existía y ya sabe elegir canales por cliente.
    notify(customer, build_confirmation(order))


# Archivo: inventory/subscribers.py
from bus.events import OrderCompleted


def mark_tickets_sold(order_completed: OrderCompleted) -> None:
    """Marca como vendidos los boletos de la orden y ajusta el disponible."""
    for ticket_id in order_completed.ticket_ids:
        ticket = repository.get_ticket(ticket_id)
        ticket.status = "sold"
        repository.save_ticket(ticket)
    inventory.decrement_available(
        order_completed.event_id,
        len(order_completed.ticket_ids),
    )

Paso 4: la suscripción. Alguien tiene que conectar las dos partes. Ese "alguien" es un archivo aparte, y su existencia es una de las decisiones más importantes del diseño.

# Archivo: bus/wiring.py
"""El registro central: aquí, y solo aquí, se dice quién escucha qué.

Este archivo existe por una razón que vas a agradecer en la lección 6:
si las suscripciones se hacen sueltas en cada módulo, la respuesta a
"¿qué pasa cuando se completa una compra?" deja de estar en ningún lado.
"""
from bus.bus import EventBus
from bus.events import OrderCompleted
from notifications.subscribers import send_buyer_confirmation, send_organizer_alert
from inventory.subscribers import mark_tickets_sold
from analytics.subscribers import track_order_paid

bus = EventBus()


def wire_everything() -> None:
    """Se llama una vez al arrancar la aplicación, desde app.py."""
    bus.subscribe(OrderCompleted, mark_tickets_sold)
    bus.subscribe(OrderCompleted, send_buyer_confirmation)
    bus.subscribe(OrderCompleted, send_organizer_alert)
    bus.subscribe(OrderCompleted, track_order_paid)

Paso 5: el publicador. Y ahora, el checkout:

# Archivo: checkout/checkout.py

    # ---- 4. Se avisa que la compra ocurrió -----------------------------
    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),
        total=order.total,
        occurred_at=now(),
    ))

    return order

Ocho líneas donde había veinte. Y —esto es lo importante— checkout.py ya no importa email_channel, ni sms_channel, ni push_channel, ni billing, ni analytics, ni reports, ni loyalty. Importa el bus y una clase de datos.

Qué esperar de este ejemplo. Lo primero que suele sorprender es que el bus no sabe nada del dominio. Podrías sacar bus/bus.py de Boletia y pegarlo en un sistema de logística sin cambiar una letra. Esa neutralidad total no es casualidad: es la señal de que la pieza es genuinamente reusable, a diferencia de las "clases base" que uno escribe a veces y que están llenas de suposiciones sobre el caso concreto.

Lo segundo: fíjate en que no hay ninguna interfaz, ninguna clase abstracta, ningún Observer que heredar. En el catálogo original de 1994, Observer se dibuja con una clase abstracta Observer con un método update(), y cada suscriptor hereda de ella. En Python eso es innecesario: una función es un objeto, se puede guardar en una lista y se puede llamar. La versión con clases se justifica cuando el suscriptor necesita mantener estado entre llamadas, y aun así muchas veces basta con una función y una clausura. Es exactamente la discusión de la lección 6 del módulo 3, aplicada a otro patrón: el lenguaje ya te trae media parte resuelta y escribir la ceremonia completa es agregar peso sin ganar nada.

Lo tercero, y quiero que lo mires con desconfianza sana: la lista de suscripciones en wiring.py es la lista que antes estaba dentro del checkout. No desapareció. Se movió a un archivo donde es más fácil de leer y donde tocarla no obliga a revisar el corazón del sistema. Eso es una mejora real y medible. Pero si alguien te dice que "ahora nadie conoce a nadie", enséñale ese archivo: alguien tiene que conocer a todos, y ese alguien es el cableado. La diferencia es que ahora está concentrado y explícito, en vez de repartido dentro de la lógica de negocio.

Y lo cuarto: nota que reusamos notify(). En el módulo 1 descubriste que notifications/notifier.py tenía una función notify(customer, message) que el checkout no usaba —la migración a medias—, y que además tenía reintentos que el checkout no tenía. Al pasar a suscriptores, el manejador de notificaciones es el lugar natural para llamar a notify(), y la migración a medias se cierra sola, casi de regalo. No siempre pasa que un refactor arregle una deuda vieja de paso; cuando pasa, es señal de que el corte que estás haciendo coincide con una junta real del sistema.

Anatomía de Observer: las cuatro piezas

Como todos los patrones, Observer se reconoce por sus piezas. Estas son cuatro, y la cuarta es la que casi nadie nombra.

Pieza 1 — El sujeto (o publicador). Quien produce el hecho. En Boletia, checkout(). Su característica definitoria es negativa: no conoce a ningún suscriptor. Si abres el archivo del sujeto y encuentras el nombre de un suscriptor, no tienes un Observer: tienes una llamada directa con pasos extra.

Pieza 2 — El hecho (o evento, o mensaje). El dato que viaja. En Boletia, OrderCompleted. Es el contrato del patrón: lo único que comparten el publicador y los suscriptores. Todo lo demás está desacoplado; esto no, y por eso la lección 4 existe.

Pieza 3 — Los suscriptores (u observadores, o manejadores). Quienes reaccionan. En Boletia, mark_tickets_sold, send_buyer_confirmation y los demás. Su característica definitoria también es negativa: no conocen al publicador. mark_tickets_sold no sabe que el checkout existe; sabe que existe OrderCompleted.

Pieza 4 — El registro de suscripciones. Dónde se guarda quién escucha qué, y quién lo llena. Esta es la pieza que decide el destino de tu implementación, y hay tres formas de resolverla:

  • Cableado central, como el wiring.py de arriba: un archivo que importa todo y registra todo. Ventaja enorme: hay un lugar donde se lee el sistema completo. Desventaja: ese archivo importa medio código base, y crece.
  • Auto-registro, donde cada módulo se suscribe solo al importarse, a veces con un decorador (@subscribe(OrderCompleted)). Ventaja: agregar un suscriptor toca un solo archivo. Desventaja grave: no existe ningún lugar donde se vea la lista completa, y el registro depende de que el módulo se haya importado, lo que produce bugs fantasma cuando el orden de importación cambia.
  • Configuración externa, en un YAML o similar. Aparece en sistemas grandes y agrega una capa más de indirección; en un sistema del tamaño de Boletia casi nunca vale la pena.

Yo, Mike, tengo una preferencia fuerte aquí y te la digo de frente: cableado central, salvo que tengas una razón concreta para otra cosa. El auto-registro se siente más elegante el día que lo escribes y es el que produce las noches largas de las que habla la lección 6. La elegancia de escritura y la facilidad de lectura no siempre apuntan en la misma dirección, y cuando se pelean, gana la lectura.

La inversión de la dependencia, dibujada

Aquí está el porqué del patrón, y es lo único de esta lección que vale la pena memorizar.

Antes, con llamadas directas, las flechas de dependencia salen todas del checkout:

                    ┌──► notifications/email_channel.py
                    │
  checkout.py ──────┼──► inventory/inventory.py
  (conoce a         │
   todos)           ├──► billing/billing.py
                    │
                    ├──► analytics/tracker.py
                    │
                    └──► loyalty/points.py

Lee ese dibujo como lo que significa: si cambia cualquiera de los cinco de la derecha, hay riesgo de tener que tocar el checkout. Y para probar el checkout hay que tener los cinco disponibles, o simular los cinco.

Después, con eventos, las flechas cambian de sentido:

  checkout.py ──────►  OrderCompleted  ◄────── notifications/subscribers.py
  (solo conoce         (el contrato)   ◄────── inventory/subscribers.py
   el hecho)                           ◄────── billing/subscribers.py
                                       ◄────── analytics/subscribers.py
                                       ◄────── loyalty/subscribers.py

Fíjate bien: nadie apunta al checkout, y el checkout no apunta a nadie. Los seis módulos apuntan a la misma cosa: la definición del hecho. Esa forma —dos lados que dependen de un contrato compartido en vez de depender uno del otro— tiene nombre en el vocabulario de las fundaciones: es inversión de la dependencia. El módulo 4 la vio aplicada a la construcción de objetos; aquí la ves aplicada a la comunicación.

Y ahora la consecuencia práctica, que es la razón por la que uno hace esto: agregar el sexto interesado es crear un archivo nuevo y una línea en wiring.py. No se toca checkout.py. No se pide revisión al equipo que cuida el corazón del sistema. No hay riesgo de romper la venta. El costo de agregar un interesado pasó de "modificar el archivo más delicado del producto" a "crear un archivo que no le importa a nadie más".

Una precisión honesta antes de seguir, porque este dibujo se usa a menudo para vender el patrón de más. La flecha no desapareció: se movió. Los cinco suscriptores siguen dependiendo de algo —de OrderCompleted— y si ese contrato cambia, se rompen los cinco. Lo que ganaste no es "cero acoplamiento": es que el acoplamiento ahora es a un dato estable y explícito en vez de a cinco implementaciones que cambian. Es una mejora grande. No es magia. La lección 4 vive enteramente en esta advertencia.

Las cuatro decisiones que el patrón te obliga a tomar

Aquí es donde la versión de trece líneas se pone interesante. Ese bus mínimo tomó cuatro decisiones sin decírtelo, y las tomó de la forma más simple posible. Cada una tiene alternativa, y elegir mal produce bugs específicos.

Decisión 1 — ¿Los manejadores corren ahora o después? Nuestro publish() llama a los manejadores en el momento y en el mismo hilo. Eso se llama despacho síncrono, y significa que si send_buyer_confirmation tarda dos segundos hablando con el servidor de correo, el cliente que compró espera dos segundos más. El checkout no terminó hasta que terminaron todos los suscriptores.

La alternativa es encolar: publish() deja el hecho en una cola y devuelve el control inmediatamente; otro proceso lo consume. Eso es lo que quiere decir "asíncrono", y es lo correcto para reacciones lentas o que no deben bloquear la venta. Pero cuidado con lo que trae de la mano: si el hecho se encola, puede procesarse después de que la orden cambió de estado, y el manejador tiene que estar preparado para un mundo que ya se movió. Además, la cola es infraestructura —un proceso más que administrar, monitorear y que se puede caer—, y por eso pertenece a la guía de arquitectura de sistemas más que a esta. Lo que sí es de esta guía es saber que la decisión existe, y que el bus de trece líneas la tomó por ti.

Decisión 2 — ¿Qué pasa si un manejador lanza una excepción? Nuestro publish() no atrapa nada. Si mark_tickets_sold explota, la excepción sube por publish(), sale del checkout y llega al cliente como un 500 — y los manejadores que venían después nunca corren. Es decir: reprodujimos exactamente el problema de la lección 1, ahora con más indirección. Eso es importante decirlo: el patrón por sí solo no arregla el aislamiento de fallas. Hay que decidirlo aparte.

La alternativa es aislar cada manejador:

    def publish(self, event):
        for handler in self._subscribers[type(event)]:
            try:
                handler(event)
            except Exception:
                # Un suscriptor que falla no debe tumbar a los demás ni a
                # la operación principal. Pero SÍ tiene que dejar rastro:
                # un except silencioso es cómo se pierden correos sin que
                # nadie se entere durante tres semanas.
                logger.exception(
                    "Falló el manejador %s para %s",
                    handler.__name__, type(event).__name__,
                )

Esa versión es la que casi siempre quieres, con una condición no negociable: el except registra. Un except Exception: pass en un bus de eventos es una de las peores líneas que se pueden escribir en un sistema, porque convierte fallas ruidosas en fallas invisibles.

Y ojo con la trampa: si aíslas los errores, un manejador que era parte de la operación —como marcar los boletos vendidos— ahora puede fallar en silencio mientras la compra se reporta como exitosa. Otra razón más para no convertir en evento lo que no es una reacción.

Decisión 3 — ¿En qué orden corren? En nuestro bus, en el orden en que se suscribieron, porque list.append conserva el orden. Eso significa que el orden de ejecución de tu sistema está determinado por el orden de las líneas de wire_everything(). Es frágil, y es una fuente clásica de bugs: alguien reordena las líneas por prolijidad alfabética y una cosa que dependía de correr después empieza a correr antes.

La regla sana es simple y vale la pena escribirla como comentario en el propio wiring.py: ningún manejador debe depender de que otro haya corrido antes. Si dos reacciones tienen una relación de orden obligatoria, eso no son dos reacciones independientes: es un procedimiento con pasos, y un procedimiento con pasos se escribe como una función, no como dos suscriptores. La lección 7 vuelve sobre esto porque es una de las señales más claras de que el evento no era la herramienta.

Decisión 4 — ¿Se puede dar de baja un suscriptor? Nuestro bus no tiene unsubscribe. Para un cableado que se hace una vez al arrancar, está bien y no agregar el método es lo correcto. Pero si los suscriptores son objetos con ciclo de vida —una pantalla de interfaz gráfica que se abre y se cierra, un objeto por petición— entonces la falta de baja es una fuga de memoria: el bus conserva la referencia para siempre y el objeto nunca se libera. Es el bug clásico de Observer en aplicaciones de escritorio y móviles, y tiene incluso un nombre propio: el problema del lapsed listener.

Cuatro decisiones, cuatro líneas de código de diferencia, y consecuencias completamente distintas. Cuando en tu trabajo uses un bus que escribió otro —o el de un framework—, estas cuatro preguntas son lo primero que hay que ir a averiguar. Vas a encontrar la respuesta más rápido leyendo su publish() que leyendo su documentación.

Qué no es Observer

Tres confusiones frecuentes, que conviene despejar ahora para que puedas usar la palabra sin causar el daño del que hablaba la lección 3 del módulo 1.

No es una cola de mensajes. Una cola —RabbitMQ, Kafka, SQS— es infraestructura: un proceso aparte, con persistencia, con garantías de entrega, con reintentos. Observer es una estructura dentro de un programa: una lista de funciones en memoria. Las dos resuelven el mismo problema conceptual a escalas distintas, y por eso se confunden. La regla mecánica que ya conoces: si involucra la red o un proceso aparte, es arquitectura de sistemas y es otra guía. Si es una lista de funciones en un diccionario, es esta.

No es Mediator. Mediator es el otro patrón de la familia que aparece seguido en el catálogo: un objeto central que coordina a varios colegas, que sí lo conocen a él. La diferencia clave está en quién sabe qué: en Observer el publicador no sabe nada de los suscriptores y el bus es una tubería tonta; en Mediator el mediador sí conoce a los participantes y decide qué hacer con cada uno. Si tu "bus" tiene un if que dice if isinstance(event, OrderCompleted): notificar(), ya no tienes un bus: tienes un mediador, y probablemente sin querer.

No es "programación reactiva". Los flujos reactivos —RxPy, streams, observables— están construidos sobre la idea de Observer, pero agregan encima toda una álgebra de transformación de flujos: mapear, filtrar, combinar, controlar la contrapresión. Es un tema aparte y mucho más grande. Que el nombre Observable aparezca ahí no significa que sea el mismo patrón que estás leyendo; significa que aquel se construyó encima de este.

Errores comunes

Escribir la clase abstracta Observer porque el diagrama la tiene (conceptual). Qué pasa: alguien lee el patrón en el libro original, ve la clase abstracta con su método update(), y la reproduce en Python: una ABC, tres subclases, y cada una con un update(self, event) que llama a una sola función. El resultado son sesenta líneas para lo que se hacía con una. Por qué pasa: el diagrama del catálogo se escribió pensando en lenguajes donde una función no es un valor de primera clase y no se podía guardar en una lista. En C++ de 1994 hacía falta un objeto; en Python no. Cómo detectarlo: si tus clases suscriptoras tienen un solo método y ningún atributo de estado, son funciones disfrazadas. Cómo corregirlo: usa funciones. Si un suscriptor necesita configuración —una llave de API, un cliente— pásasela con una clausura o con functools.partial, o hazlo un objeto con __call__. La clase completa se justifica cuando hay estado real que mantener entre invocaciones, y eso es más raro de lo que parece.

Publicar un evento y esperar un resultado (conceptual). Qué pasa: alguien escribe result = bus.publish(OrderCompleted(...)) esperando que le devuelva algo, o peor, diseña un evento CalculateShippingCost esperando que un suscriptor le conteste con el costo. El código a veces hasta funciona —con un solo suscriptor— y se rompe en cuanto entra el segundo. Por qué pasa: por arrastrar el modelo mental de la llamada a función, donde uno pide algo y recibe una respuesta. Un hecho no pide nada: informa. Si necesitas una respuesta, necesitas una llamada, no un evento. Cómo detectarlo: mira el nombre. Si está en imperativo (Calculate..., Send..., Create...) no es un hecho, es una orden — y las órdenes son el patrón de la lección 5, o directamente una función. Cómo corregirlo: los eventos se nombran en pasado y su publish devuelve None. Si te cuesta nombrarlo en pasado, es la señal de que no era un evento.

Confundir "el checkout quedó corto" con "el problema se resolvió" (de criterio). Qué pasa: se hace el refactor, el checkout queda en ocho líneas, y se declara la victoria. Nadie nota que mark_tickets_sold —que es parte de la transacción— ahora corre dentro de un try/except que se traga los errores, y que durante tres semanas se están vendiendo boletos que nunca se marcan como vendidos. Por qué pasa: porque el patrón mueve el código de lugar y la mejora visual es inmediata, mientras que el cambio de garantías es invisible. Cómo detectarlo: por cada línea que sacaste del checkout, pregúntate qué garantía tenía antes —¿corría siempre?, ¿en qué orden?, ¿qué pasaba si fallaba?— y si esa garantía sobrevivió a la mudanza. Cómo corregirlo: el criterio de la lección 7 y la disciplina del proyecto, que te va a pedir justificar cada decisión por separado en vez de en bloque.

Ejercicios

Ejercicio 1 — Encuentra las cuatro piezas en código que ya conoces. Toma este fragmento de un sistema de chat cualquiera y señala dónde está cada una de las cuatro piezas de Observer (sujeto, hecho, suscriptores, registro). Después di qué decisión tomó su autor sobre los errores.

class ChatRoom:
    def __init__(self):
        self._listeners = []

    def on_message(self, fn):
        self._listeners.append(fn)

    def post(self, author, text):
        msg = {"author": author, "text": text, "at": now()}
        self._history.append(msg)
        for fn in self._listeners:
            fn(msg)


room = ChatRoom()
room.on_message(lambda m: save_to_db(m))
room.on_message(lambda m: push_to_websocket(m))
room.on_message(lambda m: check_for_spam(m))
Ver solución

Sujeto: ChatRoom, y más precisamente su método post(). Fíjate en la huella: guarda el mensaje y después recorre una lista de cosas llamables. No menciona ni a la base de datos, ni al websocket, ni al filtro de spam.

Hecho: el diccionario msg. Es un hecho pobre —un dict en vez de una clase— y eso tiene consecuencias que veremos en la lección 4: nadie sabe qué campos tiene sin leer post(), no hay verificación de tipos, y agregar o quitar una clave rompe suscriptores en silencio. Pero conceptualmente cumple el rol.

Suscriptores: las tres lambda. Aquí el sujeto es una clase pero los suscriptores son funciones, que es exactamente la mezcla natural en Python.

Registro: las tres líneas de room.on_message(...), sueltas al final del archivo. Es cableado central de facto, aunque informal: está todo junto y se lee de un vistazo. Si esas tres líneas estuvieran repartidas en tres módulos distintos, sería auto-registro y perderías la lectura.

Decisión sobre errores: ninguna, o mejor dicho, la decisión por omisión. No hay try, así que si check_for_spam lanza una excepción, post() falla completa. El mensaje ya se guardó en _history pero el llamador recibe un error. Es el mismo problema de la lección 1, en miniatura.

Detalle extra que vale oro: el orden importa aquí y nadie lo escribió. save_to_db corre antes que push_to_websocket, y probablemente eso sea deliberado —no querrías empujar a los clientes un mensaje que no se persistió—. Esa dependencia de orden vive únicamente en el orden de tres líneas, sin un comentario que la explique. Es una bomba de tiempo, y ese es el tipo de cosa que la lección 6 te va a entrenar a detectar.

Por qué funciona: acabas de leer Observer en un código que no se parece al de Boletia, escrito con otro estilo y sin la palabra "bus" en ninguna parte. Reconocer la forma bajo estilos distintos es exactamente la habilidad del módulo 1 aplicada a este patrón.

Ejercicio 2 — Agrega el aislamiento de errores y decide qué se pierde. Toma el EventBus de trece líneas y escribe una versión que (a) aísle los errores de cada manejador, (b) los registre, y (c) le permita a quien publica saber si algo falló, sin obligarlo a saberlo. Después responde: ¿por qué es importante que el publicador pueda saberlo pero no tenga que saberlo?

Ver solución
class EventBus:
    def __init__(self, logger):
        self._subscribers = defaultdict(list)
        self._logger = logger

    def subscribe(self, event_type, handler):
        self._subscribers[event_type].append(handler)

    def publish(self, event) -> list[tuple[str, Exception]]:
        """Entrega el hecho a todos. Devuelve la lista de fallas.

        Devolver las fallas en vez de lanzarlas deja la decisión del
        lado de quien publica: puede ignorarlas (el caso normal) o
        revisarlas si le importan. Lo que NO puede pasar es que una
        falla de un suscriptor tumbe al publicador sin que él lo pida.
        """
        failures = []
        for handler in self._subscribers[type(event)]:
            try:
                handler(event)
            except Exception as exc:
                name = getattr(handler, "__name__", repr(handler))
                self._logger.exception(
                    "Falló %s manejando %s", name, type(event).__name__
                )
                failures.append((name, exc))
        return failures

Sobre la pregunta: si publish() lanzara la excepción, el publicador estaría obligado a saber que existen suscriptores y que pueden fallar — y eso destruye media razón del patrón. Si publish() se tragara la excepción sin dejar rastro, perderías la capacidad de enterarte de que un suscriptor lleva un mes roto. Devolver las fallas resuelve las dos cosas: el caso normal es ignorar el valor de retorno, y el caso donde importa —por ejemplo, una prueba que verifica que ningún manejador falló— tiene la información disponible.

Una observación importante sobre el diseño: fíjate en que el logger entra por el constructor. Eso es inyección de dependencias del módulo 4, y aquí se paga sola: en las pruebas le pasas un logger falso y verificas que registró la falla, sin leer archivos ni capturar la salida estándar.

Por qué funciona: te obliga a tomar explícitamente la decisión 2 del patrón, que es la que más consecuencias tiene en producción, y a notar que "aislar errores" y "esconder errores" están a una línea de distancia.

Ejercicio 3 — Decide el registro. Un compañero propone reemplazar bus/wiring.py por un decorador de auto-registro, así:

# En bus/bus.py
def subscribes_to(event_type):
    def decorator(fn):
        bus.subscribe(event_type, fn)
        return fn
    return decorator

# En notifications/subscribers.py
@subscribes_to(OrderCompleted)
def send_buyer_confirmation(order_completed): ...

Argumenta a favor y en contra en tres líneas cada uno, y da tu recomendación para Boletia con su razón.

Ver solución

A favor. Agregar un suscriptor toca un solo archivo, el suyo, en vez de dos. La suscripción queda pegada a la función, así que leyendo el manejador sabes de inmediato a qué reacciona —información que en el cableado central está en otro lado—. Y desaparece el archivo que importaba medio sistema, que era feo y crecía.

En contra. No existe ningún lugar donde se lea la lista completa de quién escucha qué: la respuesta a "¿qué pasa cuando se completa una compra?" deja de estar escrita en ningún archivo y pasa a ser el resultado de una búsqueda por todo el repositorio. La suscripción depende de que el módulo se haya importado, lo que produce el bug más desconcertante de esta familia: en producción funciona, en las pruebas no, o al revés, según qué importó qué. Y el orden de ejecución de los manejadores pasa a depender del orden de importación de Python, que nadie controla ni piensa.

Recomendación para Boletia: cableado central. La razón concreta no es estética. Boletia tiene seis personas, ninguna dedicada a la plataforma, y una operación —el checkout— que factura. El día que un organizador reclame que no le llegó su aviso, alguien va a tener que contestar rápido "¿quién escucha OrderCompleted?". Con wiring.py eso es abrir un archivo. Con el decorador es una búsqueda de texto por todo el repositorio, con la esperanza de que nadie haya escrito la suscripción de otra manera. La lección 6 pone precio a esa diferencia.

Un matiz honesto: en proyectos con decenas de módulos y equipos separados, el auto-registro gana terreno porque el archivo central se vuelve un punto de conflicto permanente en cada fusión de ramas. Cuando eso pasa, la salida sensata no es abandonar la lectura sino generarla: una prueba o un pequeño comando que imprima el registro completo del bus al arrancar. La información no puede desaparecer; puede cambiar de forma.

Por qué funciona: es la primera decisión de diseño del módulo donde las dos opciones son defendibles y la respuesta depende del contexto. Esa es la marca de una decisión de criterio, y es exactamente la forma que tiene el proyecto de la lección 8.

Resumen y siguiente paso

En esta lección escribiste Observer completo en trece líneas y viste que el patrón, desnudo, es un diccionario de listas de funciones. Conociste sus cuatro piezas: el sujeto que publica sin conocer a nadie, el hecho que viaja y que es el verdadero contrato, los suscriptores que reaccionan sin conocer al sujeto, y el registro de suscripciones —la pieza que casi nadie nombra y la que más decide el destino de la implementación—.

Viste dibujada la razón por la que el patrón funciona: la inversión de la dependencia. Antes, cinco flechas salían del checkout hacia cinco módulos; ahora, seis flechas apuntan al mismo contrato y nadie apunta a nadie. Y viste la consecuencia práctica, que es la única que justifica el trabajo: agregar el sexto interesado ya no obliga a tocar el archivo más delicado del producto.

También viste las cuatro decisiones que el bus mínimo toma en silencio —cuándo corren los manejadores, qué pasa si uno falla, en qué orden van, si se pueden dar de baja— y que cada una tiene un bug con nombre propio esperando del otro lado.

Antes de avanzar deberías poder: escribir el EventBus de memoria; nombrar las cuatro piezas y decir cuál es el contrato; dibujar las flechas de antes y de después; y explicar por qué en Python los suscriptores casi nunca necesitan ser clases.

Lo que sigue es aplicarlo de verdad. En la lección 3 vamos a tomar el bloque de veinte líneas del checkout de Boletia y convertirlo, con nombres reales, con los campos que pueden ser None, con el notify() que estaba abandonado desde el módulo 1 — y vamos a medir, con números concretos, qué se ganó y qué se perdió. Porque en el aire todos los patrones se ven bien; el criterio se construye sobre casos con esquinas.

Recursos

  • Refactoring Guru — Observer — la forma canónica del patrón con diagrama. Léelo sabiendo que su versión con clase abstracta responde a lenguajes sin funciones de primera clase.
  • Blinker — la librería de señales de Python, usada por Flask entre otros. Es Observer en unas trescientas líneas; leer su código fuente es un ejercicio excelente después de esta lección.
  • Django — Signals — Observer dentro de un framework grande. Vale la pena leer su propia advertencia sobre cuándo no usar señales: es la lección 7 de este módulo escrita por los autores del framework.
  • Python — dataclasses — la documentación de @dataclass, incluido frozen=True, que es lo que hace inmutables nuestros eventos.
  • Python — functools.partial — la forma limpia de darle configuración a un suscriptor que es función, sin convertirlo en clase.