Comunicación orientada a eventos
Cómo los microservicios de White Label se comunican a través de eventos: el modelo de mensajería, producers, consumers, observabilidad distribuida y la transición hacia un sistema desacoplado.
Por qué event driven
El modelo de comunicación más natural cuando se construyen microservicios es la llamada síncrona: un servicio llama a otro por gRPC o HTTP, espera la respuesta y continúa. Es simple de razonar y fácil de implementar. También crea un problema que crece con el sistema.
Cuando el servicio A llama al B, y el B llama al C, los tres quedan acoplados en tiempo de ejecución. Si el C falla, el B falla, y el A falla. Si se necesita desplegar el C, hay que asegurarse de que el B sigue siendo compatible. Si el B cambia su API, el A se rompe. Lo que empezó como tres microservicios independientes se comporta como un monolito distribuido.
La transición hacia comunicación orientada a eventos busca romper ese acoplamiento. En lugar de que el servicio A le diga al servicio B “haz esto ahora”, el servicio A publica un evento que dice “esto ocurrió”. El servicio B, si le interesa, lo consume y reacciona. A no sabe si B existe, B no sabe si A lo está mirando. Cada uno puede desplegarse, escalar y fallar de forma independiente.
En White Label esta transición está en curso. No todo el sistema es event driven hoy, pero es la dirección hacia la que nos movemos y el modelo que aplicamos en los diseños nuevos.
El modelo de eventos
NATS JetStream
La infraestructura de mensajería es NATS con JetStream habilitado. JetStream agrega persistencia y garantías de entrega sobre el modelo base de NATS: los mensajes se almacenan en streams y los consumers pueden consumirlos de forma durable, lo que significa que si un consumer cae y se recupera, retoma desde donde quedó.
CloudEvents
Todos los eventos siguen el estándar CloudEvents. CloudEvents define un formato común para describir eventos independientemente del sistema que los produce o consume. Cada evento tiene un conjunto de atributos obligatorios:
id— identificador único del evento.type— tipo del evento, por convención en formato<dominio>.<entidad>.<acción>. Ej:customer.address.seller_resolved.source— origen del evento. Identifica el microservicio o dominio que lo produjo.specversion— versión de la especificación CloudEvents.
El payload del evento va en el campo data serializado como JSON.
Channels y subjects
Los eventos se organizan en canales. Un canal agrupa todos los eventos que emite un dominio. Dentro del canal, cada tipo de evento tiene su propio subject.
El tenant prefija tanto el canal como el subject, lo que garantiza aislamiento total entre marcas en la misma infraestructura de NATS:
// Canal del dominio customer address para el tenant co-jumbo
co-jumbo.customer.address
// Subject de un evento específico
co-jumbo.customer.address.seller_resolved
La carpeta event/
Cada dominio tiene una carpeta event/ que centraliza todo lo
relacionado con los eventos que emite: las constantes del canal
y los tipos de eventos, y los structs que definen el payload
de cada uno.
Esta carpeta pertenece conceptualmente al core del dominio pero vive separada para que sea fácil responder dos preguntas en cualquier momento: qué eventos produce este dominio y qué forma tiene cada uno.
Ejemplo event
// Constantes del canal y source del dominio
const CUSTOMER_SOURCE = "dc-wl-groceries-core-customers"
const CUSTOMER_ADDRESS_CHANNEL = "customer.address"
// Subject con el que se publicará el evento)
// Debe ser domain.action
// En este caso DOMAIN = "customer.address"
const AddressSellerResolvedEvent = "customer.address.seller_resolved"
/// Definición de de la data del evento (serialización en json)
type AddressSellerResolved struct {
ID string `json:"id"`
AddressID string `json:"addressId"`
Username string `json:"username"`
SellerName string `json:"sellerName"`
StoreName string `json:"storeName"`
StoreID string `json:"storeId"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
PostalCode string `json:"postalCode"`
CartID string `json:"cartId"`
}
Eventos, no comandos
Un evento describe algo que ya ocurrió, no una orden para que
otro servicio haga algo. customer.address.seller_resolved dice
“el seller de esta dirección fue resuelto”. No dice “actualiza
el carrito” ni “sincroniza los tokens”.
Esta distinción es lo que produce el desacoplamiento real. Si el microservicio de customers publicara un comando dirigido al microservicio de cart, ambos quedarían acoplados aunque usen NATS como canal de comunicación. El mecanismo asíncrono no garantiza el desacoplamiento, lo garantiza el diseño del evento.
El microservicio que publica un evento no sabe quién lo va a consumir ni cuántos consumidores existen. Se limita a notificar lo que ocurrió. Cada consumidor decide qué hacer con esa información de forma completamente independiente. Esa es la fuente del desacoplamiento.
Al nombrar un evento, el criterio es siempre el mismo: describir la acción completada en pasado desde la perspectiva del dominio que la realizó. Si el nombre del evento implica que otro servicio debe hacer algo, es una señal de que se está modelando un comando, no un evento.
La regla fundamental
Los microservicios no se llaman entre sí por gRPC para coordinar acciones como consecuencia de un flujo de negocio. La comunicación entre microservicios ocurre a través de eventos.
Cuando un dominio completa una acción que otros dominios o microservicios pueden necesitar conocer, publica un evento. Los interesados lo consumen y reaccionan de forma independiente. Ninguno de los dos sabe del otro a nivel de código.
Esta regla aplica también dentro de un mismo microservicio cuando hay más de un dominio: los dominios no se llaman entre sí directamente, se comunican por eventos.
Producers
Un producer es la abstracción que usa el core para publicar un
evento. Su responsabilidad es mínima: recibir el evento del
dominio, construir el CloudEvent con los atributos correctos y
delegarle la publicación al NatsProducer del shared package.
Sin lógica, sin validación, sin decisiones de negocio.
El core decide cuándo publicar y qué publicar. El producer solo sabe cómo enviarlo.
Estructura
Cada producer vive en la capa data del dominio que emite el
evento, dentro de la carpeta producer/. Tiene un único método
Publish que recibe el evento del dominio como struct tipado.
Ejemplo producer
type AddressSellerResolvedProducer struct {
producer *nats.NatsProducer
}
func (p *AddressSellerResolvedProducer) Publish(
ctx context.Context,
ev *event.AddressSellerResolved,
) error {
cEvent := ce.NewEvent()
cEvent.SetID(ev.ID)
cEvent.SetType(event.AddressSellerResolvedEvent)
cEvent.SetSource(event.CUSTOMER_SOURCE)
if err := cEvent.SetData(ce.ApplicationJSON, ev); err != nil {
return err
}
return p.producer.Publish(ctx, &cEvent)
}
El shared package
El NatsProducer del shared package
(dc-wl-groceries-shared-pkg) es quien maneja la comunicación
real con NATS JetStream. Al inicializarse, crea el stream si no
existe, configurando el canal con el prefijo del tenant y el
periodo de retención de mensajes. Al publicar, serializa el
CloudEvent a JSON, construye los headers necesarios e inyecta
el contexto de tracing para que la traza continúe del lado del
consumer.
Los producers del dominio no instancian NatsProducer
directamente: lo reciben como dependencia a través de FX.
NatsProducer por canal
Cada dominio inicializa un NatsProducer por canal, no por
evento. El canal agrupa todos los eventos que emite ese dominio
y se configura una sola vez con su nombre, descripción y periodo
de retención. Todos los producers de eventos de ese dominio
reutilizan el mismo NatsProducer.
La inicialización del NatsProducer vive en data/producer/
junto a los producers de eventos. Es responsabilidad del módulo
de data proveerlo al grafo de FX para que los producers de
eventos puedan recibirlo como dependencia.
Cuando un dominio tiene muchos producers, es válido extraerlos
a su propio producer.Module para no saturar el data.Module
y mantener el código organizado. La decisión queda a criterio
del desarrollador según el tamaño del dominio.
Ejemplo inicialización NatsProducer por canal
func NewCustomerAddressProducer(
tenant config.TenantValue,
js jetstream.JetStream,
) (*nats.NatsProducer, error) {
if js == nil {
log.Println("⚠️ customer.address producer not initialized: JetStream instance is nil")
return nil, nil
}
return nats.NewNatsProducer(nats.NatsProducerParams{
Ctx: context.Background(),
JS: js,
Tenant: string(tenant),
Name: "customer-address-producer",
Description: "Channel used to publish events related to customer address changes.",
Channel: event.CUSTOMER_ADDRESS_CHANNEL,
RetentionPeriod: 72 * time.Hour,
UseAuth: true,
})
}
Data module inicializando producers
var Module = fx.Module(
"data",
fx.Provide(producer.NewCustomerAddressProducer),
fx.Provide(producer.NewAddressDefaultSetProducer),
fx.Provide(producer.NewAddressSellerResolvedProducer),
fx.Provide(mongodb.NewCustomerAddressCollection),
fx.Provide(cache.NewAddressCache),
)
Consumers
Un consumer es el punto de entrada para eventos entrantes, equivalente al service en el flujo gRPC. Su responsabilidad es suscribirse a un evento, deserializar el payload y delegar el procesamiento al core. Sin lógica de negocio, sin decisiones propias.
Cada consumer maneja un solo tipo de evento. Si un dominio necesita reaccionar a tres eventos distintos, tiene tres consumers separados.
Estructura
Los consumers viven en la carpeta consumer/ del dominio. Cada
uno tiene dos métodos: Subscribe que inicializa la suscripción
a NATS al arrancar el servicio, y Handler que procesa cada
evento recibido llamando al core.
Ejemplo consumer Subscribe
func (c *AddressSellerResolvedConsumer) Subscribe(
ctx context.Context,
) error {
consumer, err := nats.NewNatsConsumer(nats.NatsConsumerParams{
Ctx: ctx,
JS: c.js,
Name: "dc-wl-groceries-core-customers-address-seller-resolved-consumer",
Description: "Consumer for handling customer.address.seller_resolved events",
Tenant: c.tenant,
Channel: event.CUSTOMER_ADDRESS_CHANNEL,
Event: event.AddressSellerResolvedEvent,
Handler: func(ctx context.Context, ev ce.Event) error {
var e event.AddressSellerResolved
if err := ev.DataAs(&e); err != nil {
return err
}
return c.Handler(ctx, &e)
},
})
if err != nil {
return err
}
c.consumer = consumer
return nil
}
El shared package
El NatsConsumer del shared package maneja la suscripción real
a NATS JetStream y el ciclo de vida de los mensajes. Internamente
implementa un pool de workers con un semáforo para controlar la
concurrencia: cada mensaje que llega se envía a un canal interno
y un worker disponible lo procesa. El número de workers y el
tamaño del buffer son configurables por consumer.
Cuando el handler retorna sin error, el consumer hace ACK del
mensaje. Si el handler falla, hace NAK y JetStream reintenta
la entrega según la política configurada en el stream. Esto
garantiza que ningún mensaje se pierde silenciosamente.
El consumer del dominio no gestiona nada de esto directamente: recibe el evento ya deserializado como CloudEvent y solo necesita implementar el handler de negocio.
Observabilidad distribuida
Una de las principales objeciones a la comunicación asíncrona es la dificultad para trazar un flujo completo. En un sistema síncrono, una request HTTP genera una traza continua de principio a fin. En un sistema asíncrono, el productor termina su trabajo y el consumidor empieza el suyo en un proceso separado, potencialmente en otro momento. Sin instrumentación explícita, esos dos tramos quedan desconectados en las herramientas de observabilidad.
El NatsProducer y el NatsConsumer del shared package resuelven
esto usando OpenTelemetry. El productor inyecta el contexto de
tracing activo en los headers del mensaje antes de publicarlo.
El consumer extrae ese contexto de los headers al recibir el
mensaje y lo usa para iniciar un nuevo span que queda vinculado
al span original del productor.
El resultado es una traza continua que atraviesa el boundary asíncrono: se puede ver en una sola vista el flujo completo desde que el productor publicó el evento hasta que el consumer terminó de procesarlo, aunque hayan ocurrido en procesos y momentos distintos.
Cada publicación y cada consumo generan su propio span con atributos del mensaje: tipo de evento, canal, tenant, ID del CloudEvent. Esto permite filtrar trazas por tipo de evento o por tenant en cualquier herramienta compatible con OpenTelemetry como Jaeger o Grafana Tempo.
Los developers que implementan producers y consumers en sus dominios no necesitan instrumentar nada manualmente. El tracing está integrado en el shared package y se propaga de forma transparente.
El estado actual y la transición
El sistema no es completamente event driven hoy. Hay flujos que todavía dependen de llamadas síncronas entre microservicios, y hay consumers que al procesar un evento llaman a otros microservicios por gRPC para completar su trabajo. Eso es deuda técnica conocida.
El AddressSellerResolvedConsumer es un ejemplo documentado de
esta transición. Al recibir el evento, llama al microservicio
de cart por gRPC para actualizar el carrito, y al microservicio
de auth para actualizar los tokens de segmento. El comentario
en el código lo deja explícito:
Ejemplo deuda técnica documentada
// Este proceso se va a ejecutar en background una vez que el service
// ya respondió a la aplicación. Pero esto al igual que el consumer es temporal.
// El microservicio de cart debe emitir un evento [cart shipping updated] o similar.
// El micro de core auth debe consumir ese evento y actualizar los tokens de segmento ahí.
// Por ahora se mantiene este proceso en este micro hasta tanto se hagan las modificaciones
// necesarias en cart y core auth.
El camino correcto en ese caso es que el microservicio de
customers publique el evento address.seller_resolved y se
desentienda de lo que ocurre después. El microservicio de cart
debería consumir ese evento y publicar su propio evento
cart.shipping_updated. El microservicio de auth debería
consumir ese evento y actualizar los segment tokens de vtex.
Cada uno reacciona de forma independiente, sin que ninguno sepa de los otros.
La transición se hace de forma incremental. El criterio para priorizar qué migrar primero es el acoplamiento que genera más fricción operacional: los flujos donde un despliegue obliga a desplegar otro, o donde un fallo en cascada afecta a más de un dominio.
Al diseñar cualquier feature nueva, el punto de partida es siempre el modelo event driven. Las llamadas síncronas entre microservicios quedan reservadas para los casos donde la respuesta inmediata es un requisito real del flujo, no una conveniencia de implementación.
Cuándo usar eventos y cuándo usar llamadas síncronas
La regla no es “siempre event driven”. Es usar el modelo correcto según la naturaleza de la operación.
Operaciones de escritura → eventos
Cuando el usuario ordena que el sistema haga algo, el flujo natural es síncrono hacia el primer servicio que recibe la orden, y asíncrono para todo lo que ocurre como consecuencia. El servicio procesa la acción, publica un evento que describe lo que ocurrió, y se desentiende de lo que pasa después. Los demás servicios reaccionan de forma independiente.
Ejemplo: el usuario actualiza su dirección. El microservicio de
customers procesa la actualización, publica
customer.address.seller_resolved y responde al cliente. El
microservicio de cart consume ese evento y actualiza el carrito.
El microservicio de auth consume ese evento y actualiza los
tokens. Cada uno reacciona sin que customers sepa de su existencia.
Operaciones de lectura → llamadas síncronas
Cuando el usuario pide datos para construir una pantalla, necesita una respuesta inmediata con información agregada de múltiples fuentes. Ahí la llamada síncrona es el modelo correcto.
Ejemplo: el microservicio de search devuelve una lista de productos con sus promociones calculadas. Para construir esa respuesta llama de forma síncrona al microservicio de promotions. No hay forma práctica de resolver esto con eventos sin pre-calcular y almacenar las promociones en el propio índice de search, lo cual agrega complejidad que solo se justifica si el volumen hace que la llamada síncrona sea un cuello de botella real.
La pregunta clave
Antes de decidir el modelo de comunicación, la pregunta es: ¿esta operación es un comando o una query?
- Si es un comando que dispara consecuencias en otros servicios → eventos.
- Si es una query que necesita agregar datos en tiempo real → llamada síncrona.
Reglas
Los eventos describen hechos, no órdenes. Un evento notifica que algo ocurrió. El nombre del evento va en pasado desde la perspectiva del dominio que lo emite. Si el nombre implica que otro servicio debe hacer algo, es un comando disfrazado de evento.
Un consumer, un evento. Cada consumer maneja un solo tipo de evento. Si un dominio necesita reaccionar a varios eventos, tiene varios consumers independientes.
El core decide, el producer publica. La lógica de cuándo y qué publicar vive en el core. El producer es una abstracción de transporte, no toma decisiones.
El consumer delega al core. El consumer deserializa el evento y llama al core. La lógica de qué hacer con el evento vive en el core, no en el consumer.
Un NatsProducer por canal. Todos los eventos de un dominio
comparten el mismo NatsProducer. La configuración del stream
se define una sola vez a nivel de canal.
Comandos síncronos para reads, eventos para writes. Las llamadas síncronas entre microservicios son válidas en operaciones de lectura donde se necesita agregar datos en tiempo real. En operaciones de escritura que disparan consecuencias en otros servicios, el modelo es event driven.
La deuda técnica se documenta. Cuando un consumer llama a otro microservicio por gRPC como solución temporal, se deja un comentario explícito con el diseño correcto al que se debe migrar. La deuda que no se documenta no se paga.
Tip
Para entender cómo está organizado el catálogo de microservicios y dominios de la plataforma, continuá con Catálogo de servicios.