Architecture

Arquitectura Orientada a Eventos

La arquitectura orientada a eventos reemplaza las llamadas directas por hechos. Un servicio registra que algo sucedió y continúa, mientras que otros servicios reaccionan a su propio ritmo. El acoplamiento disminuye, y también lo hace la certeza.

advanced15 min readUpdated 16 sept 2026
events/order-placed.ts
ts
// events/order-placed.ts
export type OrderPlaced = {
  id: string;             // unique event id, used for dedupe
  type: "order.placed";   // past tense: a fact, not a request
  version: 1;             // payload contract version
  occurredAt: string;     // ISO-8601, when it became true
  producer: "orders";
  correlationId: string;  // ties the flow together
  data: {
    orderId: string;
    customerId: string;
    totalCents: number;
  };
};
Entrega
At-least-once
Consistencia
Eventual
Acoplamiento
Temporal y espacial
Patrón núcleo
Transactional outbox
Brokers comunes
Kafka, RabbitMQ, NATS
Sumidero de fallos
Dead-letter queue

Por que importa

Lo que realmente ganas con este patrón

Los productores dejan de esperar

Registrar un evento es una escritura local. El productor no mantiene una conexión abierta mientras un servicio downstream procesa, agota el tiempo de espera o se reinicia, por lo que su latencia deja de depender de terceros.

El fan-out no cuesta nada

Agregar un nuevo lector —búsqueda, analíticas, fraude, un cargador de data warehouse— no cambia al productor ni molesta a los consumidores existentes. Cada uno se suscribe y lee a su propio ritmo.

Cada cambio se convierte en un registro

Debido a que los eventos son hechos duraderos, obtienes un rastro de auditoría, un flujo para analíticas y la capacidad de reconstruir una proyección desde el historial en lugar de adivinar qué sucedió.

La imagen completa

Productor, broker, consumidor

Un productor registra un hecho, un broker lo distribuye y los consumidores reaccionan sin que el productor sepa quién está escuchando o esperando por ellos.

Evento

Describir

Un hecho inmutable en tiempo pasado con un id, un tipo y un payload. Pertenece al servicio que lo produjo y se nombra según lo que sucedió, no según lo que debería suceder.

Broker

Distribuir

Un log o exchange que almacena y enruta eventos a los suscriptores, desacoplando quién produjo un hecho de quién lo consume y absorbiendo picos de tráfico.

Consumidor

Reaccionar

Un servicio que se suscribe a tipos de eventos, actualiza su propio estado en respuesta y confirma la recepción solo después de que su trabajo sea duradero.

HTML5 de un vistazo

El vocabulario que necesitas

Evento

Un hecho en tiempo pasado —order.placed, user.registered— que ya ocurrió.

Topic

Un canal o log nombrado al cual se publican y desde el cual se leen los eventos.

Suscripción

La declaración de un consumidor sobre qué eventos desea y desde dónde reanudar la lectura.

Replay

Volver a leer el historial para reconstruir una proyección o recuperarse de un bug.

Idempotencia

La propiedad que hace que sea seguro ejecutar un consumidor dos veces sobre el mismo evento.

Lag

El retraso entre que un evento es publicado y que un consumidor lo procesa.

Flujo

Cómo se propaga un hecho

El productor confirma su cambio de estado y el evento juntos, y luego se retira. Todo lo posterior es problema del broker y responsabilidad de los consumidores.

  1. 1

    Cambiar estado y registrar el hecho

    El servicio escribe el cambio en su base de datos y el evento en una tabla outbox en la misma transacción, para que el evento exista exactamente cuando el estado lo haga.

  2. 2

    Relevo al broker

    Un proceso de relay o de change-data-capture lee las filas de la outbox no publicadas, las publica en el topic y luego las marca como enviadas.

  3. 3

    El broker hace fan-out

    El broker almacena el evento y entrega una copia a cada grupo de consumidores suscritos, sin que el productor sepa o le importe quiénes son.

  4. 4

    Los consumidores reaccionan independientemente

    Cada consumidor actualiza su propio modelo de lectura, envía una notificación o dispara más trabajo, a su propia velocidad y con su propia política de reintentos.

  5. 5

    Reintento y luego dead-letter

    Un consumidor que falla reintenta con backoff; tras alcanzar el límite de intentos, el evento se mueve a una dead-letter queue donde un humano puede inspeccionarlo y re-ejecutarlo.

