Guía · Arquitectura

Arquitectura orientada a eventos

Cuando las partes de un sistema se avisan en lugar de llamarse, cada una puede caerse, tardar o cambiar sin arrastrar a las demás. A cambio, hay que decidir qué lleva cada aviso, cómo viaja y qué pasa si se pierde o llega dos veces. Esta guía cuenta el estilo completo y sus patrones, fuera del caso del banco.

Estilo: arquitectura orientada a eventos (Fowler, Hohpe y Woolf, Richardson)~20 min

Qué es, y por qué no es un marco con autor

A diferencia de los mapas de Wardley o de Team Topologies, la arquitectura orientada a eventos no tiene un libro fundacional. Es un estilo: una forma de conectar las partes de un sistema en la que cada parte cuenta lo que le ha pasado y las demás deciden qué hacer con ello. Alrededor del estilo hay un catálogo de patrones que se han ido nombrando durante veinte años, y que solo se entienden juntos: un outbox sin consumidores idempotentes se queda a medias, y una saga sin reintentos seguros, también.

La guía se ordena sobre el artículo de Martin Fowler What do you mean by «Event-Driven»? (2017), que separó cuatro cosas que se llamaban igual, y sobre los patrones de mensajería de Gregor Hohpe y Bobby Woolf y de Chris Richardson. El ejemplo es una tienda en línea: Pedidos, Facturación, Almacén y Notificaciones.

Llamar o avisar

Cuando un cliente confirma un pedido, hay que facturarlo, reservar el stock y mandarle un correo. La forma más directa es que Pedidos llame a los otros tres y espere sus respuestas. Funciona, pero ata a Pedidos a los tres: si uno está caído o lento, el pedido falla o tarda, aunque al cliente le dé igual cuándo llega el correo. Es el acoplamiento temporal: para que una parte trabaje, las otras tienen que estar disponibles a la vez.

La alternativa es que Pedidos avise: publica el evento PedidoConfirmado en un intermediario (el broker) y sigue con lo suyo. Cada interesado lo recoge cuando puede. Si Notificaciones está caído, el aviso le espera en el broker.

Llamadas: Pedidos espera a los tres Pedidos Facturación Almacén Notificaciones espera las tres respuestas si una no llega, falla caído Eventos: Pedidos avisa y sigue Pedidos BROKER PedidoConfirmado Facturación Almacén Notificaciones no espera a nadie el aviso le espera
El mismo pedido, dos veces. Con llamadas, la caída de Notificaciones tumba el pedido. Con eventos, el pedido sale y el aviso espera en el broker a que Notificaciones vuelva.

Avisar tiene otra ventaja, menos visible: quien publica no sabe quién escucha. Si mañana Analítica quiere contar pedidos, se suscribe a PedidoConfirmado sin que Pedidos cambie una línea. Y otro coste, también menos visible: durante un rato, Pedidos da el pedido por confirmado y Almacén todavía no ha reservado el stock. El sistema es coherente al cabo de un rato (consistencia eventual), y el negocio tiene que aceptarlo.

Avisar no sustituye a pedir. Conviene separar dos tipos de mensaje:

ComandoEvento
EjemploReservarStockPedidoConfirmado
Tiempo verbalImperativo: algo que se quiere que pasePasado: algo que ya ha pasado
ReceptoresUno, que quien lo envía conoceCero, uno o muchos, que quien lo publica no conoce
¿Se puede rechazar?Sí: no hay stockNo: ya ha ocurrido; como mucho se reacciona
Quién decideQuien lo envíaQuien lo recibe

Cuando la respuesta hace falta para seguir (¿hay saldo?, ¿es fraude?), una llamada o un comando con respuesta siguen siendo lo correcto. La arquitectura orientada a eventos no elimina las llamadas: las reserva para cuando esperar es parte del negocio.

Qué lleva un evento

Fowler escribió su artículo después de un taller con colegas de ThoughtWorks en el que vieron que la gente decía «orientado a eventos» para hablar de cuatro cosas distintas. Las cuatro se pueden ordenar por cuánto se apoya el sistema en los eventos:

