Módulo 6: Patrones para comunicar entre partes

3. Las notificaciones de Boletia como Observer

Descripción

Al terminar esta lección vas a haber hecho el refactor completo: tomar las veinte líneas del checkout de Boletia y convertirlas en un OrderCompleted con cuatro suscriptores, paso por paso, con el código real y con las esquinas reales. No es un ejemplo de manual con dos clases limpias: aquí están los campos que pueden ser None, el notify() que llevaba meses abandonado, el aviso al organizador que no debe ir por SMS y el orden de ejecución que alguien va a romper sin querer.

Y vas a salir con dos mediciones, que es lo que de verdad quiero dejarte. Una de lo que se ganó, expresada no como "quedó más limpio" sino en unidades que se pueden contar: cuántos módulos importa el checkout antes y después, cuántos archivos hay que tocar para agregar el sexto interesado, y cuántas razones distintas tiene el archivo para cambiar. Y otra de lo que se pagó, en las mismas unidades: cuántos archivos hay que abrir para contestar "¿qué pasa cuando se completa una compra?", y qué garantías se perdieron por el camino.

Esto importa porque el criterio no se construye con opiniones, se construye con casos donde tú viste los dos lados de la balanza. Casi cualquiera puede decir "los eventos desacoplan". Muy poca gente puede decir "en este caso concreto desacoplé cinco dependencias, bajé a una las razones de cambio del archivo crítico, y a cambio perdí la capacidad de leer el flujo completo en un lugar y me quedé con un manejador que no debería estar ahí". La segunda frase es la que te van a pedir en una revisión de código y la que te va a servir en una entrevista.

Conexión con el módulo: la lección 1 te dejó el problema y la lección 2 el mecanismo. Esta lección junta las dos sobre el caso central del módulo y te deja el código que vas a seguir usando en las lecciones siguientes. La lección 4 va a tomar el OrderCompleted que definimos aquí y lo va a poner a prueba: qué pasa cuando alguien le agrega un campo, qué pasa cuando alguien le quita uno, y cómo se diseña para que aguante. La lección 6 va a tomar el sistema resultante y va a preguntar, a las tres de la mañana, por qué a un organizador no le llegó su aviso. Y la lección 7 va a mirar con lupa la cuarta suscripción —la del inventario— que ya al final de esta lección vas a estar mirando con desconfianza.

Cambiarse de casa y avisar la dirección nueva

Te mudas. Hay que avisar la dirección nueva, y la lista es larga: el banco, la compañía de luz, el trabajo, la escuela de tu hija, tres suscripciones, la familia, el seguro médico. Cada uno tiene su formulario, su teléfono, su portal.

Hay dos formas de hacerlo, y las dos existen de verdad.

La primera es la que todos conocemos: te sientas un domingo y haces la lista, y vas uno por uno. Funciona. Y tiene una propiedad valiosa que rara vez se aprecia: al terminar sabes exactamente quién se enteró, porque tú lo hiciste. Tienes la lista tachada. Si la factura de la luz llega a la casa vieja, sabes con certeza que ese sí lo hiciste y que el problema está del otro lado.

La segunda existe en algunos países y es un servicio de cambio de domicilio: das de alta la dirección nueva en un registro central, y quien esté suscrito a ese registro se entera solo. El banco, el seguro y el padrón electoral se actualizan sin que hagas nada. Es notablemente mejor: el trabajo pasó de "avisarle a doce" a "declararlo una vez".

Y aquí está el detalle que conviene mirar. Con el registro central, cuando la factura de la luz llega a la casa vieja, tú ya no sabes si el problema fue tuyo, del registro o de la compañía. No tienes lista tachada. Tienes un acto de fe y una búsqueda por delante.

Fíjate también en que hay cosas que nunca meterías en ese registro. La llave del departamento nuevo no se la das al registro central esperando que llegue a quien corresponda: se la das en la mano a tu hermana. Y el contrato de arrendamiento no lo publicas: lo firmas con el arrendador, cara a cara, y si esa firma falla no hay mudanza. Hay cosas que son avisos y hay cosas que son parte de la operación, y la mudanza no las trata igual.

Ese último párrafo es la lección entera. Vamos al código.