La guia completa

Arquitectura Orientada a Eventos: Todo lo que necesitas saber

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 Accepted con status: "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 version permite 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.

En la practica

Outbox, relay, consumidor, versión

Las cuatro piezas que convierten una transacción de base de datos en un evento confiable.

orders/place-order.ts
import { pool } from "../db.js";

export async function placeOrder(input: PlaceOrderInput) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    const { rows } = await client.query(
      `INSERT INTO orders (customer_id, total_cents, status)
       VALUES ($1, $2, 'placed')
       RETURNING id`,
      [input.customerId, input.totalCents],
    );

    const event = {
      id: crypto.randomUUID(),
      type: "order.placed",
      version: 1,
      occurredAt: new Date().toISOString(),
      producer: "orders",
      correlationId: input.correlationId,
      data: {
        orderId: rows[0].id,
        customerId: input.customerId,
        totalCents: input.totalCents,
      },
    };

    // State and event commit together or not at all.
    await client.query(
      `INSERT INTO outbox (id, topic, payload)
       VALUES ($1, $2, $3)`,
      [event.id, "orders", JSON.stringify(event)],
    );

    await client.query("COMMIT");
    return rows[0].id;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Evento vs comando

Un evento declara un hecho y puede tener muchos lectores. Un comando es una instrucción dirigida a un solo manejador y puede ser rechazada. Nombrarlos igual es como los sistemas pierden su sentido.

Evento
// Past tense, already true, owned by the producer.
// Any number of consumers may react.
await bus.publish("order.placed", {
  orderId: order.id,
  occurredAt: new Date().toISOString(),
});
Comando
// Imperative, addressed to one handler,
// and able to fail or be rejected.
await bus.send("reserve-inventory", {
  orderId: order.id,
  quantity: 2,
});

Transactional outbox vs publicación directa

La base de datos y el broker son dos sistemas. Escribir en ambos sin una transacción compartida es el problema de la doble escritura (dual-write): un fallo entre ellos provoca la pérdida de un evento o la creación de uno ficticio.

Outbox
await db.transaction(async (tx) => {
  await tx.insert(orders).values(order);

  await tx.insert(outbox).values({
    id: event.id,
    topic: "orders",
    payload: event,
  });
});
// A relay publishes later, so the fact cannot be lost.
Dual write
await db.insert(orders).values(order);

// If this call fails or the process dies here,
// the row exists but the event never happened.
await bus.publish("order.placed", event);

Compromisos

Cuándo los eventos ayudan y cuándo perjudican

El diseño orientado a eventos cambia la certeza por el desacoplamiento. Es un buen intercambio para algunos problemas y doloroso para otros.

Strengths

  • Desacoplamiento temporal y entre equipos

    El productor no conoce a sus consumidores, y que un consumidor esté caído no hace fallar al productor. Los equipos pueden desplegar independientemente siempre que el contrato del evento se mantenga.

  • Fan-out y replay gratuitos

    Un nuevo lector se suscribe sin tocar al productor, y el historial retenido permite reconstruir una proyección, alimentar un servicio nuevo o responder preguntas que nadie hizo en su momento.

  • Auditoría y analíticas naturales

    Como cada cambio es un hecho duradero, un log de eventos sirve doblemente como rastro de auditoría y flujo de analíticas, sin necesidad de añadir un segundo mecanismo.

Trade-offs

  • El debugging abarca varios servicios

    Una solicitud que antes tocaba un solo stack trace ahora toca cinco logs y un broker. Sin correlation ids y tracing, el flujo de un evento es casi invisible.

  • La consistencia se vuelve eventual

    Un usuario puede ver un pedido antes de que exista su factura. Cada modelo de lectura debe tolerar el lag, y cada UI debe explicarlo honestamente.

  • Las piezas móviles se multiplican

    Brokers, schemas, reintentos, dead-letter queues e idempotencia son ahora tu responsabilidad. Para una app CRUD pequeña, esto es complejidad sin recompensa.

Preguntas frecuentes

Preguntas frecuentes

Keep learning

Related topics from the roadmap.

$ comienza a aprender

Listo para aprender Event-Driven Architecture?

Nuestro tutorial interactivo te guia a traves de Event-Driven Architecture paso a paso — con quizzes y codigo real que puedes ejecutar en el navegador.