PatrónQué lleva el eventoLo que ganaLo que cuesta
NotificaciónSolo qué ha pasado y un identificador: «el pedido 42 se ha confirmado»Desacopla a quien publica; el evento es pequeñoQuien lo recibe tiene que volver a preguntar los detalles, y vuelve a depender de Pedidos
Estado en el evento (event-carried state transfer)Todo lo que los demás necesitan: líneas, importes, direcciónNadie vuelve a preguntar; cada uno guarda su copia y funciona aunque Pedidos esté caídoCopias de datos por todas partes, eventos grandes y un contrato más difícil de cambiar
Event sourcingCada cambio, guardado como hecho; el estado se calcula a partir de ellosHistoria completa; se puede reconstruir cualquier momentoOtra forma de pensar los datos, y cambiar eventos antiguos es delicado
CQRSNo es un tipo de evento: separa el modelo que escribe del que se lee, y los eventos los conectanCada lectura con la forma que le convieneDos modelos que mantener, y lecturas con retraso

La notificación y el estado en el evento deciden cómo se hablan las partes. Event sourcing y CQRS deciden cómo guarda y sirve sus datos una parte por dentro, y se cuentan más abajo. Se pueden combinar: un servicio con event sourcing puede publicar hacia fuera simples notificaciones.

Eventos de dominio y eventos de integración. Dentro de un servicio circulan eventos finos, con el vocabulario interno (LineaAñadida, DescuentoAplicado). Hacia fuera se publica otro tipo de evento, más estable y pensado como contrato (PedidoConfirmado, con lo que los demás necesitan). Publicar los internos tal cual convierte cada cambio de diseño en un cambio para todos los consumidores.

Cómo viajan: colas, logs y garantías

Entre quien publica y quien recibe hay un broker, y no todos los brokers guardan los mensajes igual. Hay dos familias:

Cola: lo que se consume se borra consumidor #1 #2 #3 #4 #5 #6 ya borrados Varios consumidores se reparten los mensajes. Nadie puede volver a leer #1, y un consumidor nuevo solo ve lo que llegue a partir de ahora. Log: se guarda todo; cada lector sabe por dónde va 0 1 2 3 4 5 6 7 Facturación Analítica Cada lector guarda su posición (su offset). Uno nuevo puede empezar desde 0, y uno que tenía un error puede volver atrás y releer.
Una cola reparte trabajo y olvida. Un log retiene los mensajes durante un tiempo (o para siempre), y cada lector avanza a su ritmo. RabbitMQ es el ejemplo típico de lo primero; Kafka, de lo segundo.

Las dos sirven para eventos, pero cambian lo que se puede hacer cuando algo va mal. Con un log, un consumidor que ha procesado mal una semana de eventos puede corregir el error y releerlos. Con una cola, esa semana ya no está en el broker: si hace falta, tiene que estar guardada en otro sitio (y esa es una de las razones del inbox, más abajo).

Sea cual sea el broker, entre dos servicios hay una red, y la red falla. Quien envía un mensaje y no recibe la confirmación no sabe si llegó. Puede reintentar o no, y eso da tres garantías de entrega:

GarantíaCómo se consigueQué puede pasar
Como mucho una vezEnviar y no reintentarSe pierden mensajes
Al menos una vezReintentar hasta que llegue la confirmaciónLlegan mensajes repetidos
Exactamente una vezAl menos una vez, más un receptor que ignora los repetidosNada, si el receptor deduplica bien

La última fila es la importante. «Exactamente una vez» no es algo que la red pueda garantizar de punta a punta. Algunos brokers lo ofrecen dentro de sus propios límites, pero en cuanto el mensaje provoca algo fuera del broker (una fila en una base de datos, un correo), lo que existe es al menos una vez con un efecto exactamente una vez. Para eso, el receptor tiene que ser idempotente: procesar el mismo mensaje dos veces tiene que dejar las cosas igual que procesarlo una.

Queda el orden. Si se reparten los mensajes entre varios consumidores para ir más rápido, dos eventos del mismo pedido pueden procesarse al revés: PedidoCancelado antes que PedidoConfirmado. La solución habitual es particionar por clave: todos los eventos del pedido 42 van a la misma partición y los procesa un solo consumidor, en orden. Se pierde paralelismo dentro de un pedido y se mantiene entre pedidos distintos. El orden global casi nunca hace falta; el orden por entidad, casi siempre.

Persistir lo que entra y lo que sale

Hasta aquí, los eventos parecen viajar solos. En la práctica, cada servicio tiene dos puntos frágiles: cuando recibe un mensaje y cuando publica uno. En los dos hay que escribir en dos sitios que no comparten transacción (el broker y la base de datos del servicio), y si el servicio se cae entre una escritura y la otra, queda una hecha y la otra no.

La estrategia que resuelve los dos lados es la misma: que nada importante viva solo en memoria ni solo en el broker. Lo que entra se guarda en una tabla de entrada (inbox) y lo que sale, en una tabla de salida (outbox), las dos en la misma base de datos que los datos del servicio. Así, cada cosa que el servicio tiene que hacer queda apuntada en un sitio que sobrevive a un reinicio.

