Módulo 6: Patrones para comunicar entre partes
4. Acoplar por evento en vez de por llamada directa
Descripción
Al terminar esta lección vas a haber desarmado el malentendido más extendido de esta familia de patrones: la idea de que con eventos "el acoplamiento desaparece". No desaparece. Cambia de forma, y la forma nueva tiene una propiedad peligrosa que la anterior no tenía: cuando se rompe, no hace ruido.
Vas a salir con tres cosas concretas. Primero, la capacidad de ver el contrato donde antes veías un simple objeto de datos: OrderCompleted no es una dataclass, es un acuerdo entre seis módulos que ninguno de ellos puede cambiar solo. Segundo, un catálogo de qué cambios rompen y cuáles no, con el caso más traicionero de todos —el que no rompe nada y deja todo mal—. Y tercero, un conjunto de reglas prácticas para diseñar un evento que aguante el tiempo: qué lleva, qué no lleva, cómo se nombra, cómo crece y cómo se retira.
Esto importa por una razón que se ve mejor con una comparación. Cuando el checkout llamaba directo a billing.create_invoice(order, customer), esa dependencia era fea pero honesta: estaba escrita, se veía en los imports, y si alguien le cambiaba la firma a create_invoice, el sistema fallaba de inmediato y en un lugar concreto. Con eventos, la dependencia es invisible y silenciosa: nadie importa a nadie, no hay firma que verificar, y una incompatibilidad puede vivir meses en producción produciendo datos incorrectos sin que ninguna prueba se ponga roja. Cambiaste una dependencia ruidosa por una silenciosa, y las silenciosas cuestan más caro.
Conexión con el módulo: la lección 3 te dejó el refactor hecho y el evento OrderCompleted definido, con una decisión que solo justifiqué a medias —que llevara identificadores en vez de objetos—. Esta lección la justifica del todo y le agrega el resto de las reglas. Es la lección más conceptual del módulo y la que más te va a servir fuera de él: todo lo que aquí se dice sobre diseñar un evento aplica igual a un mensaje en una cola, a la carga de un webhook o al cuerpo de una llamada a una API. La lección 5 cambia de patrón y trae Command. La lección 6 toma este mismo sistema y lo mira desde el peor momento posible: un incidente en producción.
El formulario que llenan en cuatro ventanillas
Piensa en un trámite de esos que existen en cualquier país: un formulario de alta que llenas una vez y que después leen varias oficinas distintas. Tú lo entregas en la ventanilla uno; de ahí pasa a impuestos, a registro civil y a la caja de pensiones. Cada oficina lee los campos que le interesan.
Ahora imagina que la oficina que diseñó el formulario decide mejorarlo. Cambian el campo que decía "ingreso mensual" por uno que dice "ingreso anual". Es un cambio razonable, se acordó en una reunión, y el formulario nuevo está mejor.
¿Qué pasa río abajo? Depende del cambio, y las tres posibilidades vale la pena verlas separadas.
Si borran el campo, la oficina de pensiones abre el formulario, busca "ingreso mensual", no lo encuentra y se detiene. Es molesto, pero el problema se manifiesta de inmediato y todo el mundo sabe qué pasó.
Si agregan un campo nuevo —digamos "correo electrónico"— y dejan los demás, no pasa nada. Las tres oficinas siguen leyendo lo que leían. El formulario creció y nadie se enteró, que es exactamente lo que uno quiere.
Y si dejan el campo con el mismo nombre pero le cambian el significado —"ingreso" ahora es anual, antes era mensual— entonces la oficina de pensiones lee el número, lo entiende como siempre lo entendió, y calcula la pensión de todo el mundo doce veces mal. Nadie se detiene. Nadie ve un error. Los formularios se procesan con normalidad durante meses, y el problema aparece cuando alguien se jubila.
Esas tres posibilidades son, exactamente, los tres tipos de cambio que le puedes hacer a un evento. Y la tercera —la que no rompe nada y deja todo mal— es la que hace que esta lección exista.
Hay un detalle más de la analogía que quiero que veas, porque es el que decide todo lo demás. El formulario no le pertenece a la oficina que lo diseñó. Le pertenece al trámite. En el momento en que cuatro oficinas leen el mismo papel, cambiarlo dejó de ser una decisión de una sola oficina, aunque técnicamente ella sea quien lo imprime. Con OrderCompleted pasa lo mismo: lo publica el checkout, pero no es del checkout.
Ejemplo trabajado: cuatro cambios a OrderCompleted y quién se rompe
Este es el evento que dejamos en la lección 3, y los cinco manejadores que lo leen:
# Archivo: bus/events.py
@dataclass(frozen=True)
class OrderCompleted:
order_id: int
customer_id: int
event_id: int
ticket_ids: tuple[int, ...]
total: float
occurred_at: str
Quién lee qué campo:
send_buyer_confirmation → customer_id, order_id
send_organizer_alert → event_id, order_id
issue_invoice → total, customer_id, order_id
track_order_paid → order_id, total, event_id
add_loyalty_points → customer_id, total
Vamos con los cuatro cambios, del más inofensivo al más peligroso.
Cambio 1 — Agregar un campo. Marketing quiere saber si la compra vino de la aplicación o del sitio web.
@dataclass(frozen=True)
class OrderCompleted:
order_id: int
customer_id: int
event_id: int
ticket_ids: tuple[int, ...]
total: float
occurred_at: str
source: str = "web" # nuevo, con valor por defecto
Quién se rompe: nadie. Los cinco manejadores siguen leyendo lo suyo. Quien publica puede seguir sin pasar source porque hay valor por defecto. Este es el cambio bueno y es la razón por la que siempre conviene poner valor por defecto a los campos nuevos: sin él, todos los lugares que construyen el evento —incluidas las pruebas de los cinco manejadores— dejan de funcionar de golpe.
Cambio 2 — Renombrar un campo. A alguien le parece que total es ambiguo y lo cambia a total_amount.
total_amount: float # antes: total
Quién se rompe: issue_invoice, track_order_paid y add_loyalty_points. Los tres lanzan AttributeError la primera vez que corren. Es un cambio que rompe, sí, pero fíjate en lo importante: rompe fuerte y rápido. La primera compra después del despliegue produce tres excepciones. Si el bus las registra, en cinco minutos alguien sabe qué pasó.
Hay un matiz feo que conviene ver. Con el bus que aísla errores —el que quisimos en la lección 2— esas tres excepciones no tumban la compra. Se registran y siguen. Así que si nadie mira los registros, Boletia lleva tres días vendiendo sin emitir facturas ni sumar puntos, y todo se ve bien desde afuera. El aislamiento de errores, que era una virtud, se convierte aquí en un anestésico. Es un tradeoff real y no tiene una solución cómoda: lo que tiene es una obligación de vigilancia, que la lección 6 desarrolla.
Cambio 3 — Quitar un campo. El equipo de datos nota que event_id se puede deducir de los boletos y lo saca "para simplificar".
Quién se rompe: send_organizer_alert y track_order_paid. El primero es el peor caso posible: el aviso al organizador deja de salir. Y lo hace en silencio para el organizador, que simplemente no recibe correos y tarda semanas en notar que ya no le llegan.
Este cambio ilustra por qué quitar es más peligroso que agregar, y también por qué la persona que lo hizo no era irresponsable: desde checkout.py no hay ninguna forma de ver quién usa event_id. Una búsqueda de texto por event_id en Boletia devuelve doscientos resultados, porque es un nombre común. La única defensa real es que exista un lugar donde se pueda leer quién escucha qué —el wiring.py— y la disciplina de mirarlo antes de tocar un evento.
Cambio 4 — Cambiar el significado sin cambiar el nombre. Y aquí está el que importa.
Contabilidad pide que la factura muestre el subtotal sin la comisión de servicio. Alguien mira el evento, ve que total incluye la comisión, y decide que es más limpio publicar el subtotal:
# En checkout.py, antes:
bus.publish(OrderCompleted(..., total=order.total, ...))
# order.total = subtotal + comisión de servicio (8%)
# En checkout.py, después:
bus.publish(OrderCompleted(..., total=order.subtotal, ...))
# ahora total = subtotal, sin comisión
Quién se rompe: nadie. Y todo está mal.
issue_invoiceemite facturas por un 8% menos de lo que se cobró. Es un problema fiscal.track_order_paidreporta ingresos 8% por debajo de la realidad. El panel del organizador miente y nadie sospecha, porque la cifra es plausible.add_loyalty_pointsda 8% menos puntos. Nadie reclama, porque nadie sabe cuántos puntos le tocaban.
Ninguna excepción. Ninguna prueba en rojo —las pruebas de cada manejador construyen su propio OrderCompleted con los valores que ellas eligen, así que pasan felices—. Ningún registro. El sistema funciona perfectamente y produce datos incorrectos, y va a seguir haciéndolo hasta que alguien de contabilidad cuadre las cifras a fin de trimestre.
Qué esperar de estos cuatro casos. Lo primero: el peligro no es proporcional al esfuerzo del cambio. El cambio 4 es el más chico de los cuatro —una palabra— y el más caro con diferencia. Esa desproporción es la marca del acoplamiento semántico y es lo que lo hace difícil de gobernar: no hay ninguna señal visible que le diga a quien lo hace que está tocando algo delicado.
Lo segundo: fíjate en que ninguna herramienta te salva. Un verificador de tipos atrapa los cambios 2 y 3 si tus manejadores están anotados, y es un buen motivo para anotarlos. Pero el cambio 4 no lo atrapa nada: los tipos son idénticos, la firma es idéntica, la ejecución es idéntica. La única defensa contra el cambio semántico es el nombre y la documentación del campo, más una revisión de código que sepa que ese campo lo leen cinco módulos.
Y lo tercero, el más importante para tu criterio: compara con lo que habría pasado sin eventos. Con la llamada directa billing.create_invoice(order, customer), ese cambio de significado ni siquiera habría sido posible de la misma manera: create_invoice recibía el Order completo y decidía por sí misma qué campo usar. La ambigüedad de total nació cuando aplanamos la orden a un evento. Es decir: el evento no solo movió el acoplamiento, creó un lugar nuevo donde equivocarse. Eso también hay que ponerlo en la balanza.
Las tres formas de acoplamiento, comparadas
Vale la pena poner las tres juntas, porque el argumento del módulo entero está en esta tabla.
| Por nombre (llamada directa) | Por forma (evento) | Por significado (evento) | |
|---|---|---|---|
| Qué comparte | El nombre de la función y su firma | Los campos del evento y sus tipos | Qué quiere decir cada campo |
| Dónde se ve | En el import y en la llamada | En la definición del evento | En ningún lado |
| Cómo se rompe | Al importar o al llamar | Al leer un campo que no está | No se rompe: da mal |
| Cuándo te enteras | De inmediato | En la primera ejecución | Semanas o meses después |
| Quién te avisa | El intérprete, las pruebas, el verificador de tipos | El registro de errores, si lo miras | Un humano que cuadra números |
| Costo de arreglarlo | Bajo | Medio | Alto, más el costo de los datos malos |
Léela de izquierda a derecha y verás el arco del módulo. Al pasar de llamada directa a evento ganamos evolución —agregar interesados es barato— y perdimos visibilidad de la falla. No es que el acoplamiento por evento sea peor: es que su modo de fallar es peor, y hay que compensarlo con disciplina donde antes te compensaba la máquina.
La conclusión práctica de esa tabla es una sola frase, y es la que quiero que te lleves: con eventos, la revisión de código y las pruebas de contrato dejan de ser buenas prácticas y pasan a ser el único mecanismo de defensa que tienes. Con llamadas directas puedes ser descuidado y el lenguaje te tapa. Con eventos, no.
Cómo se diseña un evento que aguante
Siete reglas. Las primeras cuatro son de forma y las últimas tres son de gobierno, que es donde de verdad se decide si un sistema de eventos envejece bien.
Regla 1 — Nómbralo en pasado, y que sea un hecho del negocio. OrderCompleted, no CompleteOrder ni OrderCompletedEvent ni NotifyOrderCompleted. El pasado no es un capricho gramatical: es la prueba de que estás publicando un hecho y no dando una orden. Si el nombre natural te sale en imperativo, no tenías un evento —tenías una llamada disfrazada, y probablemente lo que quieres es la lección 5 o directamente una función.
La segunda mitad de la regla importa igual: el nombre debe ser del negocio, no de la implementación. OrderCompleted es un hecho que un organizador de eventos entendería. OrderRowUpdated o CheckoutFunctionFinished son hechos técnicos, y los hechos técnicos envejecen con la implementación que los produjo.
Regla 2 — Identificadores y datos del hecho; nada de objetos vivos. Un evento lleva customer_id, no Customer. Las razones, ahora completas:
- Serialización. El día que quieras encolar el evento —y ese día llega— un
Customercon su conexión a la base de datos adentro no se puede convertir a JSON. Los identificadores sí. - Frescura. Si metes el
Customercompleto y el manejador corre tres segundos después, está trabajando con una foto vieja. Con el identificador, cada manejador busca el estado actual y decide él si le sirve. - Acoplamiento al modelo. Si el evento lleva
Customer, los cinco manejadores dependen de la claseCustomer, y cambiar el modelo vuelve a romper cinco módulos. Habrías desacoplado delcheckoutpara acoplarte al modelo de datos, que cambia igual de seguido.
La excepción legítima es la que ya usamos: los datos que describen el hecho, no el estado. total va en el evento porque es cuánto se cobró en ese momento, y eso no cambia aunque la orden se reembolse mañana. occurred_at va por la misma razón. La prueba para distinguirlos: pregúntate si ese dato, dentro de un año, seguiría siendo cierto acerca de ese momento. Si sí, es del hecho y va. Si es el estado actual de algo, no va: va su identificador.
Hay una tensión real aquí y conviene decirla. Un evento delgado obliga a cada manejador a consultar la base de datos, y cinco manejadores son cinco consultas donde antes había cero. Un evento gordo evita las consultas y trae los problemas de arriba. Fowler llama a la segunda opción event-carried state transfer y tiene su lugar, sobre todo cuando publicador y suscriptor están en procesos distintos y la consulta implicaría una llamada de red. Dentro de un mismo programa, como en Boletia, empieza siempre delgado: engordar un evento después es fácil (agregar campos con valor por defecto), adelgazarlo es un cambio que rompe.
Regla 3 — Inmutable, sin comportamiento. frozen=True y nada de métodos que hagan cosas. Si el evento tuviera un método send_email(), el evento sabría qué hay que hacer cuando ocurre — y volveríamos al punto de partida, con el publicador conociendo las reacciones, ahora escondido dentro de la clase de datos. Un evento es un sustantivo, no un verbo. Se le permiten, como mucho, propiedades calculadas que no consulten nada externo.
Regla 4 — Explícito hasta ser aburrido. Este es el antídoto contra el cambio 4. Si un campo puede entenderse de dos maneras, el nombre tiene que elegir una:
@dataclass(frozen=True)
class OrderCompleted:
"""Una orden se pagó y quedó confirmada.
Campos monetarios: todos en pesos mexicanos (MXN), como float.
"""
order_id: int
customer_id: int
event_id: int # el concierto, no el hecho
ticket_ids: tuple[int, ...]
subtotal_mxn: float # precio de los boletos, sin comisión
service_fee_mxn: float # la comisión de servicio cobrada
total_charged_mxn: float # lo que se cargó a la tarjeta: subtotal + fee
occurred_at: str # ISO 8601 en UTC
source: str = "web" # "web" | "app" | "box_office"
Sí, es más largo. Y ahora el cambio 4 es imposible de hacer por accidente: nadie va a poner el subtotal en un campo que se llama total_charged_mxn. Cada vez que un nombre de campo carga una unidad, una moneda o un criterio de inclusión, estás eliminando una clase entera de bugs silenciosos. En un evento, la verbosidad no es un defecto: es el único mecanismo de defensa que tienes contra el acoplamiento semántico.
Regla 5 — El evento tiene dueño, y no es quien lo publica. El checkout publica OrderCompleted, pero cambiarlo afecta a cinco módulos. En la práctica esto se resuelve con dos acuerdos de equipo, no con código: que todas las definiciones de eventos vivan en un solo lugar (bus/events.py), y que cambiar ese archivo requiera revisión de quien mantiene cada suscriptor. Ese archivo tiene que ser el más cuidado del repositorio, más que el checkout.
Regla 6 — Los cambios se hacen agregando, no modificando. El orden de preferencia, del más seguro al menos:
- Agregar un campo con valor por defecto. Nadie se rompe. Es la operación segura y debería cubrir el 90% de los casos.
- Agregar un campo nuevo y dejar el viejo, marcado como obsoleto. Se publica en los dos durante un tiempo, se migran los suscriptores uno por uno, y cuando ninguno lee el viejo, se quita. Es el camino largo y es el correcto para renombrar.
- Publicar un evento nuevo —
OrderCompletedV2, o mejor, un nombre que diga qué cambió— y publicar los dos durante la transición. Se usa cuando el cambio es tan grande que mantener un solo evento sería peor. - Cambiar o quitar de golpe. Solo si puedes enumerar a todos los suscriptores y cambiarlos en el mismo despliegue. Dentro de un mismo programa, como Boletia, esto es viable y muchas veces es lo correcto: no hay que inventarse un versionado de eventos para un sistema de seis personas y un solo despliegue. Entre procesos distintos, casi nunca lo es.
Regla 7 — Prueba el contrato, no solo los manejadores. Este es el mecanismo que compensa lo que perdiste al pasar de acoplamiento por nombre a acoplamiento por forma:
# Archivo: tests/test_event_contracts.py
def test_order_completed_carries_what_its_subscribers_read():
"""Falla si alguien le quita al evento un campo que alguien lee.
Esta prueba parece tonta y es la más valiosa del archivo: es lo único
que convierte un cambio silencioso en un cambio ruidoso.
"""
expected = {
"order_id", "customer_id", "event_id", "ticket_ids",
"subtotal_mxn", "service_fee_mxn", "total_charged_mxn",
"occurred_at", "source",
}
actual = {f.name for f in dataclasses.fields(OrderCompleted)}
assert expected <= actual, f"Faltan campos del contrato: {expected - actual}"
def test_every_subscriber_survives_the_real_event():
"""Cada manejador registrado corre contra un evento real construido
por el checkout, no por la prueba. Así, si el checkout deja de llenar
un campo, la prueba se entera."""
event = build_order_completed_from_fixture()
for handler in wiring.bus.subscribers_of(OrderCompleted):
handler(event) # si alguno lanza, la prueba falla y dice cuál
Fíjate en la segunda: el punto no es probar la lógica de cada manejador —eso ya lo hacen sus propias pruebas—, sino probar que el evento que se publica de verdad alcanza para todos los que lo escuchan de verdad. Es la única prueba del sistema que ve el contrato completo, y es el reemplazo directo del error de importación que el lenguaje te daba gratis cuando había llamadas directas.
Errores comunes
Creer que el acoplamiento se fue porque no se ve (conceptual). Qué pasa: alguien presenta el refactor diciendo "ahora el checkout no depende de nadie" y el equipo lo acepta, porque los imports desaparecieron y eso es visible. Meses después, un cambio de una palabra en bus/events.py produce datos incorrectos en tres módulos. Por qué pasa: porque la medida intuitiva de acoplamiento es la lista de imports, y esa medida deja de funcionar en el momento en que introduces un contrato compartido. Cómo detectarlo: pregunta "si cambio este archivo, ¿quién se rompe?" sobre bus/events.py. Si la respuesta honesta es "no sé", el acoplamiento existe y además es del tipo que no puedes ver. Cómo corregirlo: trata bus/events.py como tratarías una API pública —porque lo es— con su revisión, su documentación de campos y sus pruebas de contrato.
Meter el objeto del modelo "para no hacer la consulta" (de criterio). Qué pasa: el manejador necesita el Customer, así que alguien lo mete en el evento. Es una línea, ahorra una consulta y se siente eficiente. Después el evento deja de ser serializable, empieza a llevar datos viejos cuando el despacho se vuelve asíncrono, y acopla cinco módulos al modelo de datos. Por qué pasa: porque el costo aparece meses después y el beneficio es inmediato y medible. Cómo detectarlo: si el tipo de algún campo del evento es una clase de models/, ya está pasando. Otra señal: si no puedes convertir el evento a JSON con la biblioteca estándar. Cómo corregirlo: identificadores, y que cada manejador consulte. Si el costo de las consultas resulta ser un problema real y medido —no imaginado—, el remedio es cachear o publicar un evento más rico de forma deliberada y documentada, no engordarlo por conveniencia.
Inventar versionado de eventos antes de necesitarlo (de criterio). Qué pasa: alguien lee sobre sistemas distribuidos y agrega version: int = 1 a todos los eventos, un despachador que enruta por versión y una carpeta events/v1/. Boletia tiene un solo despliegue, seis personas y cero eventos que salgan del proceso. Por qué pasa: porque el versionado es una solución real a un problema real —el de sistemas donde publicador y suscriptor se despliegan por separado— y la solución se copia sin copiar el problema. Es el módulo 2 con otro traje. Cómo detectarlo: pregúntate si existe algún momento en el que un suscriptor viejo pueda recibir un evento nuevo. Si publicador y suscriptores viven en el mismo proceso y se despliegan juntos, ese momento no existe. Cómo corregirlo: cambia el evento y sus suscriptores en el mismo cambio, con la prueba de contrato como red. El versionado entra el día que un suscriptor viva en otro proceso, y ese día lo vas a saber sin dudas.
Ejercicios
Ejercicio 1 — Encuentra los campos ambiguos. Aquí hay un evento de otro sistema. Señala cada campo que puede entenderse de más de una manera, y reescribe el evento aplicando la regla 4.
@dataclass(frozen=True)
class SubscriptionRenewed:
user_id: int
plan: str
amount: float
date: str
duration: int
discount: float
Ver solución
Los seis campos son ambiguos. Cinco de ellos peligrosamente.
amount: ¿con impuestos o sin? ¿en qué moneda? ¿en unidades o en centavos? Tres ambigüedades en una palabra.date: ¿la fecha de la renovación o la del próximo vencimiento? ¿en qué zona horaria? ¿es fecha o instante?duration: ¿días, meses, ciclos? Un número sin unidad es una bomba.discount: ¿un porcentaje (10 = 10%), una fracción (0.10) o un monto absoluto? Los tres son plausibles y el error de interpretación es de un orden de magnitud.plan: texto libre, sin lista de valores válidos. Unstren un contrato compartido invita a que cada suscriptor lo compare a su manera —exactamente el problema deTicket.kindque arrastramos desde el módulo 1.user_id: el menos malo, pero conviene decir de qué sistema es el identificador si hay más de uno.
Una reescritura:
@dataclass(frozen=True)
class SubscriptionRenewed:
"""Se renovó una suscripción y se cobró el periodo siguiente."""
user_id: int
plan_code: str # "basic" | "pro" | "team"
charged_amount_usd_cents: int # lo cobrado, con impuestos, en centavos
tax_amount_usd_cents: int # cuánto de lo anterior fue impuesto
discount_applied_usd_cents: int # monto absoluto descontado, no porcentaje
renewed_at: str # ISO 8601 en UTC, instante de la renovación
period_ends_at: str # ISO 8601 en UTC, fin del periodo pagado
period_length_days: int
Dos observaciones que valen más que la reescritura. La primera: los montos pasaron a enteros en centavos. Los float para dinero producen errores de redondeo que en un evento se propagan a todos los suscriptores a la vez; en un sistema de pagos serio esto no es opcional. Boletia usa float desde el módulo 1 y es una de sus deudas reales — que un ejercicio te haga notar una deuda del caso de estudio es buena señal de que estás leyendo con ojos de ingeniero.
La segunda: el evento creció de seis campos a ocho y nadie lo lamentaría. Un evento no se optimiza por brevedad. Se optimiza por no poder malinterpretarse.
Ejercicio 2 — Clasifica seis cambios. Para cada uno, di si (A) no rompe a nadie, (B) rompe ruidosamente, o (C) rompe en silencio. Y para los de tipo C, di cómo lo detectarías.
- Agregar
coupon_code: str | None = None. - Cambiar
ticket_idsdetuple[int, ...]alist[int]. - Cambiar
occurred_atde hora local a UTC, sin renombrar. - Renombrar
event_idaconcert_id. - Cambiar
total_charged_mxnde pesos a centavos, sin renombrar. - Quitar
source, que solo lee un manejador de analítica.
Ver solución
1 → A. Campo nuevo con valor por defecto. El caso seguro, y la razón por la que el valor por defecto no es opcional: sin él, todos los constructores del evento —incluidas las pruebas— fallan.
2 → C, y con una trampa doble. Ningún manejador se rompe: recorren ticket_ids igual. Pero frozen=True deja de proteger de verdad, porque la lista es mutable: un manejador puede hacer order_completed.ticket_ids.append(...) y el siguiente manejador recibe un evento distinto del que se publicó. Además la clase deja de ser hashable. Cómo detectarlo: una prueba que verifique que todos los campos de todos los eventos son de tipos inmutables. Se escribe una vez y protege para siempre.
3 → C, el caso del formulario. Todos leen occurred_at y todos siguen funcionando; simplemente los datos quedan corridos seis horas. Cómo detectarlo: en la práctica, por una anomalía en un panel —"las ventas del lunes empiezan a las seis de la mañana"—. Cómo prevenirlo: que el nombre cargue la zona (occurred_at_utc) y que la anotación de tipo sea datetime con zona en vez de str.
4 → B. AttributeError en send_organizer_alert y track_order_paid en la primera compra. Ruidoso, siempre que alguien mire el registro. Es el escenario donde la prueba de contrato paga su costo: falla en el momento del cambio y no en producción.
5 → C, y el peor de la lista. Todo sigue corriendo. Las facturas salen por cien veces el monto real, los puntos de lealtad se multiplican por cien, el panel del organizador reporta cifras absurdas. Este último quizá alerte a alguien; los otros dos no. Cómo detectarlo: idealmente antes, con el nombre — un campo llamado total_charged_mxn_cents no se confunde. Con el nombre viejo, la única detección posible es una prueba que verifique el valor concreto contra un caso conocido, o un rango de plausibilidad en el panel.
6 → B, débilmente. El manejador de analítica lanza AttributeError. Ruidoso, pero si el bus aísla y registra, la analítica queda rota en silencio hasta que alguien mire el registro o note que el panel dejó de segmentar por origen. Es el recordatorio de que "ruidoso" depende de que alguien escuche el ruido.
El patrón general que quiero que veas: los cambios de tipo y de nombre rompen ruidosamente; los cambios de unidad, de zona horaria y de criterio de inclusión rompen en silencio. Y esos tres tipos de cambio son, casualmente, los que no se ven en ninguna anotación de tipo. Por eso la regla 4 —nombres explícitos hasta ser aburridos— no es un capricho de estilo: es la única barrera que existe contra la categoría de bug más cara del módulo.
Ejercicio 3 — Decide entre delgado y gordo. El equipo de notificaciones se queja: send_buyer_confirmation hace dos consultas —el Customer y el Order— y con la venta de un festival grande eso son miles de consultas en pocos minutos. Proponen meter en el evento el correo del cliente y la lista de boletos con su asiento, para no consultar nada. Escribe tu recomendación con su razón, y qué medirías antes de decidir.
Ver solución
Qué medir primero, siempre. ¿Cuántas consultas por segundo son de verdad en el pico? ¿Cuánto tardan? ¿Son el cuello de botella medido o el sospechoso intuitivo? En mi experiencia, dos consultas por venta contra una base de datos con índices, en un sistema que además está cobrando con una pasarela externa que tarda cientos de milisegundos, casi nunca son el problema. Optimizar el contrato del sistema para resolver un problema que no se midió es cómo se acumula deuda con la mejor de las intenciones.
Si la medición confirma el problema, mi recomendación es intermedia y ordenada por costo:
- Cachear las consultas por identificador durante el despacho. Los cinco manejadores piden el mismo
Customery la mismaOrder; un caché de vida corta reduce diez consultas a dos sin tocar el contrato. Es lo primero que probaría y suele bastar. - Si aun así no alcanza, engordar el evento con criterio. Y aquí la clave es qué se agrega:
customer_emailes un dato razonable porque cambia poco y porque el correo al que se mandó la confirmación es, en cierto sentido, parte del hecho. La lista de boletos con su asiento no, porque es estado que puede cambiar —un asiento se puede reasignar— y meterlo obliga a los manejadores a razonar sobre datos posiblemente viejos. - Lo que no haría en ningún caso es meter los objetos
CustomeryOrdercompletos. Eso no es engordar el evento: es mudar el modelo de datos adentro del contrato, con las tres consecuencias de la regla 2.
Y la parte que casi nadie escribe: si el evento engorda, hay que documentar en el propio evento qué garantía tiene ese dato. Un comentario que diga "customer_email es el correo al momento de la compra; si el cliente lo cambió después, este campo no lo refleja" evita la discusión futura sobre por qué la confirmación fue a un correo viejo.
Por qué funciona: es la primera vez en el módulo que la respuesta correcta empieza con "mide", y no va a ser la última. La regla del evento delgado es una buena regla por defecto, no un dogma; lo que la convierte en criterio es saber exactamente qué evidencia haría falta para romperla.
Resumen y siguiente paso
En esta lección desarmaste la idea de que los eventos eliminan el acoplamiento. Viste que lo transforman: de acoplamiento por nombre —ruidoso, visible, verificado por el lenguaje— a acoplamiento por forma y, sobre todo, por significado, que es invisible y silencioso. Recorriste cuatro cambios a OrderCompleted y comprobaste que el más chico de todos, cambiar qué quiere decir total, es el más caro con diferencia: no rompe nada y deja todo mal.
Te llevas siete reglas para diseñar un evento que aguante: nombre en pasado y del negocio; identificadores y datos del hecho en vez de objetos vivos; inmutable y sin comportamiento; explícito hasta ser aburrido, con unidades y monedas en el nombre; dueño compartido y un solo archivo cuidado; cambios que agregan en vez de modificar; y pruebas de contrato, que son el reemplazo directo del error de importación que el lenguaje te daba gratis.
Antes de avanzar deberías poder: explicar en una frase por qué un cambio de unidad es más peligroso que borrar un campo; decidir si un dato va en el evento o si va su identificador, usando la prueba de "¿seguiría siendo cierto dentro de un año acerca de ese momento?"; y escribir la prueba de contrato que verifica que el evento publicado alcanza para todos sus suscriptores.
Hasta aquí el módulo trató sobre hechos: cosas que ya pasaron y que se anuncian. La lección 5 gira noventa grados y trae el otro patrón de la familia, que trabaja con lo contrario: órdenes, cosas que todavía no pasaron y que alguien quiere que pasen. Command empaqueta una acción como objeto para poder guardarla, encolarla, reintentarla o deshacerla. Y viene con la advertencia más grande del módulo, que voy a adelantarte para que leas esa lección con la guardia alta: en la enorme mayoría del código que vas a escribir, una función basta.
Recursos
- Martin Fowler — What do you mean by "Event-Driven"? — las secciones de Event Notification y Event-Carried State Transfer son exactamente la tensión delgado/gordo de la regla 2, explicada por quien acuñó los términos.
- Martin Fowler — Domain Event — por qué un hecho se nombra en pasado y por qué no debe llevar comportamiento.
- Python — dataclasses —
frozen=True, valores por defecto ydataclasses.fields(), que es lo que usa la prueba de contrato de la regla 7. - Semantic Versioning — no es sobre eventos, pero su distinción entre cambios que agregan y cambios que rompen es exactamente la de la regla 6, y el vocabulario se traslada tal cual a una conversación de equipo.