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.
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.
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:
| Comando | Evento | |
|---|---|---|
| Ejemplo | ReservarStock | PedidoConfirmado |
| Tiempo verbal | Imperativo: algo que se quiere que pase | Pasado: algo que ya ha pasado |
| Receptores | Uno, que quien lo envía conoce | Cero, uno o muchos, que quien lo publica no conoce |
| ¿Se puede rechazar? | Sí: no hay stock | No: ya ha ocurrido; como mucho se reacciona |
| Quién decide | Quien lo envía | Quien 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ón | Qué lleva el evento | Lo que gana | Lo que cuesta |
|---|---|---|---|
| Notificación | Solo qué ha pasado y un identificador: «el pedido 42 se ha confirmado» | Desacopla a quien publica; el evento es pequeño | Quien 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ón | Nadie vuelve a preguntar; cada uno guarda su copia y funciona aunque Pedidos esté caído | Copias de datos por todas partes, eventos grandes y un contrato más difícil de cambiar |
| Event sourcing | Cada cambio, guardado como hecho; el estado se calcula a partir de ellos | Historia completa; se puede reconstruir cualquier momento | Otra forma de pensar los datos, y cambiar eventos antiguos es delicado |
| CQRS | No es un tipo de evento: separa el modelo que escribe del que se lee, y los eventos los conectan | Cada lectura con la forma que le conviene | Dos 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:
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ía | Cómo se consigue | Qué puede pasar |
|---|---|---|
| Como mucho una vez | Enviar y no reintentar | Se pierden mensajes |
| Al menos una vez | Reintentar hasta que llegue la confirmación | Llegan mensajes repetidos |
| Exactamente una vez | Al menos una vez, más un receptor que ignora los repetidos | Nada, 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.
- Al publicar: Pedidos guarda el pedido y después publica
PedidoConfirmado. Si se cae en medio, el pedido existe y nadie se entera. Si lo hace al revés, anuncia un pedido que no existe. - Al recibir: Almacén confirma al broker que ha recibido el mensaje y después reserva el stock. Si se cae en medio, el broker ya no se lo volverá a dar. Si lo hace al revés, reserva y se cae antes de confirmar; el broker se lo vuelve a dar y reserva dos veces.
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:
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 guardado | Qué pasa al volver | |
|---|---|---|---|
| 1 | Antes de guardar en el inbox | Nada | El broker no recibió la confirmación y vuelve a entregar el mensaje |
| 2 | Después de guardar, antes de confirmar | El mensaje, pendiente | El broker lo vuelve a entregar; la clave única del inbox lo reconoce y lo descarta. El original sigue pendiente |
| 3 | A mitad de procesar | El mensaje, pendiente (la transacción no se completó) | El procesador encuentra la fila pendiente y la procesa otra vez, desde el principio |
| 4 | Después de procesar, antes de publicar | El stock reservado y el aviso, pendiente en el outbox | El repetidor encuentra el aviso pendiente y lo publica |
| 5 | Después de publicar, antes de marcarlo | El aviso, todavía pendiente | El 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:
| Nivel | Qué se guarda | Qué sobrevive a una caída | Lo que cuesta |
|---|---|---|---|
| 0. Nada | Solo el estado | El estado. Los avisos se pierden o se duplican | Nada… hasta el primer incidente silencioso |
| 1. Outbox | El estado y lo que hay que publicar, en la misma transacción | Ningún aviso se pierde al salir | Una tabla, un repetidor y algo de retraso al publicar |
| 2. Inbox y outbox | Además, cada mensaje recibido, antes de confirmarlo | Nada se pierde al entrar y los repetidos se descartan | Otra tabla que crece y hay que limpiar |
| 3. Event sourcing | Los eventos son el estado | Todo, con su historia; se puede reconstruir cualquier momento | Otra 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ía: no hay director. Pedidos publica
PedidoConfirmado; Facturación cobra y publicaPagoCobrado; Almacén reacciona a ese y publicaStockReservado; y así hasta el final. Cada uno sabe a qué reacciona, y nadie tiene la foto completa. - Orquestación: un director (el orquestador) envía comandos a cada paso y espera sus respuestas. El flujo está escrito en un sitio, y se puede ver en qué punto está cada pedido.
| Coreografía | Orquestación | |
|---|---|---|
| Acoplamiento | Bajo: cada uno conoce eventos, no servicios | El orquestador conoce a todos |
| ¿Dónde está el flujo? | Repartido; hay que reconstruirlo | En el orquestador |
| Añadir un paso | Fácil si solo reacciona; difícil si cambia el orden | Se cambia en un sitio |
| Encaja con | Flujos cortos y estables, con pocos pasos | Flujos 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:
- Contratos: lo que el servicio promete al resto. Es la única zona que otros equipos leen, y la que más cuesta cambiar.
- Entrada: por donde llega el trabajo, ya sea una petición (un comando por la API) o un evento (un consumidor con su inbox). Traduce lo que llega al lenguaje del núcleo.
- Núcleo: las reglas del negocio. La capa de aplicación decide qué hacer con cada comando o evento (las políticas del tipo «cuando llegue
PagoCobrado, reserva el stock»), y el dominio sabe si se puede hacer y qué ha pasado al hacerlo. - Salida: lo que el servicio deja escrito, el dato y el aviso en la misma transacción, y el repetidor que publica.
- Operación: lo que hace falta para vivir con todo lo anterior en producción: cuántos reintentos y cuándo rendirse, dónde van los mensajes muertos, un identificador de correlación que viaja con cada mensaje para reconstruir la historia de un pedido, y cuánto retraso lleva cada consumidor.
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
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.
- Añadir un campo opcional es seguro si los consumidores ignoran lo que no conocen (el lector tolerante).
- Quitar o renombrar un campo rompe a quien lo usaba. Se hace en dos tiempos: se añade el nuevo, se espera a que nadie lea el viejo y entonces se quita.
- Cambiar el significado de un campo sin cambiar su nombre es el peor caso: no rompe nada visible y lo estropea todo. Mejor un evento nuevo con otra versión.
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
- Publicar después de guardar, sin outbox. Funciona en las pruebas y pierde eventos en producción, sin dejar ningún error que mirar.
- Creer que el broker garantiza «exactamente una vez». En cuanto un mensaje provoca algo fuera del broker, el receptor tiene que ser idempotente.
- Confirmar el mensaje antes de guardarlo. Una caída en ese momento lo pierde, y el broker ya no lo volverá a dar.
- Eventos que son comandos disfrazados.
EnviarCorreoDeConfirmacionpublicado como evento ata a quien publica con quien recibe. Si se espera que alguien concreto haga algo concreto, es un comando. - Publicar los eventos internos. Convierte cada cambio de diseño en una migración para todos los consumidores.
- Coreografía para todo. Con diez pasos encadenados por eventos, nadie sabe en qué estado está un pedido ni quién tiene que compensar qué.
- Event sourcing por defecto. Es una decisión de modelado para donde la historia importa, no la forma normal de guardar datos.
- Olvidar la operación. Sin un identificador de correlación ni métricas de retraso, un sistema de eventos es imposible de depurar.
Dónde se ve en New Capital Bank
Varias series usan estas piezas sobre el banco, con una simulación en cada caso:
- Fraude: ¿esperar o avisar? (Las áreas del banco): llamar o avisar, y comandos frente a eventos.
- El alta de un cliente (Las áreas del banco): coreografía frente a orquestación.
- ¿De quién es el límite? (Las áreas del banco): agregados, y por qué entre agregados se habla con eventos.
- La doble pulsación (El dinero no se duplica): idempotencia.
- El Bizum que se queda a medias (El dinero no se duplica): saga, compensaciones y el paso pivote.
- Guardado, pero no avisado (El dinero no se duplica): outbox, y al menos una vez.
- Atención al cliente lo ve todo (Laura no ve su Bizum): CDC.
- Una ficha para leer (Laura no ve su Bizum): proyecciones y CQRS.
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.