Outbox: el aviso se guarda con el dato

En lugar de publicar directamente, Pedidos escribe el evento en su tabla outbox dentro de la misma transacción en la que guarda el pedido. Se guardan los dos o ninguno. Después, un proceso aparte (el repetidor, o relay) lee la tabla, publica lo que encuentre pendiente y lo marca como enviado.

Si el repetidor publica y se cae antes de marcar, al volver publicará otra vez. El outbox garantiza al menos una vez, no exactamente una. Por eso necesita a su pareja en el otro lado.

Inbox: el mensaje se guarda antes de confirmarlo

Al recibir un mensaje, Almacén lo guarda en su tabla inbox, con el identificador del mensaje como clave única, y solo entonces confirma al broker. Si el mensaje ya estaba (porque es un repetido), la clave única lo rechaza y se descarta. Después, un proceso lee las filas pendientes y, en una sola transacción, hace tres cosas: cambia el estado (reserva el stock), apunta en el outbox los eventos que haya que publicar y marca el mensaje como procesado.

El nombre «inbox» es de la comunidad. Hohpe y Woolf describieron la idea en 2003 como receptor idempotente (Idempotent Receiver), y Richardson la llama consumidor idempotente. Hay una variante más sencilla, que solo guarda el identificador del mensaje en la misma transacción que el cambio, para deduplicar. La versión completa guarda el mensaje entero, y eso permite algo más: confirmar al broker enseguida, procesar a su ritmo y reprocesar después.

Dónde se puede caer, y qué pasa al volver

Con las dos tablas, el recorrido de un mensaje dentro de un servicio queda así. Los números marcan los momentos en los que el servicio se puede caer:

BROKERentrada BROKERsalida SERVICIO ALMACÉN consumidor procesador repetidor MISMA BASE DE DATOS inboxid · pendiente stockel estado outboxaviso · pendiente una transacción por mensaje: marcar procesado + cambiar el stock + apuntar el aviso guarda y confirma lee pendientes escribe lee pendientes 1 2 3 4 5
Almacén recibe PedidoConfirmado, reserva el stock y publica StockReservado. El consumidor, el procesador y el repetidor solo trabajan contra la base de datos. La transacción del centro es la única que cambia algo, y lo cambia todo a la vez.
Se cae…Qué hay guardadoQué pasa al volver
1Antes de guardar en el inboxNadaEl broker no recibió la confirmación y vuelve a entregar el mensaje
2Después de guardar, antes de confirmarEl mensaje, pendienteEl broker lo vuelve a entregar; la clave única del inbox lo reconoce y lo descarta. El original sigue pendiente
3A mitad de procesarEl mensaje, pendiente (la transacción no se completó)El procesador encuentra la fila pendiente y la procesa otra vez, desde el principio
4Después de procesar, antes de publicarEl stock reservado y el aviso, pendiente en el outboxEl repetidor encuentra el aviso pendiente y lo publica
5Después de publicar, antes de marcarloEl aviso, todavía pendienteEl repetidor lo publica otra vez. Es un repetido, y lo descarta el inbox de quien lo reciba

En ningún caso se pierde nada ni se hace nada dos veces. Lo que lo consigue no es una pieza concreta, sino una regla: cada paso lee de una tabla y escribe en otra, y entre dos pasos solo hay filas guardadas. Un reinicio solo retrasa el trabajo pendiente; no lo pierde. Y como el inbox de un servicio absorbe los repetidos que produce el outbox del anterior, las dos piezas forman una cadena: al menos una vez al salir, exactamente una vez al aplicarse.

Cuánto persistir

No todos los servicios necesitan las dos tablas. Se puede ver como un espectro, y cada escalón añade una garantía y un coste:

NivelQué se guardaQué sobrevive a una caídaLo que cuesta
0. NadaSolo el estadoEl estado. Los avisos se pierden o se duplicanNada… hasta el primer incidente silencioso
1. OutboxEl estado y lo que hay que publicar, en la misma transacciónNingún aviso se pierde al salirUna tabla, un repetidor y algo de retraso al publicar
2. Inbox y outboxAdemás, cada mensaje recibido, antes de confirmarloNada se pierde al entrar y los repetidos se descartanOtra tabla que crece y hay que limpiar
3. Event sourcingLos eventos son el estadoTodo, con su historia; se puede reconstruir cualquier momentoOtra forma de modelar y de consultar los datos