Ejemplo trabajado: de veinte líneas a un publish, paso por paso

Paso 0 — El inventario de lo que hay. Antes de mover nada, se lee. Esta es la sección 4 del checkout tal como la dejamos en la lección 1, y voy a numerar cada reacción para poder referirme a ellas:

# Archivo: checkout/checkout.py — el punto de partida

    # ---- 4. Todo lo que pasa después de cobrar -------------------------
    customer = repository.get_customer(order.customer_id)

    # (1) confirmación al comprador, por los canales que tenga
    email_channel.send(customer.email, build_confirmation(order))
    if customer.phone:
        sms_channel.send(customer.phone, build_short_confirmation(order))
    if customer.push_token:
        push_channel.send(customer.push_token, build_push_confirmation(order))

    # (2) aviso al organizador del evento
    organizer = repository.get_customer(event.organizer_id)
    email_channel.send(organizer.email, build_organizer_alert(order, event))

    # (3) inventario: marcar vendidos y descontar disponibles
    for ticket in tickets:
        ticket.status = "sold"
        repository.save_ticket(ticket)
    inventory.decrement_available(event.id, len(tickets))

    # (4) factura
    if order.total > 0:
        invoice = billing.create_invoice(order, customer)
        email_channel.send(customer.email, build_invoice_email(invoice))

    # (5) analítica y panel del organizador
    analytics.track("order_paid", order_id=order.id, total=order.total)
    reports.refresh_event_dashboard(event.id)

    # (6) puntos de lealtad
    loyalty.add_points(customer.id, points=int(order.total // 10))

    return order

Seis reacciones. Y antes de tocar nada, conviene anotar tres cosas que ya se ven:

  • La reacción (1) tiene lógica de canales metida en el checkout: los dos if que preguntan por customer.phone y customer.push_token. Recuerda del módulo 1 que esos campos pueden ser None, y que notifications/notifier.py ya sabe manejar eso — pero el checkout no lo usa. La migración a medias sigue ahí.
  • La reacción (2) manda correo directo, sin pasar por notify(). Y eso, resulta, es deliberado: al organizador se le avisa solo por correo, no por SMS ni push. Es un requisito real, no un descuido.
  • La reacción (3) es de otra naturaleza que las demás. Las otras cinco informan; esta cambia el estado del negocio. Guarda esa idea.

Paso 1 — Decidir qué sale y qué se queda. Este paso es el que casi todo el mundo se salta, y es el único donde hay criterio de verdad. La pregunta, la misma de la lección 1: "si esto falla, ¿la compra debería considerarse fallida?"

Voy a hacer lo que haría un equipo real la primera vez, incluida la parte cuestionable, y al final de la lección volvemos sobre ella con lupa: las seis salen como suscriptores. El argumento con el que se aprobó ese cambio en la revisión fue "todas son cosas que pasan después de cobrar, tratémoslas igual". Es un argumento cómodo y es exactamente el que quiero que aprendas a desconfiar. Por ahora seguimos, porque el mejor momento para ver que una decisión estaba mal es después de vivir con ella.

Paso 2 — Definir el hecho. ¿Qué necesitan saber los suscriptores?

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


@dataclass(frozen=True)
class OrderCompleted:
    """Una orden se pagó y quedó confirmada. Ya ocurrió.

    Qué lleva: los identificadores necesarios para que cualquier
    interesado busque lo que necesite, más los pocos datos que
    describen el hecho en sí (el total y el momento).

    Qué NO lleva, a propósito: el objeto Order, el Customer, ni los
    Ticket. La razón está desarrollada en la lección 4; el resumen
    es que meter objetos del modelo dentro del evento vuelve a acoplar
    a los suscriptores con el modelo, y encima con la versión del
    modelo que existía en el instante de publicar.
    """
    order_id: int
    customer_id: int
    event_id: int                    # el concierto (models/event.py)
    ticket_ids: tuple[int, ...]
    total: float
    occurred_at: str

Una decisión que vale la pena señalar ahora aunque la desarrollemos después: el evento lleva identificadores, y cada suscriptor busca lo que necesita. La alternativa —meter el Customer completo, el Order completo y la lista de Ticket— haría los manejadores más cortos hoy y mucho más frágiles mañana. Fíjate además en que total sí va, aunque se podría buscar: es un dato del hecho, no del estado actual. Si mañana la orden se reembolsa y su total cambia, el hecho de que en ese momento se cobraron 1,240 pesos no cambia. Los eventos guardan la foto, no el objeto vivo.

Paso 3 — Cada reacción se muda a su suscriptor. Regla de la mudanza: el manejador vive en la carpeta del módulo que le importa, no en una carpeta de "manejadores". El de notificaciones vive en notifications/, el de inventario en inventory/. Así, cuando alguien de operaciones tenga que cambiar el inventario, todo lo suyo está en su carpeta.

# Archivo: notifications/subscribers.py
from bus.events import OrderCompleted
from data import repository
from notifications.notifier import notify
from notifications.email_channel import EmailChannel
from notifications.templates import (
    build_confirmation, build_organizer_alert, build_invoice_email,
)


def send_buyer_confirmation(order_completed: OrderCompleted) -> None:
    """Confirmación al comprador por todos los canales que apliquen.

    Aquí se cierra la migración a medias del módulo 1: en vez de repetir
    la lógica de canales, llamamos a notify(), que ya recorre CHANNELS y
    pregunta is_available_for(customer). Los dos `if` del checkout —el de
    phone y el de push_token— desaparecen, y de paso el comprador gana los
    reintentos que notify() sí tenía y el checkout no.
    """
    customer = repository.get_customer(order_completed.customer_id)
    order = repository.get_order(order_completed.order_id)
    notify(customer, build_confirmation(order))


def send_organizer_alert(order_completed: OrderCompleted) -> None:
    """Aviso al organizador. Solo por correo, a propósito.

    NO usamos notify() aquí: el organizador tiene teléfono cargado y
    notify() le mandaría un SMS por cada boleto vendido. Un evento con
    dos mil asistentes son dos mil SMS. La llamada directa a EmailChannel
    no es descuido: es el requisito.
    """
    event = repository.get_event(order_completed.event_id)
    organizer = repository.get_customer(event.organizer_id)
    order = repository.get_order(order_completed.order_id)
    EmailChannel().send(organizer.email, build_organizer_alert(order, event))

Detente en el segundo manejador, porque tiene una lección adentro. Al mudarse las reacciones, apareció una diferencia que en el checkout estaba escondida: a dos destinatarios se les avisa con políticas distintas, y esa política antes vivía implícita en qué línea llamaba a qué canal. Ahora está escrita, con nombre y comentario. Un buen refactor no solo mueve código: hace visible lo que estaba implícito. Si al mudar una línea no puedes explicar por qué era así, encontraste una pregunta para el equipo, no un detalle.

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


def mark_tickets_sold(order_completed: OrderCompleted) -> None:
    """Marca los boletos como vendidos y ajusta el disponible del evento."""
    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),
    )
