Event-Driven Architecture: De los Fundamentos a la Implementación Real
Una guía completa para entender y aplicar la arquitectura orientada a eventos en sistemas distribuidos, desde los conceptos básicos hasta patrones avanzados como Sagas y compensaciones.
1. El Problema que EDA Viene a Resolver
Hay un momento en la vida de todo sistema que funciona. Empieza pequeño, con unas cuantas tablas en la base de datos y endpoints que hacen exactamente lo que dicen. Todo tiene sentido. El código es legible. Los deploys no dan miedo.
Luego el sistema crece.
De repente, lo que era “crear un carrito de compras” ya no es insertar un registro. Es obtener un formulario de VTEX, resolver la dirección del usuario, actualizar el shipping, sincronizar el perfil del cliente, actualizar el token de segmentación en el sistema de autenticación y persistir todo en MongoDB. Y todo eso tiene que pasar cuando el usuario a penas entra en la app.
La respuesta instintiva —la que casi todos tomamos la primera vez— es llamar a cada cosa en secuencia:
Funciona en local. Funciona en staging. Y cuando llega el primer pico de tráfico real, empieza a fallar de formas que antes no imaginabas:
- El microservicio de autenticación está lento porque alguien deployó un cambio. El usuario espera… aunque VTEX ya creó el carrito hace cuatro segundos.
POST /clientProfileDataen VTEX falla en el paso 9. Los pasos 1 al 8 ya ejecutaron. El carrito existe en VTEX pero sin perfil sincronizado. ¿Quién lo arregla? ¿El usuario? ¿Un job nocturno?- Necesitas agregar analytics al flujo. Agregas una llamada más. El tiempo de respuesta sube otro poco. No el suficiente para notarlo en desarrollo, sí el suficiente para notarlo cuando hay 1500 usuarios comprando al mismo tiempo.
- El equipo de
core-authquiere cambiar su API de sesiones. Para no romper el carrito, tienes que coordinar el deploy con otro equipo, en otro repo, en otro sprint.
Esto tiene nombre: monolito distribuido. No es un insulto, es un diagnóstico. Tomaste la fragilidad del acoplamiento de un monolito y le agregaste la latencia de red de los microservicios. Lo peor de los dos mundos.
EDA —Event-Driven Architecture— existe específicamente para romper esto. No es una moda, no es la arquitectura de moda de esta temporada. Es la respuesta a un patrón de fallo que aparece, sin falta, cuando los sistemas crecen lo suficiente.
La idea central es un cambio de mentalidad que parece sutil pero lo cambia todo:
En lugar de que el servicio A le ordene a los demás qué hacer, simplemente anuncia lo que ocurrió y cada quien reacciona de forma independiente.
Cart no llama a core-auth para actualizar el token. Cart publica un evento que dice “el shipping de este carrito fue actualizado” y se desentiende. Si core-auth quiere hacer algo con eso, es su problema. Si core-auth está caído, Cart no se entera y no le importa.
Las consecuencias de ese cambio son el tema de este artículo.
2. Evento vs. Mensaje: la Distinción que Más Importa
Antes de hablar de patrones y brokers, hay que fijar algo que parece semántico pero tiene consecuencias de diseño reales. En los equipos de ingeniería, “evento” y “mensaje” a menudo se usan como si fueran la misma cosa. No lo son. Confundirlos produce sistemas que tienen toda la complejidad operacional de EDA pero ninguno de sus beneficios de desacoplamiento.
Piénsalo con dos imágenes.
La emisora de radio. Cuando una emisora transmite música, no sabe quién está escuchando. No le importa. Puede haber diez oyentes o diez mil. La emisora transmite y los radioescuchas reaccionan de forma completamente independiente: uno baila, otro cocina, otro apaga la radio. La emisora no espera que nadie le responda. Si nadie sintoniza en ese momento, el sonido se va al aire y punto.
Eso es un evento.
El teléfono. Cuando llamas a alguien, sabes exactamente a quién estás llamando. Esperas que contesten. La conversación es un intercambio entre dos partes. Si la persona no contesta, el flujo falla. Cuando cuelgas, la conexión cierra.
Eso es un mensaje.
| Evento (la emisora) | Mensaje (el teléfono) | |
|---|---|---|
| Semántica | ”Esto ocurrió” — hecho en pasado | ”Haz esto” — comando o consulta |
| ¿Sabe el emisor quién escucha? | No, y no le importa | Sí, siempre |
| ¿Cuántos receptores? | Todos los suscritos al canal | Exactamente uno |
| ¿Espera respuesta? | No | Generalmente sí |
| ¿Quién define el contrato? | El emisor es dueño del schema | El receptor define la interfaz |
| ¿Qué pasa si nadie escucha? | Nada — el evento ocurrió de todas formas | El flujo falla |
CartCreated, CartShippingUpdated, AddressSellerResolved son eventos: el servicio anuncia que algo pasó y no le importa quién reacciona ni cuántos lo hacen.
La llamada gRPC CustomersService.GetAddresses(ctx, request) es un mensaje: sabes exactamente a quién le hablas, esperas una respuesta y si falla, tu operación falla también.
La importancia práctica de esta distinción es enorme. Si diseñas algo que dice ser un evento pero el emisor necesita saber quién reacciona, o necesita una respuesta directa, en realidad es un mensaje disfrazado con ropa de evento. Y eso significa que el acoplamiento que querías eliminar sigue ahí, solo que más escondido.
La prueba de fuego para saber si algo es un evento: ¿el servicio que lo publica tiene necesidades de saber que existe un servicio que lo consume? Si la respuesta es no, es un evento. Si la respuesta es sí, probablemente es un mensaje.
3. Los Bloques de Construcción
Ahora que tenemos el vocabulario base, las piezas que conforman cualquier sistema event-driven. Son pocas, pero entenderlas bien evita muchas confusiones cuando uno empieza a leer código o documentación ténica.
Producer / Publisher
El servicio que publica el evento. Es el dueño del schema: define qué campos tiene CartCreated, con qué tipos, con qué versión. Cuando el schema necesita cambiar, el producer decide cómo hacerlo sin romper a los consumers existentes.
Una regla que en WL se aplica de forma consistente: el core es siempre el dueño de los eventos, nunca los adapters. El handler gRPC no publica eventos. El consumer no publica eventos. El caso de uso en el core es quien decide cuándo y qué publicar. Los adapters son solo canales de entrada y salida.
Consumer / Subscriber
El servicio que se suscribe a un topic y procesa los eventos que llegan. La belleza del modelo: UpdateShipping y UpdateClientProfile pueden escuchar el mismo CartCreated sin saber el uno del otro. Mañana puedes agregar un consumer de analytics que también escuche CartCreated sin tocar ni un archivo de los consumers existentes.
Event Broker
El intermediario. Recibe el evento del producer, lo persiste y lo entrega a todos los consumers suscritos. Es la pieza que hace posible que producer y consumer nunca se hablen directamente. Sin el broker, el desacoplamiento no existe. En WL: NATS JetStream. En otros stacks: Kafka, RabbitMQ, AWS SQS/SNS. La tecnología varía, el concepto no.
Durable Consumer
Un consumer con memoria. Recuerda exactamente hasta qué mensaje procesó, incluso si el servicio se reinicia o cae. Sin esto, cada vez que tu servicio levanta después de un crash, no sabe qué eventos se perdió mientras estuvo caído — y simplemente los ignora.
El durable consumer convierte “tal vez procese los eventos” en “siempre los procesará, aunque tarde un poco más”. En NATS JetStream, el durable consumer tiene un nombre único que lo identifica dentro del stream. Ese nombre es lo que NATS usa para recordar la posición.
El detalle que más confunde al principio: cada consumer recibe su propia copia independiente del evento. El broker no elige uno. Todos los consumers suscritos reciben el mismo mensaje y cada uno lo procesa a su ritmo, con su propia política de reintentos, sin afectar a los demás. Esto es posible porque cada consumer tiene su propio durable consumer name y NATS lleva la cuenta de la posición de cada uno de forma independiente.
4. Coreografía vs. Orquestación: Dos Filosofías de Coordinación
Cuando múltiples servicios necesitan coordinarse en un proceso complejo, hay una decisión fundamental que tomar antes de escribir una línea de código. Y la diferencia entre las dos opciones no es solo técnica — es una diferencia de filosofía sobre quién conoce qué.
4.1 Orquestación: el Director de Orquesta
Imagina una orquesta sinfónica. Hay un director al frente que tiene la partitura completa. Sabe exactamente qué instrumento entra cuándo, a qué tempo y con qué dinámica. Cuando el director baja la batuta hacia los violines, los violines tocan. Cuando señala al oboe, toca el oboe. Nadie improvisa. La visibilidad es total y el control es centralizado.
Eso es orquestación:
Lo bueno: si algo falla, sabes exactamente en qué paso y por qué. El orquestador tiene visibilidad completa del estado del flujo en todo momento. El debugging es lineal. El monitoreo es simple.
Lo que cuesta: ese orquestador central se convierte en el punto que une todo. Si cambia, hay que coordinar con todos los servicios. Si cae, cae el flujo entero. El orquestador conoce a todos sus participantes — si agregas un nuevo servicio al flujo, tienes que modificar el orquestador.
4.2 Coreografía: el Ensemble de Jazz
Ahora imagina un grupo de jazz. No hay director. Cada músico escucha lo que hacen los demás y reacciona. El pianista termina su frase, el baterista recoge el ritmo, el saxofón improvisa encima. El flujo emerge de la interacción entre los participantes, no de las instrucciones de alguien al centro. Puedes agregar un nuevo músico al grupo y los demás simplemente lo incorporan a la conversación.
Eso es coreografía:
Ningún servicio le “llama” a otro. Cada uno publica lo que ocurrió y los demás reaccionan. Cart no sabe que existe core-auth. core-auth no sabe que existe Cart. Cada servicio puede desplegarse, escalar y fallar de forma completamente independiente.
Lo bueno: desacoplamiento máximo. Los servicios son genuinamente autónomos. Agregar un nuevo consumer a CartShippingUpdated mañana no requiere tocar ninguno de los servicios existentes.
Lo que cuesta: el flujo completo es “invisible”. No hay un solo lugar donde ver todos los pasos de punta a punta. Cuando algo sale mal, reconstruir qué pasó requiere correlacionar logs y trazas de múltiples servicios. La complejidad no desaparece, se mueve — de las cadenas de llamadas al sistema de observabilidad.
4.3 La Regla de Decisión: ¿Cuándo Uso Eventos y Cuándo Uso gRPC?
Esta es la pregunta que más genera confusión en equipos que están migrando a EDA. La respuesta no es “siempre eventos” ni “siempre gRPC”. Es usar el modelo correcto según la naturaleza de la operación.
La regla:
- Si la operación es una escritura que dispara consecuencias en otros dominios → eventos.
- Si la operación es una lectura que necesita agregar datos en tiempo real para construir una respuesta → llamada síncrona.
Veamos el mismo escenario modelado de las dos formas para entender la diferencia concreta.
Forma incorrecta — el usuario actualiza su dirección y Customers llama síncronamente a los otros dominios:
Consecuencia: si Cart está caído, la actualización de dirección falla. El usuario no puede cambiar su dirección porque el microservicio de carrito tiene un problema. Customers depende operacionalmente de Cart para hacer su propio trabajo. Eso no son microservicios independientes — es un monolito distribuido.
Forma correcta — Customers publica un evento y cada dominio reacciona:
Consecuencia: si Cart está caído, Customers igual responde al usuario. Cart procesa el evento cuando vuelve a estar disponible. CoreAuth actualiza el token cuando Cart termina su trabajo. Cada servicio falla de forma independiente sin arrastrar a los demás.
Para casos de lectura, la llamada síncrona sigue siendo la correcta. Cuando el consumer de UpdateShipping necesita la dirección actualizada del usuario para enviarla a VTEX, llama a CustomersService.GetAddressByID(ctx, addressId) por gRPC. No tiene sentido modelar esa lectura como un evento — el consumer necesita los datos ahora mismo para hacer su trabajo, y si Customers no responde, el consumer debe fallar y reintentar.
La pregunta calibradora que funciona en todos los casos: ¿el proceso que estoy implementando necesita una respuesta inmediata de otro servicio para poder trabajar? Si la respuesta es no, es un evento. Si la respuesta es sí necesita la respuesta para continuar, es una llamada síncrona.
Los sistemas maduros usan ambos: coreografía entre dominios (para que cada equipo pueda moverse a su ritmo sin depender de los demás) y llamadas síncronas dentro de un dominio o para lecturas en tiempo real (cuando la respuesta es necesaria para continuar el trabajo).
5. Cómo se Estructura un Evento en WL: CloudEvents y Convenciones
Hasta aquí hemos hablado de eventos en abstracto. Es hora de aterrizar: ¿cómo se ve un evento en el código real de la plataforma WL? ¿Qué estándar se usa? ¿Dónde viven los structs? ¿Cómo se nombran los subjects? ¿Qué va en el payload?
5.1 El Estándar CloudEvents
WL usa el estándar CloudEvents para todos los eventos. CloudEvents es una especificación abierta que define un formato común para describir eventos, independientemente del sistema que los produce o consume. La motivación: si cada microservicio inventa su propio formato, integrar sistemas se vuelve un trabajo de traducción constante.
Un CloudEvent tiene atributos obligatorios:
| Atributo | Qué es | Ejemplo |
|---|---|---|
id | Identificador único del evento | "evt-a3f9c2" |
type | Tipo del evento — el subject | "cart.shipping_updated" |
source | Microservicio que lo emitió | "dc-wl-groceries-core-cart" |
specversion | Versión de la especificación | "1.0" |
data | El payload del evento serializado en JSON | { "cartId": "...", ... } |
La buena noticia: el shared package gestiona la construcción del CloudEvent. El developer define el type (subject), el source y el data (el struct del payload). El resto lo hace la infraestructura.
5.2 Naming: Cómo se Llaman los Eventos
La convención de naming en WL sigue el formato {domain}.{action} en tiempo pasado, desde la perspectiva del dominio que emite el evento:
cart.created → el dominio cart creó un carrito
cart.shipping_updated → el dominio cart actualizó el shipping
customer.address.seller_resolved → el dominio customer.address resolvió el seller
¿Por qué en pasado? Porque un evento describe algo que ya ocurrió, no una orden para que alguien haga algo. Si el nombre de tu evento implica que otro servicio debe hacer algo (“cart.update_auth_token”), en realidad estás modelando un comando, no un evento. El nombre correcto sería “cart.shipping_updated” — describe el hecho, no la consecuencia.
¿Por qué el prefijo del tenant? En NATS JetStream, el subject real de un evento incluye el tenant como prefijo: cl-jumbo.cart.created, ar-jumbo.cart.created. Esto garantiza aislamiento total entre marcas que comparten la misma infraestructura de NATS. Un evento de un carrito chileno no llega al consumer de un carrito argentino.
El shared package aplica el prefijo automáticamente al publicar y suscribirse. El developer trabaja solo con cart.created — no tiene que preocuparse por el prefijo.
5.3 La Carpeta event/: Dónde Vive el Contrato
Cada microservicio tiene una carpeta event/ en su capa core. Esa carpeta es la fuente de verdad del contrato que el dominio expone al mundo. Centraliza todo lo relacionado con los eventos que emite: constantes del canal, subjects y structs del payload.
// event/cart.go — Dominio: cart
// Identidad del dominio como emisor
const CART_SOURCE = "dc-wl-groceries-core-cart"
const CART_CHANNEL = "cart"
// Subject del evento: domain.action en pasado
const CartCreatedEvent = "cart.created"
const CartShippingUpdatedEvent = "cart.shipping_updated"
// Payload de CartCreated
// Regla: incluir solo los identificadores que los consumers necesitan
// para buscar el estado fresco cuando lo requieran
type CartCreated struct {
CartID string `json:"cartId"`
OrderFormID string `json:"orderFormId"`
Username string `json:"username"`
}
// Payload de CartShippingUpdated
type CartShippingUpdated struct {
CartID string `json:"cartId"`
Username string `json:"username"`
SellerName string `json:"sellerName"`
StoreName string `json:"storeName"`
StoreID string `json:"storeId"`
}
5.4 El Principio del Payload Mínimo
Esta es una de las decisiones de diseño más contraintuitivas al principio, y también una de las más importantes.
La tentación: incluir el estado completo del recurso en el payload del evento. “Si incluyo todo el carrito en CartCreated, el consumer no tiene que ir a buscarlo — es más eficiente.”
El problema: el evento es un snapshot del momento exacto en que ocurrió. Si el consumer tarda unos segundos en procesarlo y mientras tanto otro proceso modificó el carrito, el consumer está trabajando con datos obsoletos sin saberlo.
La regla: incluye en el payload los identificadores necesarios para que el consumer pueda encontrar el estado actual cuando lo necesite. No incluyas el estado en sí — ese es el trabajo del consumer cuando lo procese.
CartCreated lleva cartId, orderFormId y username. Con esos tres datos, cada consumer puede hacer exactamente lo que necesita: UpdateShipping busca la dirección del usuario en Customers, llama a VTEX con el orderFormId. No necesitaba el estado completo del carrito.
6. El Shared Package: lo que la Infraestructura Hace por Ti
Antes de escribir tu primer consumer, hay algo importante que entender: no tienes que implementar la mecánica de NATS desde cero. En WL existe dc-wl-groceries-shared-pkg v2, una librería interna que abstrae toda la infraestructura de mensajería.
Saber qué hace el shared package y qué debes hacer tú evita mucho tiempo buscando por qué algo no funciona.
6.1 División de Responsabilidades
| El shared package gestiona | Tú gestionas |
|---|---|
| Crear el stream y el durable consumer en NATS | El nombre del durable consumer (debe ser único y consistente) |
| Pool de workers concurrentes por consumer | La lógica del handler — qué hacer con el evento |
ACK cuando el handler retorna nil | Idempotencia del handler |
| NAK + delay cuando el handler retorna error | Cuándo señalizar el INBOX y con qué payload |
| Propagación del contexto OTel entre producer y consumer | Los flags de fallo en MongoDB al agotar MaxDeliver |
| Deserialización del CloudEvent | La política de MaxDeliver y backoff (configurable por consumer) |
| Prefijo del tenant en subjects | Lógica de compensación cuando el procesamiento falla definitivamente |
6.2 Estructura de un Producer
El producer es la abstracción más simple. Su única responsabilidad es construir el CloudEvent y enviarlo al broker:
// data/producer/cart_created_producer.go
type CartCreatedProducer struct {
producer *nats.NatsProducer
}
func (p *CartCreatedProducer) Publish(
ctx context.Context,
ev *event.CartCreated,
) error {
cEvent := ce.NewEvent()
cEvent.SetID(uuid.New().String())
cEvent.SetType(event.CartCreatedEvent)
cEvent.SetSource(event.CART_SOURCE)
if err := cEvent.SetData(ce.ApplicationJSON, ev); err != nil {
return err
}
// El NatsProducer inyecta el contexto de tracing en los headers
// del mensaje antes de publicar — la traza continúa del lado del consumer
return p.producer.Publish(ctx, &cEvent)
}
Sin lógica de negocio. Sin validaciones. Solo empaqueta y envía. El core decide cuándo llamar a este producer.
6.3 Estructura de un Consumer
El consumer tiene dos métodos: Subscribe inicializa la suscripción al arrancar el servicio, y Handler implementa la lógica de negocio:
// consumer/cart_created_consumer.go
func (c *CartCreatedConsumer) Subscribe(ctx context.Context) error {
_, err := nats.NewNatsConsumer(nats.NatsConsumerParams{
Ctx: ctx,
JS: c.js,
// El nombre es el identificador del durable consumer en NATS.
// NATS usa este nombre para recordar hasta qué mensaje procesó.
// Si dos instancias del servicio tienen el mismo nombre,
// NATS distribuye los mensajes entre ellas (competing consumers).
Name: "dc-wl-groceries-core-cart-update-shipping-consumer",
Tenant: c.tenant,
Channel: event.CART_CHANNEL,
Event: event.CartCreatedEvent,
Handler: func(ctx context.Context, ev ce.Event) error {
var e event.CartCreated
if err := ev.DataAs(&e); err != nil {
return err
}
return c.Handler(ctx, &e)
},
})
return err
}
func (c *CartCreatedConsumer) Handler(
ctx context.Context,
ev *event.CartCreated,
) error {
// Solo lógica de negocio.
// Si retornas nil → el shared package hace ACK.
// Si retornas error → el shared package hace NAK + delay (reintento).
return c.core.UpdateShipping(ctx, ev.CartID, ev.OrderFormID)
}
El nombre del durable consumer es crítico. Es el campo más importante que configuras. Si cambias el nombre en un deploy, NATS crea un consumer nuevo desde el principio del stream — todos los mensajes pendientes se procesan de nuevo. Si dos consumers distintos tienen el mismo nombre, NATS los trata como uno — uno de los dos nunca recibe mensajes. Elige el nombre una vez, con la convención {proyecto}-{microservicio}-{dominio}-{acción}-consumer, y no lo cambies.
6.4 Competing Consumers: Escalabilidad Automática
Si levantas dos instancias del mismo servicio con el mismo consumer name, NATS distribuye automáticamente los mensajes entre ellas. Instancia A procesa el mensaje 1, instancia B procesa el mensaje 2, instancia A procesa el mensaje 3, y así. Sin configuración adicional.
Esta es la escalabilidad horizontal de los consumers: más carga, más instancias. NATS lo maneja.
Lo que no escala así son los consumers con nombres distintos sobre el mismo evento. UpdateShipping y UpdateClientProfile tienen nombres distintos porque son dos consumers independientes — ambos deben recibir cada CartCreated. NATS entrega una copia a cada consumer name.
7. Patrones Fundamentales
Con los fundamentos claros, los patrones. Cada uno existe porque resuelve un problema concreto. Entender el problema es más valioso que memorizar el nombre.
7.1 Saga: Transacciones Distribuidas sin Llorar
Hay una garantía que el mundo de los microservicios te quita sin aviso: la transacción atómica.
En un monolito con una sola base de datos, si algo falla a mitad de un proceso complejo, haces rollback y el sistema vuelve al estado anterior. Todo o nada. Esa garantía existe porque una sola transacción de base de datos lo protege todo.
En un sistema distribuido con múltiples servicios y múltiples bases de datos, esa garantía no existe. Si el shipping se actualiza en VTEX pero el write en MongoDB falla, ¿qué estado es el correcto? ¿El de VTEX o el de MongoDB?
La Saga es la respuesta a ese problema. La idea: dividir el proceso en una secuencia de transacciones locales, cada una con su operación compensatoria correspondiente. Si un paso falla, se ejecutan las compensaciones de los pasos anteriores para restaurar la consistencia.
Hay dos implementaciones de Saga:
Saga con coreografía: cada servicio publica eventos de éxito o fallo, y otros servicios reaccionan con las compensaciones correspondientes. No hay coordinador central.
Saga con orquestador: un componente central rastrea en qué paso está la saga y decide qué compensaciones ejecutar. Más visible, más acoplado.
Los dos tipos de compensación
Aquí hay un matiz importante que los libros suelen omitir: no toda compensación deshace el trabajo previo en tiempo real.
Compensación inmediata: si el paso 3 falla, el paso 2 se revierte ahora mismo. Necesaria cuando el estado inconsistente tiene consecuencias graves inmediatas (cobro sin fulfillment, stock reservado sin orden).
Compensación diferida: en lugar de deshacer, se escribe un flag de fallo en la base de datos propia y un proceso posterior lo detecta y reintenta. El sistema opera en un estado temporalmente inconsistente que no afecta al usuario en el ciclo de vida inmediato del recurso.
¿Cuándo usar cada una? Si el estado inconsistente es visible para el usuario o tiene consecuencias económicas inmediatas → compensación inmediata. Si el sistema puede operar sin esa consistencia por un tiempo razonable y existe un mecanismo de detección → compensación diferida.
En WL: La saga de
CreateCartusa compensación diferida. SiUpdateShippingfalla, el carrito existe en MongoDB y VTEX pero sin shipping sincronizado. El flagshippingSync.failedregistra el fallo. Cuando el usuario intente pasar al checkout,ValidateCartdetecta el flag y reintenta. El usuario ve un carrito válido — quizás sin dirección de envío resuelta temporalmente, pero funcional. Es menos elegante que una compensación inmediata, y mucho más pragmático dadas las condiciones del sistema.
7.2 Tipos de Consumer: Fire-and-Forget vs. INBOX-Signaling
Antes de hablar del patrón INBOX, necesitamos entender una distinción que determina el diseño de todos los consumers: no todos los consumers son iguales en su relación con el cliente.
En cualquier flujo event-driven coexisten dos tipos:
Fire-and-Forget
- Trabaja en background. Nadie espera su resultado para responder al cliente.
- Puede tener múltiples reintentos porque el tiempo no es un recurso escaso para él.
- Si falla definitivamente, escribe un flag de fallo y el sistema continúa sin interrumpir la experiencia del usuario.
- Ejemplo:
UpdateClientProfile. Sincroniza el perfil del cliente en VTEX. Si falla, el carrito sigue siendo válido.
INBOX-Signaling
- Está en el critical path de la respuesta HTTP. El endpoint espera su señal antes de responder al cliente.
- Tiene reintentos mínimos o ninguno — cada reintento suma al tiempo de espera del cliente.
- Si falla, señaliza el error inmediatamente para que el endpoint retorne una respuesta controlada.
- Ejemplo:
UpdateShipping. Si el carrito no tiene shipping resuelto cuando el cliente lo recibe, la experiencia está rota.
La implicación de diseño es directa: el tipo del consumer determina su política de reintentos. Antes de implementar un consumer, la primera pregunta es: ¿este consumer está en el critical path de la respuesta? Si la respuesta es sí, diseña para velocidad y fallo rápido. Si la respuesta es no, diseña para resiliencia con reintentos progresivos.
7.3 Request-Reply sobre Eventos: el Patrón INBOX
Este es el patrón que más confusión genera al migrar a EDA, porque resuelve una tensión que los diagramas de arquitectura no muestran: el mundo externo sigue esperando respuestas síncronas.
El problema concreto: el endpoint POST /v1/carts históricamente devuelve el carrito completo con el shipping ya resuelto. El cliente espera ese contrato. Pero la resolución del shipping ahora es asíncrona — ocurre en un consumer de NATS que corre después de que el caso de uso CreateCart terminó. Si el endpoint llama a GetCart inmediatamente después de publicar el evento, el carrito tiene el campo shipping vacío con alta probabilidad — el consumer aún no terminó.
La solución naive es esperar un tiempo fijo (time.Sleep) y asumir que el consumer ya terminó. Es frágil: demasiado corto y falla, demasiado largo y degrada la latencia. Lo que se necesita es saber exactamente cuándo el consumer terminó.
La solución: el canal de respuesta temporal
NATS tiene un mecanismo nativo llamado inbox subject: un subject temporal y único generado por el cliente, pensado para implementar request-reply. La capa de servicio lo usa como canal de respuesta correlacionado con ese request específico.
Desde afuera: una llamada REST síncrona normal. Por dentro: una coreografía de eventos con un canal de respuesta correlacionado.
Los cuatro principios del patrón INBOX
El inbox es efímero y único por request. No es un topic compartido ni persistente. Es un canal que vive solo durante esa petición. Cuando el endpoint responde (por éxito o por timeout), la suscripción se cancela.
El inbox subject viaja en el evento. El core lo incluye en el payload de CartCreated como metadata. El consumer sabe dónde reportar sin conocer nada sobre la API que lo disparó.
Solo el consumer INBOX-signaling señaliza. UpdateClientProfile no señaliza porque es fire-and-forget. Si ambos señalizaran, habría una race condition sobre cuál llega primero y el primer resultado ignoraría al segundo. Un solo consumer señaliza, el de mayor impacto en el estado que el cliente necesita.
El timeout es el contrato degradado. El inboxTTL define cuánto tiempo el endpoint espera antes de retornar error. Si el consumer no responde en ese tiempo, el cliente recibe un error 504. El carrito ya existe en MongoDB — el cliente puede reintentar la lectura con GetCart y encontrarlo allí. El timeout no implica que la saga falló; implica que tardó más de lo que el cliente podía esperar.
En WL: El
inboxTTLse configura entre 10 y 15 segundos — suficiente para cubrir el peor caso deUpdateShipping(llamada a Customers + POST a VTEX + UpdateOne en MongoDB), con margen para degradación. El valor se ajusta según los SLOs observados en producción.
7.4 CQRS: Comandos y Consultas No son lo Mismo
CQRS tiene un nombre intimidante para una idea de sentido común: las operaciones que modifican estado no deberían también leer y devolver ese estado.
Piénsalo con un ejemplo cotidiano. Cuando le pides a alguien que archive un documento, no esperas que te lo entregue ya archivado para que lo leas ahí mismo. Archiva el documento. Si después necesitas leerlo, vas al archivo. Son dos operaciones con responsabilidades distintas.
Sin CQRS, las operaciones de escritura tienden a hacerlo todo:
Los comandos (CreateCart, UpdateShipping, CancelOrder) modifican estado y devuelven solo error — éxito o fallo, nada más.
Las consultas (GetCart, GetOrder) leen estado y devuelven el modelo completo. No modifican nada.
La capa de servicio es quien encadena: ejecuta el comando, luego llama a la consulta, luego construye la response. El core no sabe nada del contrato de la API.
La Tensión con Contratos Heredados
En sistemas en transición, hay una variante frecuente: el contrato del endpoint exige devolver datos después de una escritura (POST /v1/carts devuelve el carrito completo), pero queremos mantener CQRS limpio en el core. La solución es delegar esa responsabilidad a la capa de servicio:
[Handler gRPC]
① core.CreateCart(ctx, params) → error (solo escribe)
② core.GetCart(ctx, cartId) → Cart (solo lee)
③ CreateCartResponse{cart: Cart} → al cliente (construye la response)
El core cumple CQRS. El contrato externo queda satisfecho. La tensión está documentada como deuda técnica — eventualmente, CreateCart debería devolver solo el ID del carrito creado y el cliente haría un GET separado.
En WL: Esta es exactamente la situación del carrito. El TDD de
CreateCartdocumenta esta tensión en la sección de decisiones (D-5) como deuda técnica aceptada, pendiente de coordinación con BFF y mobile para eliminarla sin romper el contrato existente.
7.5 Transactional Outbox: el Problema del Doble Compromiso
Hay un bug silencioso que acecha en todos los sistemas que combinan una base de datos con un broker de eventos. Se llama dual-write y es difícil de ver hasta que ya causó daño.
El escenario: tu servicio crea un carrito en MongoDB y luego publica CartCreated en NATS. Dos operaciones. Dos sistemas distintos. Sin coordinación entre ellos.
¿Qué pasa si el InsertOne en MongoDB tiene éxito pero el Publish en NATS falla? El carrito existe. Pero CartCreated nunca llega a los consumers. UpdateShipping nunca se ejecuta. El shipping del carrito queda vacío para siempre — sin flag, sin error visible, sin ningún mecanismo de recuperación. El sistema quedó inconsistente en silencio.
El Transactional Outbox resuelve esto aprovechando algo que ya tienes: las transacciones de tu propia base de datos.
La clave: escribir en la tabla de negocio (carts) y en la tabla outbox dentro de la misma transacción atómica. Si una falla, fallan las dos. Si ambas tienen éxito, el relay publica el evento al broker de forma eventual con reintentos hasta recibir ACK.
El Best-Effort Publish como Decisión Consciente
El Outbox no es la única opción válida. En algunos contextos, el riesgo del dual-write puede aceptarse conscientemente cuando:
- Existe un mecanismo de recuperación diferida (flags +
ValidateCart). - El cliente puede reintentar y reconstruir el estado.
- Los efectos huérfanos en sistemas externos son autogestionados (VTEX limpia orderForms inactivos).
La diferencia entre una decisión consciente y deuda técnica silenciosa está en una línea: ¿está documentado en el TDD? Si el riesgo está documentado y existe un mecanismo de detección, es una decisión de ingeniería. Si no está documentado, es deuda técnica que aparece como incidente en producción.
En WL: El TDD de
CreateCartdocumenta el riesgo de publicación fallida como R-3 y lo acepta explícitamente. La justificación: la compensación diferida a través deValidateCartcubre el caso de fallo de consumer, y los orderForms huérfanos en VTEX tienen ciclo de vida automático. Para un dominio donde perder el evento tiene consecuencias irreversibles (pagos, por ejemplo), el Outbox completo sería el camino correcto.
7.6 Dead Letter Queue: el Cajón de los Mensajes que Nadie Pudo Procesar
Imagina que el correo postal recibe una carta con una dirección incorrecta. ¿Qué hace? No la tira. No la reintenta indefinidamente. La manda a un cajón de “correo no entregable” donde alguien puede revisarla, corregir la dirección y reenviarla.
La Dead Letter Queue (DLQ) es exactamente eso para los mensajes que tu consumer no pudo procesar.
El problema: si un consumer falla en un mensaje y lo rechaza repetidamente, tienes dos opciones igual de malas — descartarlo (pierdes el evento para siempre) o seguir reintentando indefinidamente (bloqueas el procesamiento de todos los mensajes que vienen detrás).
La DLQ desbloquea la queue principal y preserva el mensaje fallido para análisis y reprocesamiento posterior.
La DLQ como Flag en la Base de Datos
En WL, la DLQ no siempre es una queue separada en NATS. Para los consumers del carrito, la “DLQ” funcional es un flag en MongoDB:
- Cuando
UpdateClientProfileagota sus 4 reintentos: escribeclientProfileSync.failed = trueen el documento del carrito y hace ACK — NATS no reintenta más, la queue principal avanza. - Cuando
UpdateShippingfalla: escribeshippingSync.failed = truey señaliza el INBOX con error.
El “consumer del DLQ” en este diseño es ValidateCart — el caso de uso que detecta esos flags cuando el usuario abre su carrito y reintenta la operación fallida.
Este patrón es funcionalmente equivalente a una DLQ clásica: el mensaje fallido tiene un destino explícito y visible, la queue principal sigue procesando y existe un mecanismo de reprocesamiento. La diferencia está en la implementación — el storage de la DLQ es MongoDB y el trigger del reprocesamiento es lazy (próxima lectura) en lugar de proactivo.
La regla que no puede romperse: los mensajes fallidos deben tener un destino explícito y visible. Un mensaje que desaparece en silencio —sin flag, sin log, sin ningún rastro— es la fuente más difícil de debuggear en sistemas distribuidos. Si tu consumer agota MaxDeliver y no escribe nada en ningún lugar, tienes un bug de diseño.
En WL: La política de MaxDeliver de los consumers del carrito es 4 intentos con backoff
[1s, 2s, 4s]. El callbackOnMaxDeliverescribe el flag correspondiente y hace ACK. El shared package gestiona el ciclo de vida del mensaje; el developer define qué escribe en el callback.
8. Idempotencia en la Práctica: Diseñar para Duplicados
Idempotencia es una palabra que suena a matemática abstracta pero tiene una consecuencia muy concreta en sistemas event-driven. Aquí va la definición que importa:
Una operación es idempotente si ejecutarla N veces produce el mismo resultado que ejecutarla una vez.
¿Por qué importa tanto? Porque en sistemas con garantía at-least-once, los consumers pueden recibir el mismo evento más de una vez. Esto no es un bug del broker — es el comportamiento correcto. Ocurre cuando el consumer procesa el evento exitosamente pero crashea antes de hacer ACK. El broker, sin confirmación, reentrega el mensaje.
Si tu consumer no es idempotente, tienes un bug latente que se activa bajo condiciones de fallo.
8.1 La Pregunta que Todo Handler Debe Responder
Antes de escribir el handler de cualquier consumer, hazte esta pregunta:
“Si este mensaje llega dos veces con los mismos datos, ¿el sistema queda en el mismo estado que si hubiera llegado una vez?”
Si la respuesta es “sí” sin condiciones adicionales, el handler es idempotente. Si la respuesta es “depende” o “no”, hay que ajustar el diseño.
8.2 Operaciones Naturalmente Idempotentes
UpdateOne con $set en MongoDB: actualizar un campo al mismo valor N veces produce el mismo documento. Si UpdateShipping recibe CartCreated dos veces para el mismo carrito y ejecuta $set { shipping: {...}, sellerName: "..." }, el segundo write produce exactamente el mismo estado que el primero.
// Idempotente: el resultado es el mismo si corre 1 o N veces
filter := bson.D{{Key: "providerCartId", Value: orderFormId}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "shipping", Value: shippingData},
{Key: "sellerName", Value: sellerName},
{Key: "shippingSync", Value: bson.D{{Key: "failed", Value: false}}},
}}}
collection.UpdateOne(ctx, filter, update)
POST /shippingData a VTEX: aplicar el mismo shippingData al mismo orderForm dos veces produce el mismo estado en VTEX. La operación es un set, no un append.
InsertOne con clave única + ignorar duplicate key error: si el evento intenta crear un recurso que ya existe, el segundo intento falla con un error de clave duplicada. Si capturas ese error y devuelves nil (tratándolo como “ya estaba creado”), la operación es idempotente.
8.3 Operaciones NO Idempotentes
// NO idempotente: incrementa el contador en cada ejecución
collection.UpdateOne(ctx, filter, bson.D{{Key: "$inc", Value: bson.D{
{Key: "totalQuantity", Value: 1},
}}})
// NO idempotente: agrega a la lista en cada ejecución
collection.UpdateOne(ctx, filter, bson.D{{Key: "$push", Value: bson.D{
{Key: "lineItems", Value: newItem},
}}})
Para operaciones de acumulación, la idempotencia requiere trabajo extra.
8.4 Estrategias para Operaciones No Idempotentes
Estrategia 1 — Tabla de eventos procesados. Antes de procesar, verificar si el ID del CloudEvent ya fue registrado. Si está, ACK sin procesar. Si no, procesar y registrar el ID.
Estrategia 2 — Clave única natural del negocio. En lugar del ID del evento, usar una clave única del dominio como guardia. Por ejemplo, al agregar un ítem a una lista, la clave (listId, sku) con restricción de unicidad garantiza que el mismo ítem no se agrega dos veces aunque el evento llegue dos veces.
Estrategia 3 — Estado condicional. Antes de ejecutar la operación, verificar si el estado actual ya refleja el cambio. Si el carrito ya tiene shippingSync.failed = false y el shipping correcto, el segundo procesamiento del mismo evento no necesita hacer nada.
La regla práctica de WL: para la mayoría de los consumers del carrito, el diseño con $set / upsert es suficiente. La tabla de eventos procesados se justifica cuando la operación tiene efectos secundarios en sistemas externos que no pueden rehacerse (envío de notificaciones push, llamadas a APIs de pago). Para cada nuevo consumer, la decisión debe documentarse.
9. Observabilidad en Sistemas Event-Driven: Cómo Investigar cuando No Hay Stack Trace
En un sistema síncrono, cuando algo falla, tienes un stack trace. Una línea continua de llamadas que lleva desde el endpoint hasta la función que lanzó el error. Es incómodo pero manejable.
En un sistema event-driven, el stack trace no cruza el boundary asíncrono. El producer termina su trabajo, el consumer empieza el suyo en un proceso separado, potencialmente en otro momento. Sin instrumentación explícita, esos dos tramos son mundos separados en las herramientas de observabilidad.
9.1 La Solución: Propagación de Contexto con OpenTelemetry
El shared package resuelve esto usando OpenTelemetry. El NatsProducer inyecta el contexto de tracing activo en los headers del mensaje antes de publicarlo. El NatsConsumer extrae ese contexto de los headers al recibir el mensaje e inicia un nuevo span vinculado al span original.
El resultado es una traza continua que atraviesa el boundary asíncrono:
Traza completa del flujo CreateCart
│
├─ [service.CreateCart] span
│ ├─ [core.CreateCart] span
│ │ ├─ [VTEX GET /orderForm] span
│ │ ├─ [MongoDB InsertOne] span
│ │ └─ [NATS publish CartCreated] span ─────────────────┐
│ │ mismo trace ID
└─ (la traza continúa en el consumer) │
│
├─ [consumer.UpdateShipping] span ←───────────────────────────┘
│ ├─ [Customers GetAddresses gRPC] span
│ ├─ [VTEX POST /shippingData] span
│ ├─ [MongoDB UpdateOne] span
│ └─ [NATS publish CartShippingUpdated] span
En Jaeger o Grafana Tempo, puedes buscar por el trace ID y ver el flujo completo de punta a punta, incluyendo todos los spans del consumer, aunque hayan ocurrido en procesos y momentos distintos.
Los developers que implementan producers y consumers no necesitan instrumentar nada manualmente. El tracing está integrado en el shared package y se propaga de forma transparente.
9.2 El CorrelationId como Ancla de Investigación
Además del trace ID, necesitas un identificador de dominio que te permita filtrar todo lo relacionado con un recurso específico a través de logs, trazas y base de datos.
En WL, el correlationId natural es el identificador del recurso central del flujo:
cartIdpara el flujo del carrito.addressIdpara el flujo de actualización de dirección.
Con el cartId de un carrito con problemas, puedes:
- Consultar el documento en MongoDB y ver los flags de estado.
- Filtrar trazas en la herramienta de observabilidad por
cartId. - Correlacionar logs de Cart, Customers, VTEX y core-auth.
9.3 Guía de Investigación Práctica
Cuando un carrito llega al cliente sin shipping resuelto:
9.4 El Lag del Consumer como Señal de Alerta
NATS JetStream expone el estado de cada consumer, incluyendo NumPending — cuántos mensajes están pendientes de procesar. Un lag creciente puede indicar:
- Servicio caído: el consumer no está procesando porque el servicio está down.
- Bug en el handler: todos los mensajes fallan, se reintenta con backoff. El lag crece a la velocidad de llegada menos la velocidad de reintento.
- Pico de tráfico: el consumer procesa más lento de lo que llegan mensajes. Solución: más instancias del servicio (competing consumers).
Cada caso tiene una resolución distinta. El lag es la señal de que algo vale la pena investigar. Sin monitoreo del lag, los incidentes se descubren cuando el usuario se queja.
10. Garantías de Entrega: la Promesa que el Broker le Hace a Tu Consumer
Las garantías de entrega definen qué ocurre con los eventos cuando algo falla entre el producer y el consumer. Son tres, y cada una representa un trade-off entre confiabilidad y costo operacional.
| Garantía | Qué significa en la práctica | Costo | Cuándo |
|---|---|---|---|
| At-most-once | 0 o 1 entregas. Si algo falla, el mensaje se pierde. | Mínimo | Métricas best-effort, indicadores en vivo sin valor crítico |
| At-least-once | 1 o más entregas. Nunca se pierde, pero puede duplicarse. | Medio | El default para sistemas de negocio. Requiere consumers idempotentes |
| Exactly-once | Exactamente 1 entrega. Sin pérdidas ni duplicados. | Alto | Transacciones financieras. Difícil de implementar correctamente |
NATS JetStream con AckExplicit + MaxDeliver implementa at-least-once. El broker entrega el mensaje. Si el consumer no hace ACK dentro del tiempo configurado, el broker lo reentrega. Esto garantiza que ningún mensaje se pierde — pero puede significar que el consumer recibe el mismo mensaje más de una vez.
¿Cuándo ocurre la reentrega? En el escenario más común: el consumer procesa el mensaje exitosamente y actualiza MongoDB y VTEX, pero crashea antes de emitir el ACK. El broker, sin confirmación, reentrega el mensaje. El consumer lo procesa de nuevo sobre el mismo carrito que ya fue actualizado.
At-Least-Once + Idempotencia = Funcionalmente Exactly-Once
Esta es la conclusión que el sistema de garantías no dice explícitamente pero que determina cómo diseñar los consumers:
Si tu consumer es idempotente, no importa cuántas veces NATS entregue el mismo mensaje. El resultado es indistinguible de exactly-once, sin el costo operacional de implementarlo.
Exactly-once real tiene sentido cuando la operación tiene efectos secundarios irreversibles y no idempotentes: cobrar una tarjeta de crédito, enviar un email de confirmación único al usuario. Para esos casos existen estrategias específicas (tabla de eventos procesados para deduplicación, transacciones en el sistema externo). Se evalúan caso a caso, no como regla general.
11. Lo que Ganas y lo que Pagas
EDA no es gratis. Hay un principio que conviene tatuar antes de adoptar esta arquitectura:
La complejidad no desaparece cuando adoptas EDA. Se mueve.
En un sistema síncrono, la complejidad está en las cadenas de llamadas, los errores en cascada y el acoplamiento entre servicios. En un sistema event-driven, está en la idempotencia, la observabilidad, los contratos de eventos y la gestión de mensajes fallidos.
No es un intercambio malo. Es un intercambio consciente. La clave es saber de antemano qué estás comprando y a qué precio.
| Lo que ganas | Lo que pagas |
|---|---|
| Servicios genuinamente desacoplados | El flujo completo es “invisible” desde un solo lugar |
| Cada paso puede reintentarse de forma independiente | Los consumers deben ser idempotentes — los duplicados son normales |
| Trabajo en paralelo sin costo para el cliente | Consistencia eventual: el estado puede tardar en consolidarse |
| Un consumer caído no derriba el flujo entero | Debugging distribuido: no existe stack trace entre servicios |
| Los servicios evolucionan y se despliegan a su ritmo | Cambios de schema en eventos deben ser retrocompatibles |
| Agregar consumers sin tocar código existente | Overhead operacional: broker, streams, consumers, DLQs, relay |
| Fachada síncrona sobre coreografía interna (INBOX) | El inboxTTL como contrato degradado debe calibrarse |
El patrón INBOX que usa WL es un buen ejemplo de este trade-off aplicado: acepta toda la complejidad operacional de la coreografía hacia adentro, pero mantiene la interfaz síncrona que los clientes esperan hacia afuera. No es la solución “más pura” de EDA. Es la más honesta con la realidad de los contratos existentes.
12. Checklist: Preguntas para Diseñar o Revisar un Flujo EDA
Cuando diseñes un nuevo flujo o revises el de alguien más, este checklist ayuda a detectar los problemas más comunes antes de que lleguen a producción. No es una lista de reglas rígidas — es un conjunto de tensiones que conviene hacer explícitas temprano.
Sobre el Diseño del Evento
- ¿Es realmente un evento o es un mensaje disfrazado? Si el producer necesita saber quién reacciona, o si el nombre implica que alguien debe hacer algo, es un mensaje.
- ¿El nombre del evento está en pasado y describe un hecho del dominio?
cart.shipping_updated✓ —cart.update_auth_token✗ - ¿El subject sigue el formato
{domain}.{action}? Y en NATS JetStream, ¿el NatsProducer aplica el prefijo del tenant automáticamente? - ¿El struct del evento vive en la carpeta
event/del dominio emisor? ¿Es la fuente de verdad del contrato? - ¿El payload contiene solo identificadores? Si contiene el estado completo del recurso, puede volverse obsoleto antes de que el consumer lo procese.
Sobre el Consumer
- ¿El consumer es fire-and-forget o INBOX-signaling? Esta decisión determina la política de reintentos.
- ¿La política de reintentos refleja el tipo del consumer? INBOX-signaling: sin reintentos o mínimos. Fire-and-forget: MaxDeliver con backoff exponencial.
- ¿El consumer es idempotente? Si recibe el mismo evento dos veces, ¿el estado final es el mismo?
- ¿El durable consumer name es único y consistente? ¿Cambió entre versiones sin razón? (Si cambió, NATS empieza desde el principio del stream)
- ¿Qué escribe el consumer cuando agota MaxDeliver? Si la respuesta es “nada”, hay un problema de diseño.
Sobre la Persistencia y la Consistencia
- ¿Hay dual-write sin outbox? Un INSERT seguido de un Publish sin transacción atómica es un riesgo que existe aunque no se haya manifestado.
- ¿La base de datos propia se escribe antes de publicar el evento? Persiste primero, publica después. Si la publicación falla, el recurso existe y puede recuperarse.
- ¿El tipo de compensación está documentado? ¿Inmediata o diferida? ¿Cuál es el mecanismo de detección y recuperación?
Sobre el Acoplamiento
- ¿Las operaciones de escritura en el core devuelven datos? Si sí, es una violación de CQRS — ¿está justificada por un contrato heredado y documentada?
- ¿Hay acoplamiento síncrono nuevo entre dominios? Un consumer que llama síncronamente al endpoint de otro dominio es el antipatrón que EDA existe para eliminar.
- ¿El core es quien decide cuándo y qué publicar? Los adapters (handlers gRPC, consumers) no publican eventos — delegan al core.
Sobre la Observabilidad (específico WL)
- ¿Cuál es el correlationId del flujo? ¿Está presente en los logs, en las trazas y en el documento de MongoDB?
- ¿El consumer propaga el contexto de tracing hacia sus llamadas salientes? El shared package lo hace si el contexto viene de los headers del mensaje.
- ¿Hay monitoreo del lag del durable consumer? Sin esto, los incidentes se descubren cuando el usuario se queja.