Una regla práctica: el outbox es casi obligatorio en cuanto un servicio guarda datos y publica eventos. El inbox completo compensa cuando procesar un mensaje dos veces tiene consecuencias (dinero, stock, correos al cliente) o cuando hace falta poder reprocesar. Event sourcing es una decisión de modelado, no de fiabilidad: se elige por la historia, no para no perder mensajes.

Reprocesar. Un inbox que no se borra enseguida permite algo que el broker no siempre permite: si un error de código procesó mal los mensajes de ayer, se corrige, se marcan como pendientes y se vuelven a procesar. Con un log como Kafka, el mismo efecto se consigue moviendo la posición del lector hacia atrás.

Una alternativa al repetidor: leer el diario de la base de datos. En lugar de un proceso que consulta la tabla outbox cada poco tiempo, se puede usar change data capture (CDC): una herramienta lee el registro de cambios de la base de datos y publica cada fila nueva del outbox. Se publica antes y no hay que consultar la tabla, a cambio de una pieza más que operar.

Cuando un mensaje no se puede procesar

Persistirlo todo tiene un riesgo: un mensaje que siempre falla (un dato corrupto, un caso que el código no contempla) se reintenta para siempre y, si se procesa en orden, bloquea a todos los que vienen detrás. La salida es limitar los reintentos y, al llegar al límite, apartarlo a una cola de mensajes muertos (dead letter queue) para que alguien lo revise. Con un inbox, basta con marcar la fila con un estado más: «fallido».

Flujos largos: coreografía, orquestación y sagas

Un pedido no termina en un paso: hay que cobrar, reservar stock y preparar el envío. Con eventos, hay dos formas de encadenar los pasos:

CoreografíaOrquestación
AcoplamientoBajo: cada uno conoce eventos, no serviciosEl orquestador conoce a todos
¿Dónde está el flujo?Repartido; hay que reconstruirloEn el orquestador
Añadir un pasoFácil si solo reacciona; difícil si cambia el ordenSe cambia en un sitio
Encaja conFlujos cortos y estables, con pocos pasosFlujos largos, con decisiones y compensaciones

Sea cual sea la forma, un flujo de varios pasos en varios servicios no tiene una transacción que lo cubra. Si Almacén no tiene stock después de que Facturación haya cobrado, no hay un «deshacer» automático. La saga (Garcia-Molina y Salem, 1987) es la respuesta: cada paso es una transacción local, y cada uno tiene una compensación que lo contrarresta (devolver el cobro). Si un paso falla, se ejecutan las compensaciones de los anteriores, en orden inverso.

Una compensación no borra el pasado: el cobro existió y la devolución también queda registrada. Y hay pasos que no se pueden compensar (el envío ya ha salido del almacén). Conviene ordenar la saga para que esos pasos, los pivote, vayan lo más tarde posible. Las sagas se apoyan en todo lo anterior: cada paso se avisa por eventos que tienen que salir (outbox) y se reintenta sin duplicar (inbox o idempotencia).

El estado a partir de los eventos

Lo normal es guardar el estado actual (el stock del producto 7 es 12) y publicar eventos cuando cambia. Event sourcing le da la vuelta: lo que se guarda son los eventos (StockRecibido de 20, StockReservado de 5, StockReservado de 3), y el estado actual se calcula sumándolos. Un contable lo reconocería enseguida: el saldo no se apunta, se calcula a partir de los asientos.

Lo que se gana es la historia: se sabe no solo cuánto stock hay, sino cómo se llegó ahí, y se puede reconstruir el estado de cualquier momento. Lo que cuesta es que consultar se vuelve más difícil (¿qué productos tienen menos de 10 unidades?) y que los eventos guardados son para siempre: si cambia su forma, hay que seguir sabiendo leer las versiones antiguas. Para no recalcular desde el primer evento cada vez, se guardan cada cierto tiempo fotos del estado (snapshots).

De ahí sale casi siempre CQRS: si guardar eventos hace difícil consultar, se construyen aparte modelos de lectura (proyecciones) con la forma que necesita cada pantalla o cada informe, alimentados por los mismos eventos. Se pueden tirar y reconstruir desde el principio. A cambio, van con algo de retraso respecto a lo que se ha escrito.

No hace falta llegar hasta aquí. Event sourcing encaja donde la historia es el negocio (contabilidad, auditoría, logística). En un servicio donde solo importa el estado actual, añade complejidad sin dar nada a cambio. CQRS tampoco necesita event sourcing: una réplica o una tabla de lectura alimentada por eventos normales ya es CQRS.

Anatomía de un servicio

