Arquitectura orientada a eventos: qué se rompe y cuándo
Arquitectura orientada a eventos en producción: eventos vs comandos, cola vs log, consumidor idempotente y patrón outbox. Qué se rompe y cuándo no usarla.

Imagina el botón "Confirmar pedido" de una tienda online. Detrás, un endpoint hace cinco cosas seguidas: cobra, reserva stock, emite la factura, manda el email y avisa al almacén.
Un viernes, el proveedor de email tarda ocho segundos en responder. El cliente ve un spinner, pulsa otra vez y paga dos veces. Nadie tocó el código de pagos. Lo rompió un email.
Alguien propone pasar a una arquitectura orientada a eventos. Tiene razón a medias. Los eventos resuelven ese acoplamiento, pero traen duplicados, desorden y mensajes que nadie procesa nunca. Si no los ves venir, cambias un fallo ruidoso por uno silencioso.
En corto: la arquitectura orientada a eventos desacopla servicios porque el productor anuncia un hecho (OrderPlaced) y no decide quién reacciona. A cambio, tienes que diseñar para duplicados, orden por clave, mensajes envenenados y escrituras dobles. Úsala cuando varios consumidores independientes reaccionan al mismo hecho; evítala si necesitas respuesta inmediata o no puedes operar la infraestructura.
¿Qué es la arquitectura orientada a eventos?
La arquitectura orientada a eventos (event-driven architecture, EDA) es un estilo en el que los servicios publican hechos que ya ocurrieron y otros servicios, que el emisor no conoce, deciden por su cuenta cómo reaccionar.
No es "usar Kafka" ni "hacer cosas en segundo plano". Es mover la decisión de qué pasa después desde el productor hacia los consumidores.
El problema de llamar servicios en cadena
Encadenar llamadas síncronas suma latencias, multiplica la probabilidad de fallo y obliga a tocar el productor con cada feature nueva.
Volvamos al pedido. Las cifras son un ejemplo ilustrativo inventado, no un benchmark:
| Paso síncrono | Latencia (ejemplo) | Si cae… |
|---|---|---|
| Cobro (payments) | 300 ms | No hay pedido, correcto |
| Reserva de stock (inventory) | 120 ms | Falla el pedido |
| Factura (invoicing) | 450 ms | Falla el pedido ya cobrado |
| Email de confirmación | 800 ms | Falla el pedido por un email |
| Aviso al almacén (warehouse) | 200 ms | Falla el pedido |
| Total | 1.870 ms | Cualquiera tumba todo |
Las latencias se suman: el usuario espera a toda la fila. La disponibilidad se multiplica: cinco servicios al 99 % en cadena dan un 95 % aproximado (0,99 elevado a 5). Has construido algo menos fiable que cualquiera de sus piezas.
Y el problema que más duele: cada feature nueva toca al productor. ¿Marketing quiere puntos de fidelidad? Otro await en confirmOrder. ¿Analítica quiere registrar la conversión? Otro más.
Con eventos, orders guarda el pedido y publica OrderPlaced. Facturación, email y almacén escuchan. Fidelidad se suscribe mañana sin que nadie toque orders.
Eventos vs comandos vs queries
Un evento cuenta algo que ya pasó. Un comando pide que algo pase. Una query pregunta. Confundirlos es una fuente de bugs habitual en una arquitectura orientada a eventos.
| Evento | Comando | Query | |
|---|---|---|---|
| Nombre | En pasado: OrderPlaced |
Imperativo: ReserveStock |
Pregunta: GetOrderStatus |
| Destinatarios | Cero, uno o muchos, desconocidos | Exactamente uno | Exactamente uno |
| ¿Se puede rechazar? | No, ya ocurrió | Sí | Sí (error, no encontrado) |
| ¿Cambia estado? | Describe un cambio hecho | Pide un cambio | No |
| Riesgo típico | Nadie sabe quién reacciona | Si el receptor cae, el emisor espera | Latencia en cadena |
Martin Fowler describe en "What do you mean by Event-Driven?" la trampa del passive-aggressive command: el emisor espera que alguien actúe, pero lo envía como si fuera un evento.
El síntoma está en el nombre: SendConfirmationEmailEvent. Eso no es un hecho, es una orden disfrazada. Si orders necesita que el email salga y lo comprueba, es un comando. El evento honesto es OrderPlaced, y el servicio de email decide que merece un correo.
Asíncrono no es lo mismo que event-driven
Puedes meter una cola entre orders e invoicing, publicar GenerateInvoice y procesarlo en segundo plano. Es asíncrono. No es arquitectura orientada a eventos.
La pregunta práctica: ¿quién decidió la siguiente acción? Si orders decidió "ahora hay que facturar", tienes una cola de comandos, y cada paso nuevo se sigue añadiendo en orders. Si orders solo dijo "se confirmó un pedido" y invoicing decidió facturar, eso sí es event-driven.
Ninguno es mejor por defecto. Cobrar no debería depender de que un oyente desconocido tenga ganas: ahí quieres un comando.
Anatomía de un evento: sobre y payload
Un evento tiene un sobre (envelope) con metadatos y un payload con los datos de negocio. La especificación CloudEvents (versión 1.0.2, la última publicada, de febrero de 2022) exige cuatro atributos: id, source, specversion y type, y obliga a que source + id sea único para cada evento distinto.
Opcionales como time, subject o dataschema completan el resto; en la práctica, añade una extensión de correlación (correlationid; la spec solo admite minúsculas y dígitos en los nombres) para seguir un flujo entre servicios.
Un evento thin lleva solo el orderId y obliga a consultar el resto. Uno fat lleva lo necesario para trabajar sin llamar de vuelta, pero convierte el payload en contrato público. Para ficheros grandes, como el PDF de la factura, usa claim-check: guarda el fichero y manda la referencia. Y nunca metas tokens de acceso en el mensaje: se loguean, se replican y se reprocesan meses después.
Cola vs log vs router: dónde espera el evento
Una cola reparte mensajes entre workers y los borra tras el ack; un log los conserva durante la retención y permite replay y orden por clave; un router los reparte a varias suscripciones sin guardar historia. Esta decisión condiciona más que el framework, y productos como RabbitMQ ofrecen más de un modelo:
| Cola | Log | Router | |
|---|---|---|---|
| Ejemplo | SQS, colas clásicas de RabbitMQ | Kafka, RabbitMQ Streams | SNS, EventBridge |
| Tras procesar | Se borra tras el ack | Se queda hasta que expira la retención | Se entrega y no queda historia |
| Reparto | Competing consumers | Cada consumer group lee todo con su offset | Fan-out a suscripciones |
| Replay | No | Sí, moviendo el offset | Solo con archivo |
| Orden | Limitado (salvo FIFO) | Por partición (misma clave, misma partición) | No garantizado en general |
| Limitación o riesgo | Un consumidor nuevo no ve el pasado | Paralelismo limitado por particiones | Cada suscriptor necesita su cola y DLQ |
La introducción oficial de Kafka lo deja claro: los eventos no se borran tras consumirse, la retención se configura por topic, y los eventos con la misma clave van a la misma partición, que se lee en el orden de escritura.
La contrapartida: dentro de un consumer group cada partición la lee un solo consumidor, así que los consumidores que sobren se quedan ociosos (Kafka: The Definitive Guide, cap. 4).
Y no asumas que un archivo equivale a un log. EventBridge permite archivar y reproducir, pero la documentación de AWS avisa de que el replay no respeta necesariamente el orden de entrada: va por intervalos de un minuto.
Mi opinión: para la tienda, router con una cola por consumidor es el punto de partida más barato. Pasa a un log cuando necesites replay real u orden estricto por pedido.
Lo que se rompe en una arquitectura orientada a eventos
Duplicados: necesitas un consumidor idempotente
La mayoría de brokers de una arquitectura orientada a eventos entregan at-least-once: un mensaje puede llegar dos veces. Si el consumidor procesa, falla antes del ack y el broker reintenta, facturas dos veces.
La solución es registrar el id del evento en la misma transacción que el efecto de negocio:
type OrderPlaced = {
specversion: 39;1.039;;
id: string;
source: string;
type: 39;shop.order.placed39;;
correlationid: string; // extensión CloudEvents: solo minúsculas y dígitos
data: { orderId: string; total: number };
};
// Cliente genérico: adapta a pg, Prisma, Drizzle...
interface Tx {
query(sql: string, params: unknown[]): Promise<{ rowCount: number }>;
}
interface Db {
transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
}
export async function handleOrderPlaced(db: Db, event: OrderPlaced): Promise<void> {
await db.transaction(async (tx) => {
// SQL de Postgres: PRIMARY KEY (consumer, source, event_id)
const inserted = await tx.query(
`INSERT INTO processed_events (consumer, source, event_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[39;invoicing39;, event.source, event.id],
);
if (inserted.rowCount === 0) return; // duplicado: ya está facturado
await tx.query(
`INSERT INTO invoices (order_id, total) VALUES ($1, $2)`,
[event.data.orderId, event.data.total],
);
});
// El ack al broker va después del commit, nunca antes.
}
La clave de deduplicación es source + id, no el correlation ID: un mismo flujo genera muchos eventos con el mismo correlation ID y descartarías todos menos el primero. Y si el efecto no vive en tu base de datos (un email, una API externa), la transacción no te protege: necesitas una clave de idempotencia en el proveedor.
Orden: la clave de partición manda
Si OrderCancelled llega antes que OrderPlaced, el almacén prepara un pedido cancelado. Publica todos los eventos de un pedido con la misma clave (orderId). Ojo si usas colas: SQS estándar no garantiza orden; necesitas una cola FIFO con MessageGroupId = orderId.
Dos trampas: los timestamps de máquinas distintas no son fiables para ordenar, porque los relojes derivan. Y mandar un mensaje fallido a una cola de reintentos aparte rompe el orden, porque los siguientes eventos de esa clave pasan por delante.
Poison messages: una DLQ que alguien vigila
Un payload que tu código no sabe parsear fallará siempre. Pon un máximo de reintentos y manda lo que sobre a una dead letter queue. Pero una DLQ sin alertas es un cementerio.
En el hilo "Ask HN: Has anyone fully embraced an event-driven architecture?", de agosto de 2021, un usuario con SNS + SQS describe su rutina: alertas cuando la DLQ crece o los mensajes llevan demasiado tiempo esperando, reenvío manual a la cola normal e investigación si vuelven a caer.
Trazabilidad: sin correlation ID no hay debugging
Con eventos no tienes un stack trace, tienes cinco servicios que reaccionaron en momentos distintos. En el mismo hilo, otro comentario lo resume: "invest as much as possible in a good logging solution, traceability, and just general things to make debugging easier. Come up with a way to replay events easily."
Propaga el correlation ID del primer evento a todos los que derivan de él y loguéalo siempre.
Patrón outbox: el problema de la escritura doble
El patrón outbox (transactional outbox) consiste en guardar el evento en una tabla outbox dentro de la misma transacción que el cambio de negocio, para que un proceso aparte lo publique en el broker después: o se guardan los dos, o ninguno.
Guardas el pedido y luego publicas OrderPlaced. Si el proceso muere en medio, tienes un pedido sin evento. Es el dual-write.
El transactional outbox inserta el evento en una tabla outbox en la misma transacción que el pedido. Un relay la lee, por polling o con CDC (Debezium, por ejemplo), y publica.
Ojo: el relay también puede publicar dos veces si cae antes de marcar el mensaje como enviado, así que el consumidor idempotente sigue siendo obligatorio.
Lo detallo en cómo sincronizar el read model en CQRS; si CQRS te queda lejos, empieza por el patrón CQRS.
Evolución de schema sin romper consumidores
En una arquitectura orientada a eventos, el payload es una API pública. Añadir un campo opcional con valor por defecto es seguro; renombrar, borrar un campo obligatorio o cambiar el tipo suele romper a alguien. Los consumidores deben ser tolerant readers que ignoran campos desconocidos.
Backward compatibility: un consumidor nuevo lee eventos viejos. Forward: un consumidor viejo lee eventos nuevos. Para replay de meses atrás necesitas compatibilidad hacia atrás transitiva: el schema nuevo tiene que leer todas las versiones anteriores, no solo la última.
Un schema registry valida estructura, no significado: si total pasa de "con IVA" a "sin IVA" sin cambiar de tipo, no se entera. Quien llevó el equipo de schemas de Kafka en Uber lo dejó claro en HN: "We just did not accept breaking schema changes within the topic. Same as you would expect from any other public-facing API." Y si no queda otra: "If you absolutely have to break the schema, make a new topic with a new consumer and phase out the old."
Por eso el contrato del evento (nombre, campos, semántica, versionado) se escribe antes que el productor. Es la lógica del libro de Spec-Driven Development: si el contrato no está escrito, cada consumidor inventa su interpretación.
Cuándo NO usar una arquitectura orientada a eventos
Los eventos no son una mejora gratuita sobre una llamada HTTP.
Cuando necesitas respuesta inmediata. El cobro tiene que decir "pagado" o "rechazado" ahora. Eso es una llamada síncrona, con su clave de idempotencia para que un doble clic no cobre dos veces, no un evento que alguien procesará cuando pueda.
Cuando necesitas una transacción conjunta. Si cobrar y reservar stock deben ocurrir juntos o no ocurrir, los eventos te empujan a sagas y compensaciones. Muchas veces bastaba una transacción en un monolito.
Cuando el equipo no puede operar la infraestructura. Colas, DLQ, alertas, lag, replay: alguien tiene que vigilarlo a las tres de la mañana. En el hilo de HN, un comentario lo plantea en clave de microservicios: "Monolith is easier to handle. With microservices, any network connection could break…"
Cuando solo hay un consumidor. Si OrderPlaced solo lo escucha facturación y nunca habrá otro, probablemente te basta un comando en una cola.
Si te faltan conceptos de base como consistencia eventual o idempotencia, repasa los términos de system design.
Por dónde empezar mañana
Abre tu endpoint más largo y lista cada await posterior al guardado. Para cada uno: ¿tiene que terminar antes de responder al usuario? Los que respondan "no" y tengan más de un interesado son candidatos a evento. Empieza por uno: tu primera arquitectura orientada a eventos cabe en un solo consumidor idempotente con DLQ y alerta desde el primer día.
Si construyes el producto entero con agentes, especificar contratos antes de pedir código es la misma disciplina que trabajamos en Construye con IA, de la idea al producto con Claude Code.
Preguntas frecuentes
¿Cuál es la diferencia entre un evento y un comando?
Un evento describe algo que ya ocurrió, se nombra en pasado y puede tener muchos oyentes que el emisor no conoce. Un comando pide una acción a un destinatario concreto, que puede rechazarla. Si el emisor necesita que algo ocurra y lo comprueba, es un comando aunque lo llames evento.
¿Qué es un consumidor idempotente?
Es un consumidor que produce el mismo resultado aunque reciba el mismo mensaje varias veces. Se implementa guardando el identificador único del evento (source más id) en la misma transacción que el efecto de negocio y descartando los que ya existen.
¿Cola o log: cuál elijo para eventos?
Una cola reparte trabajo entre workers y borra el mensaje tras procesarlo. Un log como Kafka conserva los eventos durante la retención, deja que varios grupos lean lo mismo, permite replay y ordena por clave. Elige log si necesitas replay u orden por entidad; cola si solo necesitas repartir carga.
¿Para qué sirve el patrón outbox?
Evita el dual-write: guardar en la base de datos y publicar en el broker no es atómico. El outbox inserta el evento en una tabla dentro de la misma transacción y un relay lo publica después. El relay puede duplicar, así que los consumidores deben ser idempotentes.
¿Asíncrono significa event-driven?
No. Encolar un comando para procesarlo en segundo plano es asíncrono, pero el productor sigue decidiendo qué pasa después. Es event-driven cuando el productor solo anuncia un hecho y los consumidores deciden cómo reaccionar.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.