# Archivo: billing/subscribers.py
from bus.events import OrderCompleted
from data import repository
from billing import billing
from notifications.notifier import notify
from notifications.templates import build_invoice_email


def issue_invoice(order_completed: OrderCompleted) -> None:
    """Emite la factura y se la manda al comprador.

    La cortesía tiene total cero y no se factura; por eso el guardia.
    Antes ese `if order.total > 0` vivía en el checkout, donde no le
    tocaba: es una regla de facturación, no de compra.
    """
    if order_completed.total <= 0:
        return
    customer = repository.get_customer(order_completed.customer_id)
    order = repository.get_order(order_completed.order_id)
    invoice = billing.create_invoice(order, customer)
    notify(customer, build_invoice_email(invoice))
# Archivo: analytics/subscribers.py
from bus.events import OrderCompleted
from analytics import analytics
from reports import reports


def track_order_paid(order_completed: OrderCompleted) -> None:
    """Registra la venta y refresca el panel en vivo del organizador."""
    analytics.track(
        "order_paid",
        order_id=order_completed.order_id,
        total=order_completed.total,
    )
    reports.refresh_event_dashboard(order_completed.event_id)


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


def add_loyalty_points(order_completed: OrderCompleted) -> None:
    """Un punto por cada diez pesos. La regla vive aquí, no en el checkout."""
    points.add(
        order_completed.customer_id,
        amount=int(order_completed.total // 10),
    )

Paso 4 — El cableado. Un archivo, todas las suscripciones, en el orden en que corren:

# Archivo: bus/wiring.py
"""Registro central de suscripciones. Quién escucha qué, en un solo lugar.

REGLA DEL ARCHIVO: ningún manejador puede depender de que otro haya
corrido antes. Si necesitas ese orden, no tienes dos reacciones: tienes
un procedimiento, y un procedimiento se escribe como una función.
El orden de abajo es de legibilidad, no de dependencia.
"""
from bus.bus import EventBus
from bus.events import OrderCompleted

from inventory.subscribers import mark_tickets_sold
from notifications.subscribers import send_buyer_confirmation, send_organizer_alert
from billing.subscribers import issue_invoice
from analytics.subscribers import track_order_paid
from loyalty.subscribers import add_loyalty_points

bus = EventBus(logger=get_logger("bus"))


def wire_everything() -> None:
    """Se llama una sola vez, desde app.py, al arrancar."""
    bus.subscribe(OrderCompleted, mark_tickets_sold)
    bus.subscribe(OrderCompleted, send_buyer_confirmation)
    bus.subscribe(OrderCompleted, send_organizer_alert)
    bus.subscribe(OrderCompleted, issue_invoice)
    bus.subscribe(OrderCompleted, track_order_paid)
    bus.subscribe(OrderCompleted, add_loyalty_points)

Paso 5 — El checkout resultante.

# Archivo: checkout/checkout.py — después

    # ---- 4. Se anuncia 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

Qué esperar de este refactor. Lo primero, y no es menor: ningún archivo se volvió más complicado. Los seis manejadores son más cortos y más claros que las líneas equivalentes dentro del checkout, porque cada uno tiene un nombre que dice qué hace y un comentario que dice por qué. La complejidad no se comprimió: se repartió, y al repartirse se le pudo poner nombre a cada parte. Poner nombre a las partes es, casi siempre, la mitad de la ganancia de cualquier refactor.

Lo segundo: apareció una carpeta nueva y tres archivos que antes no existían (bus/bus.py, bus/events.py, bus/wiring.py), más cinco archivos subscribers.py. El sistema tiene ocho archivos más que ayer. Eso es real y hay que contarlo del lado del costo: ocho archivos más que abrir, más un concepto nuevo —el bus— que toda persona que entre al equipo va a tener que aprender antes de poder leer el checkout.

Lo tercero, que es un regalo: el checkout dejó de saber que existen los teléfonos. Los dos if de customer.phone y customer.push_token no se movieron a ningún lado: se borraron, porque esa decisión ya vivía en is_available_for() de cada canal y solo estaba duplicada. Cuando un refactor borra código en vez de moverlo, es señal fuerte de que el corte va por una junta real.

Qué se ganó, en unidades contables

"Quedó más limpio" no es una medición. Estas sí.

Módulos que importa checkout.py para la sección 4. Antes: nueve (email_channel, sms_channel, push_channel, repository, inventory, billing, analytics, reports, loyalty). Después: dos (bus, OrderCompleted). Es la medición más directa de "este archivo conoce medio sistema".

Archivos que hay que tocar para agregar el séptimo interesado. Antes: uno, pero es checkout.py. Después: dos, y ninguno es checkout.py. Fíjate en que el número empeoró —de uno a dos— y aun así la situación mejoró muchísimo, porque los dos archivos nuevos son archivos que no le importan a nadie más. Contar archivos sin contar cuáles es la forma más común de medir mal un refactor.

Razones por las que cambia checkout.py. Antes: cinco (cambia la forma de cobrar, o marketing quiere puntos, o contabilidad quiere facturas, o datos quiere otro panel, o operaciones cambia el inventario). Después: una. Esta es la medición que más pesa en una revisión de código, porque es la que traduce el refactor a algo que el equipo siente: cuántas veces al año alguien tiene que abrir el archivo del que depende la facturación.

Personas que deben aprobar un cambio de puntos de lealtad. Antes: quien cuide el checkout, porque el cambio toca ese archivo. Después: el equipo de marketing y quien revise loyalty/. Este argumento es organizacional, no técnico, y en la práctica suele ser el que convence.

Deuda vieja que se cerró de paso. La migración a medias del módulo 1 —notify() existía y el checkout no lo usaba— desapareció, y con ella la duplicación de la política de canales. Además el comprador ganó reintentos que antes solo tenían los recordatorios, porque notify() usa los canales envueltos en RetryingChannel. Un correo de confirmación que antes se perdía si el servidor de correo parpadeaba, ahora se reintenta tres veces.

Qué se pagó, en las mismas unidades

Archivos que hay que abrir para contestar "¿qué pasa cuando se completa una compra?". Antes: uno. Después: checkout.py para ver que publica, wiring.py para ver quiénes escuchan, y luego los cinco archivos de manejadores para saber qué hace cada uno. Siete. Y eso con cableado central; con auto-registro serían siete más una búsqueda por todo el repositorio para encontrar el segundo paso.

Garantía de orden. Antes estaba escrita en el propio flujo y era imposible romperla sin darse cuenta. Ahora vive en el orden de seis líneas de wiring.py, protegida únicamente por un comentario. Alguien va a reordenarlas alfabéticamente algún día.

Comportamiento ante fallas. Antes: si algo fallaba, fallaba todo y el cliente veía un error. Feo, pero honesto. Ahora, con el bus que aísla y registra, una factura que no se emite queda en un archivo de registro que quizá nadie mire. La falla pasó de ruidosa a silenciosa. Esa es una decisión legítima, pero es un cambio de garantías y hay que decirlo en voz alta, no dejarlo pasar como efecto colateral de un refactor de limpieza.

Concepto nuevo para el equipo. Seis personas tienen que entender qué es el bus, dónde está el cableado y por qué el checkout ya no llama a nadie. Cada concepto nuevo se paga una vez por persona y otra vez por cada persona que entre después. No es enorme —el bus tiene trece líneas—, pero no es cero.

La tentación instalada. Ahora publicar es gratis. Dentro de seis meses habrá OrderCancelled, TicketTransferred, EventPublished, CustomerRegistered y quince suscriptores. Esa deriva no es hipotética: es lo que pasa por defecto salvo que alguien la frene a propósito. Instalar un bus es también instalar una responsabilidad de curaduría.

La cuarta suscripción que no me convence

Volvamos, como prometí, sobre mark_tickets_sold.

Las cinco reacciones que informan —confirmación, aviso al organizador, factura, analítica, puntos— tienen todas la misma propiedad: si fallan, el cliente compró igual. Tiene su boleto, su dinero salió, su asiento es suyo. La falla se puede reintentar, se puede reconciliar al día siguiente, se puede corregir a mano.

mark_tickets_sold no tiene esa propiedad. Si falla, los boletos siguen figurando como disponibles. Y como siguen disponibles, el sistema los va a vender otra vez. Dos personas con el mismo asiento, dos cobros hechos, y una conversación muy incómoda en la puerta del teatro.

Míralo con el bus que aísla errores, que es el que quieres para todo lo demás:

# Lo que pasa hoy con el bus que aísla y registra:
#
#   1. checkout cobra                          → el dinero salió
#   2. checkout guarda la orden como "paid"    → la compra es oficial
#   3. bus.publish(OrderCompleted)
#        ├─ mark_tickets_sold   → EXPLOTA. Se registra en el log. Se sigue.
#        ├─ send_buyer_confirmation → el cliente recibe su boleto. Contento.
#        ├─ send_organizer_alert    → el organizador ve una venta más
#        ├─ issue_invoice           → se emite la factura
#        ├─ track_order_paid        → el panel muestra la venta
#        └─ add_loyalty_points      → se suman los puntos
#   4. checkout devuelve 200 OK                → todo se ve perfecto
#
# Y los boletos siguen marcados "available".

Todo el sistema reporta éxito. El único rastro del problema es una línea en un archivo de registro. La venta duplicada va a aparecer horas después, cuando otro cliente compre el mismo asiento, y para entonces nadie va a relacionar las dos cosas.

La conclusión honesta: el inventario no era una reacción a la compra; era parte de la compra. Su lugar es dentro del checkout, en la misma transacción que el cobro y el guardado de la orden, donde si falla, falla todo y el cliente ve un error. Un error visible es infinitamente mejor que un asiento vendido dos veces.

# Archivo: checkout/checkout.py — la versión que yo defendería

    # ---- 3b. El inventario es parte de la compra, no una reacción ------
    for ticket in tickets:
        ticket.status = "sold"
        repository.save_ticket(ticket)
    inventory.decrement_available(event.id, len(tickets))

    order.status = "paid"
    repository.save_order(order)

    # ---- 4. Se anuncia que la compra ocurrió ---------------------------
    bus.publish(OrderCompleted(...))

    return order

Fíjate en lo que acaba de pasar en términos de criterio. El refactor "todo a eventos" era más elegante, más simétrico y más fácil de explicar. La versión correcta es más fea: tiene un pedazo de inventario adentro del checkout, rompiendo la simetría. Y es mejor, porque las garantías del negocio no son simétricas y el código tiene que reflejar eso.

Esta es, para mí, la lección más valiosa del módulo entero. Cuando un refactor te queda perfectamente simétrico, sospecha: el mundo real casi nunca lo es, y una simetría que el dominio no tiene suele significar que borraste una distinción que importaba.

Errores comunes

Mover el código sin mover la decisión (de criterio). Qué pasa: alguien hace el refactor mecánicamente —cada bloque a un manejador— y no se pregunta ni una vez si cada bloque merecía salir. El resultado se ve profesional y contiene, escondida, la bomba del inventario. Por qué pasa: porque el refactor mecánico es rápido, satisfactorio y revisable línea por línea, mientras que la pregunta de criterio es lenta, incómoda y no tiene una respuesta que el revisor pueda verificar de un vistazo. Cómo detectarlo: si tu descripción del cambio dice "se movieron las reacciones del checkout al bus" y no menciona ni una decisión, no hiciste un diseño, hiciste una mudanza. Cómo corregirlo: por cada bloque, escribe una línea que diga por qué sale o por qué se queda. Si las seis líneas dicen lo mismo, no evaluaste seis casos: evaluaste uno y lo copiaste.

Meter objetos del modelo dentro del evento (conceptual). Qué pasa: en vez de customer_id, alguien pone customer: Customer en el evento, porque así el manejador se ahorra una consulta. Se siente eficiente. Seis meses después, Customer gana un campo, cambia otro, y todos los manejadores que leían el Customer del evento tienen que revisarse — además de que el evento ya no es serializable, así que el día que se quiera encolar hay que rehacerlo entero. Por qué pasa: por optimizar la consulta de hoy sin ver el contrato de mañana. Cómo detectarlo: si tu evento tiene un campo cuyo tipo es una clase de models/, tienes acoplamiento al modelo dentro del contrato. Cómo corregirlo: identificadores y datos simples. La lección 4 desarrolla la regla completa, incluida la excepción legítima —datos que describen el hecho y no el estado, como el total en el momento del cobro.

Dejar que el manejador dependa del orden (de criterio). Qué pasa: alguien escribe issue_invoice asumiendo que mark_tickets_sold ya corrió, porque en wiring.py está antes. Funciona en producción durante meses. Un día alguien reordena las suscripciones, o agrega una en medio, o las corre en paralelo, y aparece un bug que no se reproduce localmente. Por qué pasa: porque el orden existe y funciona, así que apoyarse en él no cuesta nada hoy. Cómo detectarlo: pregúntate por cada manejador si seguiría siendo correcto ejecutándose primero, y también último. Si la respuesta a alguna es no, ese manejador no es independiente. Cómo corregirlo: si dos cosas tienen orden obligatorio, van en la misma función, no en dos suscripciones. Y si de verdad hay una cadena de hechos, se modela como una cadena de hechos: el primer manejador publica un evento nuevo que el segundo escucha. Eso es explícito y verificable; el orden implícito en un archivo de cableado no lo es.

Ejercicios

Ejercicio 1 — El séptimo interesado. Llega un pedido: "cuando alguien compre boletos para un evento con asiento numerado, hay que mandarle a las 48 horas un recordatorio con el mapa del recinto". Escribe (a) qué archivos tocarías con el diseño de antes del refactor, (b) qué archivos tocarías con el diseño de después, y (c) una razón por la que la respuesta (b) es mejor aunque tenga más archivos.

Ver solución

(a) Antes: checkout/checkout.py, y solo ese. Ahí adentro habría que agregar algo como if event.has_numbered_seats: schedule_reminder(order, hours=48), más el import correspondiente. Un archivo, tres líneas, y una revisión de código sobre el corazón del sistema por parte de quien lo cuida.

(b) Después: un archivo nuevo, digamos reminders/subscribers.py, con la función schedule_seat_map_reminder(order_completed); y una línea en bus/wiring.py. Dos archivos.

(c) La razón no es la cantidad, es cuáles. El archivo nuevo no le importa a nadie más: si está mal, se rompen los recordatorios y nada más. wiring.py es una línea de registro, casi imposible de romper de forma sutil. En el diseño de antes, en cambio, una equivocación en esas tres líneas —una excepción no controlada, por ejemplo— tumba la venta. El cambio pasó de tocar el archivo con el mayor costo de falla a tocar el de menor costo.

Hay un segundo argumento que vale la pena que veas, porque es el que más se usa en la vida real: quien pidió el recordatorio probablemente no es del equipo del checkout. Con el diseño nuevo, ese equipo puede hacerlo solo, sin coordinar, sin esperar la revisión de nadie más y sin miedo. El desacoplamiento del código se convierte en desacoplamiento de las personas, y eso es lo que en la práctica hace que un equipo avance más rápido.

Una nota de honestidad: la lógica del if event.has_numbered_seats no desapareció. Se mudó al manejador, que es donde le toca — es una regla de recordatorios, no de compra.

Ejercicio 2 — Traza el fallo del organizador. Un organizador reclama que no le llegó el aviso de una venta. Escribe la secuencia de pasos que seguirías para diagnosticarlo con el diseño de después. Después escribe la secuencia con el diseño de antes. Compara la cantidad de archivos abiertos y de suposiciones hechas.

Ver solución

Con el diseño de antes. Abres checkout/checkout.py, bajas a la sección 4, y lees la línea email_channel.send(organizer.email, ...). Tienes tres hipótesis y todas están a la vista: o event.organizer_id apunta a un cliente equivocado, o organizer.email está vacío, o el envío falló. Uno o dos archivos, cero suposiciones sobre la estructura.

Con el diseño de después. Abres checkout.py y ves un publish. No sabes si el organizador es un interesado. Abres wiring.py y encuentras send_organizer_alert; ahora sí sabes que existe. Abres notifications/subscribers.py y lees el manejador. Y aquí aparecen dos hipótesis nuevas que antes no existían: que la suscripción no estuviera registrada —porque wire_everything() no se llamó, o porque alguien la borró en una fusión— y que el manejador sí corriera pero fallara y el bus se lo tragara, dejando solo una línea en el registro. Tres o cuatro archivos, y dos preguntas nuevas que no tienen que ver con el problema del organizador sino con la maquinaria.

La comparación honesta: el diagnóstico pasó de dos archivos y tres hipótesis a cuatro archivos y cinco hipótesis. Ese es el costo del que habla la lección 6, medido. Y fíjate en que las dos hipótesis nuevas son las peores de todas, porque son sobre la infraestructura y no sobre el negocio: son el tipo de cosa que a las tres de la mañana te hace dudar de todo.

Lo que compra ese costo también hay que decirlo: si el organizador se queja porque no existe el aviso —porque nadie lo implementó—, en el diseño nuevo se agrega sin tocar la venta. El costo es de diagnóstico; la ganancia es de evolución. Casi todo en este módulo es esa balanza.

Ejercicio 3 — Defiende la decisión del inventario ante un compañero. Tu compañero revisa el cambio y comenta: "me parece inconsistente que cinco reacciones sean eventos y una se quede adentro del checkout; o todo o nada". Escribe tu respuesta de revisión: máximo seis líneas, con un argumento concreto y sin apelar a la autoridad de ningún patrón.

Ver solución

Una respuesta que funciona:

"La inconsistencia es a propósito y viene de una diferencia real del negocio. Las cinco que salieron tienen una propiedad común: si fallan, el cliente compró igual y se puede corregir después. Marcar los boletos como vendidos no la tiene: si falla, el sistema los vuelve a vender y terminamos con dos personas en el mismo asiento. Necesito que esa falla tumbe la compra y el cliente vea un error, y eso solo lo puedo garantizar si corre en la misma transacción que el cobro. Si lo pasamos a evento, el bus lo aísla y lo registra en el log, y nos enteramos horas después por un reclamo. Estoy abierto a moverlo si encontramos cómo garantizar la consistencia, pero no antes."

Fíjate en lo que hace esa respuesta. No dice "porque Observer no aplica ahí". Nombrar el patrón habría sido la salida cómoda y no habría convencido a nadie: los patrones no son argumentos, son vocabulario. El argumento es la consecuencia concreta —dos personas en el mismo asiento— y la garantía que se necesita para evitarla.

Fíjate también en el cierre. No es "estás equivocado": es "estoy abierto a moverlo si aparece la garantía". Deja la puerta abierta a que exista una solución mejor que ninguno de los dos vio, y convierte el desacuerdo en una condición verificable en vez de una cuestión de gustos. El módulo 7 entero trata sobre esa forma de conversar, y esta es una probadita.

Y una advertencia sobre lo que no hay que hacer: no cedas por consistencia. "O todo o nada" suena a principio y no lo es; es una preferencia estética. Un sistema donde todo se trata igual cuando el negocio no trata todo igual es un sistema que va a mentir sobre sí mismo.

Resumen y siguiente paso

En esta lección hiciste el refactor completo sobre el caso central del módulo. Definiste OrderCompleted con identificadores y datos del hecho, mudaste seis reacciones a cinco archivos de suscriptores, escribiste el cableado central y dejaste el checkout publicando un solo hecho.

Mediste lo que se ganó en unidades contables: de nueve módulos importados a dos, de cinco razones de cambio a una, y una deuda vieja cerrada de paso —la migración a medias del notify(), que además le regaló reintentos al correo de confirmación—. Y mediste lo que se pagó en las mismas unidades: de un archivo a siete para entender el flujo, una garantía de orden que ahora vive en un comentario, fallas que pasaron de ruidosas a silenciosas y un concepto nuevo para todo el equipo.

Y llegaste, con el caso en la mano, a la conclusión que ordena el resto del módulo: el inventario no era una reacción, era parte de la compra, y por eso se queda dentro del checkout aunque rompa la simetría. Un refactor demasiado simétrico suele haber borrado una distinción que importaba.

Antes de avanzar deberías poder: escribir de memoria la forma de OrderCompleted y justificar por qué lleva identificadores en vez de objetos; nombrar tres mediciones del lado de la ganancia y tres del lado del costo; y defender en cinco líneas por qué una reacción se queda adentro.

Lo que viene ahora es la letra chica. Todo este diseño descansa sobre una suposición que no examinamos: que OrderCompleted va a seguir siendo OrderCompleted. La lección 4 la pone a prueba —qué pasa cuando alguien le agrega un campo, le quita otro, le cambia el significado a un tercero— y te va a mostrar que el acoplamiento no desapareció con el refactor: se mudó a la forma del evento, que es un contrato como cualquier otro y que se rompe igual, con el agravante de que nadie te avisa.

Recursos

  • Martin Fowler — Domain Event — la definición de "hecho ocurrido" y por qué se nombra en pasado. Corto y directo al punto de esta lección.
  • Martin Fowler — What do you mean by "Event-Driven"? — la sección de Event Notification describe exactamente el diseño que construimos aquí, y la de Event-Carried State Transfer describe la alternativa que rechazamos al no meter el Customer dentro del evento.
  • Django — Signals: cuándo usarlas — un framework grande explicando su propio Observer, con advertencias que coinciden con las de esta lección.
  • Python — dataclassesfrozen=True, valores por defecto y field(), que es lo que vas a necesitar cuando el evento crezca en la lección 4.