Con todas las piezas anteriores, un servicio orientado a eventos tiene una forma bastante reconocible, sea cual sea el lenguaje en el que esté escrito. Se parte en cinco zonas, y cada una garantiza algo distinto:

CONTRATOSlos eventos que publica y los que consume, con su esquema y su versión ENTRADA API (comandos)consumidoresinbox guarda lo que llega,descarta repetidos,confirma al broker NÚCLEO Aplicación casos de uso y políticas («cuando llegue X, haz Y») Dominio agregados, reglas y eventos de dominio no sabe que existe un broker ni una base de datos SALIDA base de datosoutboxrepetidor guarda el dato y elaviso juntos; publicaal menos una vez OPERACIÓNreintentos · mensajes muertos · id de correlación · reprocesado · retraso de cada consumidor
Las cinco zonas. Las flechas son dependencias y apuntan hacia dentro: la entrada y la salida conocen el núcleo, y el núcleo no conoce a ninguna de las dos. Por eso se puede cambiar de broker o de base de datos sin tocar las reglas del negocio.

El núcleo en el centro, y la entrada y la salida en los bordes, es en el fondo la arquitectura hexagonal (Alistair Cockburn, «puertos y adaptadores»): el núcleo define qué necesita (guardar un pedido, avisar de algo) y los bordes lo resuelven con una tecnología concreta. La aportación de los eventos es que la entrada y la salida ya no son solo «una API» y «una base de datos»: llevan su inbox y su outbox.

Para quien programa, esas zonas se suelen ver así en las carpetas de un proyecto. Si no programas, el diagrama de arriba dice lo mismo:

almacen/
├── contratos/              lo que otros equipos leen
│   ├── publica/            StockReservado (v2), StockAgotado (v1)
│   └── consume/            PedidoConfirmado (v3), PedidoCancelado (v1)
├── dominio/                agregados, reglas, eventos de dominio
├── aplicacion/
│   ├── comandos/           ReservarStock, RecibirMercancia
│   └── politicas/          «cuando llegue PedidoConfirmado, reserva»
├── entrada/
│   ├── api/
│   ├── consumidores/       un consumidor por evento que se escucha
│   └── inbox/              guardar, deduplicar, procesar pendientes
├── salida/
│   ├── base-de-datos/
│   ├── outbox/
│   └── repetidor/          a veces, un proceso aparte
├── migraciones/            las tablas de negocio, inbox y outbox: la misma base de datos
└── operacion/              reintentos, mensajes muertos, alertas de retraso
Un árbol típico, con cada carpeta en el color de su zona. Los nombres cambian de un equipo a otro; lo que se repite es la separación, y que dominio y aplicación no dependan de nada de lo que hay debajo.

Dos detalles que el árbol enseña y que se pasan por alto. Primero, el inbox y el outbox están en las migraciones de la misma base de datos que las tablas de negocio: si vivieran en otra, la transacción del centro dejaría de existir. Segundo, la carpeta de contratos a menudo no vive en el servicio, sino en un repositorio compartido o en un registro de esquemas, porque la leen todos los que publican o consumen esos eventos.

Cómo cambian los eventos

Un evento publicado es un contrato con consumidores que quien lo publica no conoce. Cambiarlo es como cambiar una API pública, con un agravante: los eventos antiguos siguen en los logs, en los inbox y, con event sourcing, en la base de datos para siempre.

Hay dos direcciones de compatibilidad: hacia atrás (un consumidor nuevo sabe leer eventos viejos) y hacia delante (un consumidor viejo sabe leer eventos nuevos). Un registro de esquemas (schema registry) comprueba las dos antes de dejar publicar una versión nueva, y convierte una conversación entre equipos en una regla automática.

Errores frecuentes

Dónde se ve en New Capital Bank

Varias series usan estas piezas sobre el banco, con una simulación en cada caso:

Fuentes. Martin Fowler, «What do you mean by “Event-Driven”?», martinfowler.com, 2017. Gregor Hohpe y Bobby Woolf, Enterprise Integration Patterns (Addison-Wesley, 2003): receptor idempotente, entrega garantizada, canal de mensajes muertos. Chris Richardson, microservices.io: outbox transaccional, consumidor idempotente y saga. Martin Kleppmann, Designing Data-Intensive Applications (O'Reilly, 2017), capítulo 11, sobre logs y colas. Hector Garcia-Molina y Kenneth Salem, «Sagas», 1987. Alistair Cockburn, «Hexagonal Architecture», 2005. El nombre «inbox» y el árbol de carpetas son convenciones de la comunidad, no de una fuente concreta.