Qué significa realmente la arquitectura orientada a eventos
La arquitectura orientada a eventos es un estilo de comunicación en el que los servicios registran eventos —declaraciones en tiempo pasado de que algo sucedió— y reaccionan a ellos, en lugar de llamarse entre sí directamente. Un servicio de pedidos no accede a un servicio de facturación para decirle “factura esto”. Registra order.placed y continúa. Un servicio de facturación al que le interese ese hecho se suscribe a él y realiza la factura en su propio momento.
La palabra clave es hecho. Un evento es inmutable y ya es una realidad. order.placed describe algo que ya ocurrió; ningún consumidor puede vetarlo y ningún productor está esperando una respuesta. Una solicitud es una pregunta con una respuesta, y el emisor queda bloqueado hasta que esta llega. Un evento es una declaración con una audiencia, y el productor termina su tarea en el momento en que la declaración queda persistida.
Esa diferencia parece pequeña, pero lo cambia todo. Debido a que el productor no llama a nadie, no necesita saber a quién le interesa la información. Debido a que los consumidores no responden, pueden ser lentos, reiniciarse o añadirse más tarde sin que el productor lo note. El coste es que el sistema ya no tiene un único hilo de control que se pueda seguir, y la corrección ahora depende de que cada participante gestione los duplicados, los retrasos y el reordenamiento de manera adecuada.
Esta guía trata sobre la maquinaria que hace reales esas garantías y sobre los casos en los que el intercambio no vale la pena.
Los comandos ordenan, los eventos describen
La fuente más común de confusión en los sistemas orientados a eventos es llamar “eventos” a ambos tipos de mensajes. Mantenlos separados.
Un comando es una instrucción: reserve-inventory, charge-card, send-welcome-email. Es imperativo, está dirigido a un handler que se espera que actúe, y puede fallar o ser rechazado. Hay exactamente un propietario de un comando, y al remitente generalmente le importa el resultado.
Un evento es una descripción: inventory-reserved, card-charged, user-registered. Está en tiempo pasado, es un hecho propiedad del productor y puede tener cero, uno o mil consumidores. Ningún consumidor puede rechazarlo, porque ya es una realidad. El productor no sabe ni le importa quién lo lea.
// A command asks for something and expects a handler to decide.
await commands.send("reserve-inventory", { orderId, quantity: 2 });
// An event reports that a decision was made. Nobody may reject it.
await events.publish("inventory.reserved", { orderId, quantity: 2 });
La nomenclatura no es pedantería. Un canal lleno de comandos es una llamada a procedimiento distribuida y tiene todo el acoplamiento de una. Un canal lleno de eventos es una transmisión (broadcast) y puede extenderse sin pedir permiso. Si el nombre de un mensaje no tiene tiempo verbal — inventory-reservation — nadie que lea el código más tarde podrá saber si se trata de una solicitud o de un hecho.
Una prueba útil: ¿puede el remitente continuar sin conocer el resultado? Si la respuesta es sí, probablemente sea un evento. Si el remitente necesita tomar una decisión basada en el resultado, es un comando, y deberías rutearlo a un único handler en lugar de transmitirlo a todos.
Es común necesitar ambos en un mismo flujo. Un servicio de checkout envía un comando charge-card al servicio de pagos y espera la respuesta, ya que no puede confirmar el pedido sin ella. Una vez que el pago tiene éxito, el servicio de pagos publica payment.captured, y todas las demás partes interesadas reaccionan de forma asíncrona. El comando es la columna vertebral síncrona; los eventos son la distribución (fan-out). Mezclarlos deliberadamente está bien; el error es fingir que un comando es un evento y sorprenderse cuando nadie responde.
Los eventos, el event sourcing y CQRS son tres ideas diferentes
Estos tres conceptos están relacionados, se utilizan frecuentemente juntos y son totalmente separables. Tratarlos como si fueran una sola cosa es la manera más rápida de construir un sistema más complicado que el problema que intenta resolver.
Event-driven es un estilo de comunicación. Los servicios intercambian hechos a través de un broker. La base de datos sigue siendo la fuente de verdad, y podrías eliminar el broker mañana mismo y solo perderías el desacoplamiento.
Event sourcing es un estilo de persistencia. En lugar de almacenar la fila actual, almacenas la secuencia ordenada de eventos que la produjeron, y el estado actual es el resultado de procesar (fold) esa secuencia. El log es la fuente de verdad. Reconstruir el saldo de una cuenta significa reproducir sus depósitos y retiros. Esto te proporciona una pista de auditoría perfecta y la capacidad de viajar atrás en el tiempo, a costa de lecturas más complejas y un proceso de migración más difícil.
CQRS (Command Query Responsibility Segregation) consiste en separar el modelo de escritura del modelo de lectura. Los comandos pasan a través de un modelo optimizado para la validación e invariantes; las consultas leen de una o más proyecciones optimizadas para la búsqueda. Los dos modelos pueden compartir una base de datos o utilizar almacenes completamente diferentes.
Puedes adoptar cualquiera de ellos sin los demás:
- Event-driven sin event sourcing: los servicios publican hechos, pero cada uno mantiene una tabla normal.
- Event sourcing sin un broker: un único servicio almacena sus eventos en su propia base de datos.
- CQRS sin eventos: dos modelos sobre los mismos datos, sincronizados de forma síncrona.
La mayoría de los equipos deberían comenzar con una comunicación event-driven sencilla y una base de datos normal. El event sourcing es un compromiso serio, y recurrir a él solo porque la arquitectura suena impresionante es una forma segura de arrepentirse.
Pub/sub, topics y fan-out
El transporte que hace que los eventos funcionen es el modelo publish/subscribe. Los productores publican en un canal con nombre, generalmente llamado topic o exchange, y los consumidores se suscriben a los topics que les interesan. El broker se encarga del enrutamiento.
La propiedad clave es que el productor no se dirige a los consumidores. Publica una sola vez en orders, y el broker entrega el mensaje a cada suscripción: facturación, cumplimiento, indexación de búsqueda, analíticas, detección de fraude. Añadir un sexto consumidor es un cambio de configuración en dicho consumidor, no un cambio de código en el productor.
// One publish, many independent subscribers.
await broker.publish("orders", {
type: "order.placed",
data: { orderId, customerId, totalCents },
});
Existen dos tipos principales de brokers, y la elección afecta a lo que puedes construir:
- Los brokers basados en logs, como Kafka, conservan cada evento durante una ventana de retención y permiten que cada grupo de consumidores rastree su propia posición. Los consumidores pueden reproducir el historial, y muchos grupos pueden leer el mismo topic de forma independiente.
- Los brokers basados en colas o exchanges, como RabbitMQ, enrutan cada mensaje a una o más colas, y normalmente el mensaje se elimina una vez confirmado (acknowledged). El enrutamiento es flexible, pero la reproducción de mensajes no es parte del modelo.
Un topic debe nombrarse según el hecho que transporta, no según el consumidor que lo lee. orders y users envejecen bien; billing-inbox no, porque el día que aparezca un segundo consumidor, el nombre será mentira. Mantén los topics estables y deja que las suscripciones sean lo que cambie.
El modelo de confirmación (acknowledgement) es lo que decide las garantías de entrega. Un consumidor que confirma antes de realizar el trabajo corre el riesgo de perder un evento si ocurre un crash; uno que confirma después de realizar el trabajo corre el riesgo de procesarlo dos veces. Casi todos los brokers usan por defecto la segunda opción, y es por eso que la idempotencia no es opcional. Algunos sistemas también permiten que una suscripción lógica reciba un evento una sola vez, incluso con muchos consumidores compitiendo —una work queue—, mientras que otros entregan a cada suscriptor su propia copia —un broadcast—. Asegúrate de saber cuál de los dos proporciona un topic antes de confiar en cualquiera de ellos.
Consistencia eventual y por qué los consumidores tienen lag
En el momento en que un productor deja de esperar, el sistema se vuelve eventualmente consistente. Después de que order.placed se confirma (commit), el pedido existe inmediatamente en la base de datos de pedidos, pero aún no en la factura, el índice de búsqueda o el almacén de analíticas. Existe una ventana —de milisegundos bajo carga normal, de minutos durante un incidente— en la que esas vistas no coinciden.
Esto no es un defecto que haya que ocultar; es la propiedad definitoria de este estilo. Cada ruta de lectura construida sobre un evento debe responder a dos preguntas: ¿qué tan obsoletos pueden estar estos datos y qué ve el usuario mientras tanto?
Algunos hábitos hacen que esto sea manejable:
- Lee tus propias escrituras desde la fuente. Después de que un usuario realice una acción, redirígelo a una vista servida por el modelo de escritura, no por una proyección que aún no se ha actualizado.
- Muestra un estado honesto. Un
202 Acceptedconstatus: "processing"es mejor que una página que parpadea pasando de estar vacía a estar poblada. - Mide el lag. La brecha entre el evento publicado más reciente y la posición de un consumidor es la señal de salud más útil de todo el sistema.
Un ejemplo concreto hace que esta ventana sea tangible. Un cliente realiza un pedido y la página de confirmación es servida por el servicio de pedidos, por lo que es correcta inmediatamente. Su página de cuenta es servida por una proyección construida a partir de order.placed, así que durante los siguientes doscientos milisegundos no muestra pedidos. Si la proyección tiene unos segundos de retraso durante un despliegue, el cliente ve “sin pedidos” y abre un ticket de soporte. Nada de esto es un bug en el flujo de eventos; es el flujo funcionando según lo diseñado, y la UI debe construirse teniendo esto en cuenta.
El lag es normal y aumenta por razones comunes: un pico de tráfico, una API downstream lenta, el reinicio de un consumidor o un rebalanceo. Un lag que crece sin límite es un problema de capacidad, y es invisible a menos que lo pongas en un dashboard. Una buena proyección rastrea su propia posición y la expone, de modo que la brecha sea un número sobre el cual puedas generar alertas en lugar de una sensación que descubres a través de las quejas.
Hay una garantía de consistencia que vale la pena mantener incluso en un sistema eventualmente consistente: lecturas monotónicas. Un consumidor nunca debería retroceder. Si llega un evento con una marca de tiempo más antigua que uno ya aplicado, aplicarlo fuera de orden puede resucitar datos eliminados o hacer retroceder un contador. Versiona tus proyecciones mediante el número de secuencia o el offset, no por la hora del reloj, e ignora cualquier cosa que sea más antigua de lo que ya has procesado.
El problema de la doble escritura y el transactional outbox
Este es el fallo que atrapa a casi todo el mundo. Un servicio necesita modificar su base de datos y publicar un evento. Escribe la fila y luego llama al broker. ¿Qué pasa si la publicación falla? ¿Qué pasa si el proceso se interrumpe entre ambas acciones?
- Hacer commit primero y luego publicar: la fila existe, el evento nunca ocurrió y los servicios downstream pierden el cambio silenciosamente.
- Publicar primero y luego hacer commit: el evento anuncia un estado que nunca se guardó, y los consumidores actúan sobre un hecho que no es real.
No hay forma de hacer que una escritura en la base de datos y una publicación en red sean atómicas. Este es el dual-write problem, y no se puede solucionar ordenando las dos llamadas con más cuidado. Un broker que soporte transacciones no ayuda, porque la base de datos es un sistema independiente.
La respuesta estándar es el transactional outbox. En lugar de publicar directamente, escribe el evento en una tabla outbox dentro de la misma transacción que el cambio de estado. O bien se hacen commit de ambas filas, o de ninguna. Un relay independiente —un worker de polling o un conector de change-data-capture que siga el log de la base de datos— lee las filas no publicadas y las envía al broker.
BEGIN;
INSERT INTO orders (id, customer_id, status, total_cents)
VALUES ($1, $2, 'placed', $3);
INSERT INTO outbox (id, topic, payload)
VALUES ($1, 'orders', $2);
COMMIT;
El relay luego elimina o marca las filas una vez publicadas. Si falla después de publicar pero antes de marcar, el evento se publicará dos veces, que es exactamente la razón por la cual los consumidores deben ser idempotentes. Si falla antes de publicar, la fila sigue ahí y será procesada en la siguiente pasada. De cualquier manera, no se pierde ningún evento.
Dos detalles son importantes. El relay debe reclamar las filas con FOR UPDATE SKIP LOCKED (o un equivalente) para que múltiples instancias del relay no publiquen la misma fila simultáneamente. Y el outbox debe ser depurado, porque una tabla que solo crece eventualmente se convertirá en el objeto más grande de tu base de datos.
Entrega at-least-once y consumidores idempotentes
Cualquier broker que valga la pena ofrece una entrega at-least-once. No ofrece exactly-once, porque lograr exactly-once a través de una red y ante un fallo del sistema es, en la práctica, imposible. Un consumidor puede procesar un evento, fallar antes de confirmarlo (acknowledge) y recibirlo de nuevo al reiniciar. Un outbox relay puede publicar una fila dos veces. Un productor puede reintentar una solicitud que expiró por timeout pero que en realidad tuvo éxito.
Este es el contrato, no un error. Tus consumidores deben ser idempotentes: procesar el mismo evento dos veces debe dejar el mismo estado final que procesarlo una sola vez.
Existen tres patrones prácticos:
Idempotencia natural. Algunas operaciones ya son seguras de repetir. Establecer un estado a shipped dos veces es lo mismo que hacerlo una. Insertar con ON CONFLICT DO UPDATE converge. Prioriza estas opciones siempre que sea posible.
Una tabla de deduplicación. Registra cada id de evento procesado con una restricción de unicidad (unique constraint), dentro de la misma transacción que el trabajo. Si la inserción genera un conflicto, significa que el evento ya ha sido gestionado y el consumidor finaliza la ejecución prematuramente. Esta es la solución de propósito general y la primera que deberías considerar.
Claves de idempotencia del proveedor. Las pasarelas de pago y muchas API aceptan una clave estable y devuelven el resultado original en lugar de repetir el efecto secundario. Combina esto con tu propia deduplicación, ya que la clave protege la llamada, no la lógica circundante.
const seen = await client.query(
`INSERT INTO processed_events (event_id, consumer)
VALUES ($1, 'billing')
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id],
);
if (seen.rowCount === 0) return { skipped: true };
Observa que la clave de deduplicación es el id del evento, no el id del pedido. Esto hace que el consumidor sea seguro incluso cuando el productor publica legítimamente dos eventos diferentes sobre el mismo pedido: order.placed y order.cancelled son hechos distintos y ambos deben procesarse.
La idempotencia es una propiedad del efecto, no del transporte. Un broker puede filtrar ids de eventos duplicados en el borde, lo cual ayuda, pero no puede saber si tu handler ya envió un correo electrónico o realizó un cargo a una tarjeta. Solo el consumidor, dentro de la misma transacción que su efecto secundario, puede decidir eso. Es por esto que la deduplicación debe estar junto a la escritura y no en una capa de middleware que se ejecute antes.
Orden y particionamiento
Un broker que distribuye la carga entre muchas particiones no puede garantizar un orden global. Kafka ordena los registros dentro de una partición; RabbitMQ ordena dentro de una cola atendida por un único consumidor. A nivel de sistema, los eventos llegan en el orden que permitan la red y la planificación.
Esto no es un problema siempre y cuando elijas una partition key que coincida con el orden que necesitan tus consumidores. Publica cada evento de un cliente con key = customerId, y todos los eventos de ese cliente caerán en la misma partición y se procesarán en orden. Los diferentes clientes se distribuirán entre las particiones y se procesarán en paralelo.
await producer.publish("orders", {
key: event.data.customerId, // ordering is per key
value: event,
});
La trampa es elegir una clave que no coincida con la invariante. Si usas orderId como clave cuando los consumidores necesitan un orden por cliente, obtendrás un paralelismo no deseado y un orden en el que no puedes confiar. Si asignas a todo una única constante, obtendrás un orden perfecto pero sin ningún tipo de paralelismo.
Dos advertencias más. Cambiar el número de particiones más adelante altera el mapeo de la clave a la partición, por lo que los eventos de una entidad pueden terminar divididos entre dos particiones y perder su orden relativo. Decide el número de particiones dejando un margen de crecimiento. Además, si un consumidor procesa una partición de forma serial, un único evento lento bloqueará todo lo que esté detrás, así que mantén el trabajo por evento acotado.
Evolución y versionado de esquemas
Un evento es un contrato entre un productor y consumidores que se despliegan de forma independiente. El productor se actualizará mientras haya consumidores antiguos aún en ejecución, y un consumidor nuevo leerá eventos escritos hace meses. La estructura del payload debe sobrevivir en ambas direcciones.
Las reglas son las mismas que para una API pública:
- Añade, no renombres ni elimines. Los campos nuevos son opcionales y los consumidores deben asignarles un valor por defecto.
- Versiona cuando el significado cambie. Un campo
versionpermite que un handler bifurque la lógica explícitamente en lugar de intentar adivinar. - Nunca reutilices el nombre de un campo para un concepto diferente. Así es como comienza una corrupción de datos silenciosa.
- Trata los eventos antiguos como válidos para siempre. El replay significa que el payload de ayer debe poder parsearse hoy.
export type OrderPlacedV2 = {
type: "order.placed";
version: 2;
data: {
orderId: string;
totalCents: number;
currency?: string; // added later; v1 events simply lack it
};
};
A escala, un schema registry convierte esto en un problema gestionado. Los productores registran un esquema —Avro, Protobuf o JSON Schema— y el registro le asigna un id, impone un modo de compatibilidad y rechaza cualquier cambio que rompería a los lectores existentes. Incluso sin un registro, mantener un archivo de esquema versionado en el repo y revisar sus cambios te aporta la mayor parte del beneficio.
La disciplina da sus frutos precisamente durante los incidentes. Cuando un despliegue rompe un consumidor, la primera pregunta es si el productor cambió un payload de una manera que nadie acordó.
Los contract tests son la versión económica de un registro. Mantén un archivo de fixtures con eventos reales por versión, y haz que cada consumidor los parsee todos en el CI. Un consumidor que falle con una fixture v1 fallará en producción la primera vez que se haga un replay de un evento antiguo. Esto solo cuesta unos pocos archivos y detecta el tipo de error que, de otro modo, aparecería días después como una proyección corrupta.
Coreografía y orquestación
Un proceso de negocio de múltiples pasos construido a partir de eventos puede coordinarse de dos maneras, y la diferencia es significativa.
La coreografía significa que cada servicio escucha eventos y reacciona, sin un coordinador central. El servicio de pedidos publica order.placed; el de inventario reserva el stock y publica inventory.reserved; el de pagos realiza el cobro y publica payment.captured; y el de envíos reacciona a ello. Cada servicio solo conoce los eventos que consume y produce. Es un modelo flexible, extensible y fácil de ampliar añadiendo pasos. Sin embargo, es difícil visualizar el proceso completo, ya que el flujo existe únicamente como la suma de las suscripciones de todos los participantes.
La orquestación significa que un componente central —un orquestador de saga o gestor de procesos— indica explícitamente a cada servicio qué hacer y rastrea el estado del proceso. El flujo reside en un solo lugar, lo que lo hace visible, testeable y fácil de razonar. El coste es contar con un coordinador del cual todos los servicios deben depender y que puede convertirse en un cuello de botella y en un punto único de fallo.
Ninguna de las dos opciones es universalmente correcta:
- La coreografía es adecuada para reacciones simples, mayormente independientes y pasos estables.
- La orquestación es adecuada para procesos largos con muchas ramas condicionales, timeouts y compensaciones.
Un enfoque pragmático común es coreografiar el “happy path” entre unos pocos servicios e introducir un orquestador solo para aquel proceso que se haya vuelto lo suficientemente complejo como para necesitarlo.
La señal de que la coreografía ha ido demasiado lejos es cuando un cambio requiere editar muchos servicios a la vez, o cuando hay un proceso que nadie en el equipo puede describir sin abrir cinco repositorios. Cuando añadir una sola regla de negocio implica tocar seis consumidores, el flujo ha dejado de ser un conjunto de reacciones independientes para convertirse en un programa distribuido sin autor. Ese es el momento de trasladarlo a un orquestador.
Sagas y compensaciones
En un monolito, una operación de varios pasos puede envolverse en una transacción de base de datos y revertirse en caso de fallo. Entre servicios no existe una transacción compartida, por lo que un proceso distribuido no puede simplemente abortar. Si el pago tuvo éxito pero el envío falló, no puedes deshacer el pago con un ROLLBACK.
El patrón saga soluciona esto. Una saga es una secuencia de transacciones locales, donde cada una publica un evento que dispara el siguiente paso. Si un paso falla, la saga ejecuta acciones compensatorias para los pasos que ya tuvieron éxito: reembolsar el pago, liberar el inventario reservado, marcar el pedido como cancelado. La compensación no es un rollback; es una nueva acción de negocio que deshace el efecto, y es en sí misma un evento que debe ser idempotente.
// Forward path
// order.placed -> inventory.reserved -> payment.captured -> order.confirmed
// If payment fails, compensate the steps that already ran.
await events.publish("payment.failed", { orderId, reason });
// inventory service listens and releases the reservation
Dos reglas de diseño mantienen las sagas bajo control. Primero, cada paso debe ser idempotente, ya que un reintento puede ejecutarlo de nuevo. Segundo, cada paso necesita una acción compensatoria definida de antemano; si un paso no se puede deshacer, la saga no puede fallar de forma segura después de él, por lo que ese paso debe ir al final o requiere un diseño diferente. Las sagas también hacen que los estados intermedios sean visibles, por lo que la UI debería mostrar “reservando stock” y “esperando pago” en lugar de fingir que la operación es atómica.
Una saga necesita su propio estado. Ya sea que el orquestador almacene el paso actual en una tabla, o que cada servicio rastree los eventos que ha recibido. Ese estado es lo que permite que un proceso se reanude tras una caída, que se agote el tiempo de espera de un paso que nunca respondió y que se sepa qué compensaciones están pendientes. Una saga sin estado persistido es una secuencia de mensajes que eventualmente quedará atrapada en un estado que nadie podrá reconstruir.
Los timeouts merecen especial atención, ya que un paso que nunca responde es el fallo más común. Si el pago no tiene éxito ni falla dentro de un plazo determinado, la saga debe decidir: reintentar, compensar o dejar el pedido en espera para una revisión manual. Dejarlo sin decidir significa que el pedido queda en el limbo para siempre, reteniendo un inventario que nunca será liberado.
Colas de mensajes no entregados (Dead-letter queues) y replay
Un evento que un consumidor no puede procesar fallará cada vez que se intente reintentar: ya sea por un payload malformado, un bug en el handler o una fila referenciada que no existe. Reintentarlo infinitamente consume un slot del consumidor y bloquea todo lo que esté detrás en la partición o cola.
La solución es una dead-letter queue (DLQ). Tras un número configurado de intentos, el broker mueve el evento —payload, headers, contador de intentos y el último error— a una cola separada que ningún consumidor lee. El tráfico saludable sigue fluyendo y un operador puede inspeccionar el evento fallido, corregir la causa y ejecutar un replay.
El replay es el superpoder silencioso de los sistemas event-driven. Debido a que los eventos son duraderos, puedes reprocesar el historial después de corregir un bug: reconstruir una proyección que se calculó mal, hacer un backfill de un servicio que se añadió tarde o ejecutar nuevamente los eventos de un día entero contra una nueva lógica. El requisito es que los consumidores sean idempotentes, ya que el replay les enviará eventos que es posible que ya hayan procesado.
Trata la DLQ como una superficie operativa, no como un cementerio. Configura alertas según su profundidad, inclúyela en el dashboard y construye una ruta de replay antes de que la necesites a las 2 a.m. Una dead-letter queue que nadie supervisa es donde se esconden los bugs.
El replay también requiere una estrategia de retención. Solo puedes reprocesar los eventos que el broker aún conserve, por lo que la ventana de retención define hasta qué punto puede llegar una corrección. Un topic que conserva siete días no puede reconstruir una proyección tras un bug que estuvo activo durante dos semanas. Decide la retención basándote en la recuperación que realmente necesitas, y recuerda que cada byte se multiplica por la replicación y por el almacenamiento propio de cada consumidor.
Visualizando el flujo de eventos
La parte más difícil de los sistemas orientados a eventos no es construirlos, sino entender qué sucedió después de que algo fallara. Una sola acción del usuario puede generar una docena de eventos a través de seis servicios, y el fallo podría estar en el tercer consumidor del quinto evento.
Tres prácticas hacen que el flujo sea observable:
- Correlation id. Genera un id en el borde (edge), inclúyelo en cada evento y en cada línea de log, y propágalo a través de cada salto. Un simple grep permite entonces reconstruir todo el flujo.
- Tracing. OpenTelemetry y herramientas similares modelan un evento como un span vinculado al evento que lo originó, lo que convierte el flujo en un grafo legible.
- Consumer lag y profundidad de la DLQ. Estas dos métricas detectan la mayoría de los problemas antes que el usuario: un consumidor que se queda atrás o un handler que ha empezado a fallar.
await events.publish("order.placed", {
...event,
correlationId: req.id, // set once, carried everywhere
});
Registra el id y el tipo de evento tanto en el lado de la publicación como en el del consumo. Sin esto, depurar significa correlacionar timestamps entre servicios y esperar que los relojes estén sincronizados.
Un dashboard útil para un flujo de eventos tiene tres filas: tasa de publicación por tipo de evento, consumer lag por grupo y profundidad de la DLQ por consumidor. Una tasa de publicación que cae a cero significa que un productor se detuvo; un lag que aumenta significa que un consumidor no puede mantener el ritmo; una profundidad de DLQ que crece significa que un handler está roto. Entre ellas, estas tres señales explican la mayoría de los incidentes antes de que alguien tenga que abrir un log.
Eventos “thin”, eventos “fat” y el contrato del payload
Una pregunta de diseño recurrente es cuántos datos debe transportar un evento. Un evento thin (delgado) contiene únicamente un identificador y un tipo — order.placed con un orderId. Un evento fat (grueso) o enriquecido transporta una instantánea completa: líneas de pedido, totales, la dirección de entrega tal como estaba en el momento del pedido.
Los eventos fat hacen que los consumidores sean más simples y robustos. Un indexador de búsqueda que recibe el pedido completo no tiene que realizar una llamada de vuelta al servicio de pedidos, lo que elimina una dependencia en tiempo de ejecución y un posible modo de fallo. Sin embargo, también amplían el contrato: cada campo es ahora algo de lo que un consumidor puede depender, por lo que cambiar la estructura se vuelve más difícil, y el evento puede transportar datos que un consumidor específico no tiene permitido ver.
Los eventos thin mantienen el contrato al mínimo y el payload pequeño, pero cada consumidor debe obtener el estado actual, lo que reintroduce el acoplamiento y puede leer datos que han cambiado desde entonces. El evento deja de ser un hecho completo para convertirse en un puntero.
Un valor predeterminado viable es incluir los campos que definen el hecho y que son seguros de compartir, y referenciar todo lo demás mediante un id. order.placed debería transportar el id del pedido, el id del cliente y el total, porque eso es el hecho. No debería incrustar el perfil completo del cliente. La prueba es sencilla: ¿puede un consumidor entender el evento para el propósito que anuncia sin convertirse en una copia de tu base de datos?
Cualquiera que sea tu elección, congela los valores que no deben cambiar. Si se cotizó un precio al finalizar la compra, el evento debe transportar ese precio incluso si el instinto de un evento thin sugiere buscarlo más tarde; porque más tarde, el precio podría ser diferente y el evento describiría entonces un hecho que nunca sucedió.
Cómo elegir un broker sin entrar en guerras de frameworks
El broker es la decisión menos interesante y, sin embargo, la que más discusiones genera en los equipos. Tres familias cubren casi todos los casos:
- Los brokers basados en logs, como Kafka y NATS JetStream, conservan los eventos durante un periodo de tiempo y permiten que cada grupo de consumidores rastree su propia posición. Elígelos cuando el replay, el alto throughput o la existencia de muchos lectores independientes sean requisitos fundamentales.
- Los brokers basados en exchanges, como RabbitMQ, enrutan mensajes a colas utilizando routing keys, con confirmación (acknowledgement) por mensaje, prioridades y dead-letter exchanges. Elígelos cuando el enrutamiento y la distribución de tareas sean más importantes que el historial.
- Las colas en la nube, como SQS y Pub/Sub, están totalmente gestionadas y son efectivamente ilimitadas, a cambio de APIs de más bajo nivel y un menor control sobre el orden y la programación.
La guía honesta es comenzar con lo que ya corre en tu plataforma y lo que tu equipo ya entiende. Un sistema correcto sobre un broker familiar es mejor que uno teóricamente perfecto sobre un broker que nadie sabe operar. Migra cuando una limitación específica —ya sea el replay, el enrutamiento o el throughput— realmente te afecte, no porque una charla en una conferencia prefiriera una herramienta diferente.
Cualquiera que elijas, ocúltalo detrás de una interfaz de publisher ligera en tu propio código. publish(topic, event) es una costura estable; un SDK de un proveedor no lo es. Esto mantiene la decisión del broker reversible y permite que los tests publiquen en un colector en memoria en lugar de un cluster real.
Probando un flujo de eventos
Los eventos son asíncronos, lo que hace que las pruebas se sientan incómodas hasta que separas sus partes.
Prueba el productor haciendo aserciones sobre el outbox, no sobre el broker. Después de llamar al servicio, tanto la fila de estado como la fila del outbox deben existir, y el payload debe coincidir con el esquema. No es necesario un broker.
Prueba el consumidor como una función pura de un evento. Aliméntalo con un payload y haz una aserción sobre el estado resultante. Luego, aliméntalo con el mismo payload dos veces y verifica que la segunda ejecución no realice ninguna acción (no-op); esta es la prueba de idempotencia, y detecta el tipo de error que solo aparece durante una redelivery en producción.
Prueba el cableado (wiring) con un broker real en un contenedor: publica un evento, espera a que el consumidor lo procese y haz una aserción sobre el estado final. Utiliza un group id o una cola única por cada ejecución de la prueba para que los offsets confirmados nunca se filtren entre ejecuciones, y haz aserciones sobre los registros recibidos en lugar de basarte en el tiempo.
test("reprocessing an event is a no-op", async () => {
await onOrderPlaced(event);
await onOrderPlaced(event); // redelivery
const { rows } = await pool.query(
"SELECT count(*)::int AS n FROM invoices WHERE order_id = $1",
[event.data.orderId],
);
expect(rows[0].n).toBe(1);
});
Mantén la aserción asíncrona determinista realizando polling del estado esperado con un timeout, en lugar de usar un sleep con un intervalo fijo. Una prueba que pasa porque esperó lo suficiente es una prueba que fallará aleatoriamente (flake) en una máquina más lenta.
Cuándo brilla la arquitectura orientada a eventos y cuándo perjudica
La arquitectura orientada a eventos es un intercambio, y vale la pena ser explícitos sobre en qué lado de la balanza te encuentras.
Brilla cuando necesitas desacoplamiento entre equipos que despliegan de forma independiente, fan-out hacia múltiples lectores, capacidad de reproducir el historial (replay), un rastro de auditoría duradero o un flujo natural para analíticas y búsquedas. Encaja en sistemas donde una reacción downstream puede llegar con un ligero retraso y donde añadir un nuevo consumidor no debería requerir cambios en el productor.
Perjudica cuando el dominio es pequeño y tiene forma de CRUD, cuando una operación debe ser inmediata y fuertemente consistente, o cuando el equipo es demasiado pequeño para operar un broker y razonar sobre la consistencia eventual. En esos casos, un monolito bien estructurado con módulos claros y una única base de datos es más simple, más rápido de construir y más fácil de depurar. Los eventos siempre se pueden añadir más tarde, y un monolito modular es un punto de partida mucho mejor que un sistema distribuido que nadie puede rastrear.
Una regla razonable: no introduzcas un broker hasta que puedas nombrar el problema específico que resuelve. “Los microservicios usan eventos” no es la definición de un problema. Escribe qué esperas ganar —un nuevo consumidor sin tocar el productor, replay después de un bug, un rastro de auditoría— y verifica más tarde si lo conseguiste. Si la respuesta honesta es “queríamos vernos modernos”, el broker es un coste sin retorno.
Si decides adoptarla, hazlo de forma incremental. Empieza con un solo evento que resuelva un problema real, ejecútalo en producción durante un tiempo y aprende cómo se comportan el lag, los duplicados y los cambios de esquema en tu equipo y en tu infraestructura antes de convertir los eventos en la columna vertebral del sistema.
Mejores prácticas
- Nombra los eventos en tiempo pasado y asígnales la propiedad en el productor; nombra los comandos por separado.
- Escribe el evento en un outbox dentro de la misma transacción que el cambio de estado.
- Publica a través de un relay o un conector CDC, nunca directamente desde un request handler.
- Haz que cada consumidor sea idempotente mediante una clave de deduplicación derivada del id del evento.
- Elige una partition key que coincida con el orden que cada consumidor realmente necesita.
- Versiona los payloads de los eventos y haz que evolucionen de forma aditiva; nunca reutilices un campo para otro propósito.
- Mantén los consumidores pequeños y con un único propósito; un consumidor por cada proyección o reacción.
- Define acciones compensatorias para cada paso antes de construir una saga.
- Limita los reintentos, redirige los eventos agotados a una dead-letter queue y construye una ruta de replay.
- Propaga un correlation id en cada evento y regístralo en ambos lados.
- Monitorea el consumer lag y la profundidad de la DLQ, y configura alertas basadas en la tendencia.
- Comienza con un modular monolith y añade eventos cuando el desacoplamiento o el replay sean una necesidad real.
Errores comunes
- Llamar a los comandos “eventos” y terminar con una llamada a procedimiento distribuida.
- Confundir event-driven, event sourcing y CQRS, e intentar adoptar los tres a la vez.
- Publicar directamente desde un request handler y enfrentarse al problema de la escritura dual (dual-write problem).
- Asumir una entrega “exactly-once” y realizar cobros duplicados en la primera redelivery.
- Asignar claves a los eventos de forma aleatoria y luego esperar un ordenamiento por entidad.
- Renombrar o eliminar un campo del payload y romper los consumidores de una versión antigua.
- Construir una coreografía sin ninguna forma de visualizar el proceso de extremo a extremo.
- Reintentar un “poison event” infinitamente y bloquear la partición que tiene detrás.
- No revisar nunca la dead-letter queue.
- Añadir un broker para una aplicación CRUD y pagar el impuesto de la complejidad sin necesidad.
Próximos pasos
El transporte subyacente de un sistema orientado a eventos suele ser un log o un broker; por ello, Apache Kafka cubre el modelo de log reproducible y RabbitMQ se encarga de la mensajería basada en enrutamiento. Dado que los eventos son la forma en que se comunican los servicios en un sistema distribuido, la guía de Microservices explica de dónde provienen originalmente los límites de los servicios. Si todo esto parece demasiado complejo para tu problema, la guía de Modular Monolith es el contrapunto honesto: la mayoría de los sistemas deberían mantenerse como una única unidad desplegable hasta que aparezca una razón real para dividirlos.