Decisiones de mensajeria
Publicar un mensaje al exterior y cambiar el estado local son dos escrituras a sistemas distintos sin transaccion comun — el dual-write problem. Toda la arquitectura del sistema de mensajeria sale de las decisiones que se tomaron para resolverlo: outbox local, inbox con dedup, cuatro bandejas separadas, Spring como transport por default, y PROCESSING no persistido — cada una con su rationale y alternativas descartadas.
El dual-write problem
Cuando un servicio cambia su estado y anuncia ese cambio al exterior, son dos escrituras a sistemas distintos: la base de datos local y el message broker. Sin una transaccion distribuida que las una, hay dos ordenes posibles y los dos son malos:
-
Publicar antes del commit: si la transaccion revierte (excepcion, deadlock, falla de validacion tardia), el mensaje ya salio. Los consumidores reaccionan a un cambio que no ocurrio — generan estado fantasma.
-
Publicar despues del commit: si el proceso se cae entre el commit y el publish, el cambio queda persistido pero nadie afuera lo sabe. El mensaje se pierde silenciosamente.
Las transacciones distribuidas clasicas (XA, 2PC) resuelven esto en teoria, pero en la practica sufren bloqueos, dependencias de coordinador, y suelen estar deshabilitadas en stacks modernos por costo de operacion. Algunas extensiones modernas (Kafka transactions, Pub/Sub message ordering) acercan parte de la garantia, pero acoplan la base de datos al broker y tienen sus propios modos de falla.
Por que outbox
El patron outbox convierte el dual-write en single-write: el mensaje se persiste en una tabla local (outbox_events, outbox_commands) dentro de la misma transaccion que el cambio del agregado. Como ambas escrituras viven en la misma base de datos, la transaccion local es suficiente para atomicidad.
Un worker asincrono lee el outbox, publica al broker, y marca el resultado. Si la transaccion del caso de uso revierte, el INSERT al outbox tambien revierte — el mensaje no existe y no sale. Si el proceso se cae despues del commit pero antes de publicar, el mensaje sigue en el outbox; cuando el worker vuelve a ejecutar, lo retoma.
El precio: latencia adicional entre el cambio y la entrega al broker (al menos un ciclo de polling), y carga de escritura adicional en la base. A cambio: garantia at-least-once sin transaccion distribuida.
Por que inbox
Del lado consumidor, los brokers tipicamente entregan at-least-once: pueden reentregar el mismo mensaje si el ACK del consumer se pierde, si hay rebalanceo de particiones, o si el consumer reinicia. Sin medidas, eso significa que el handler corre dos veces el mismo trabajo.
Dos alternativas para absorberlo:
-
Handlers idempotentes: que el dev escriba cada handler de modo que ejecutarlo dos veces produzca el mismo resultado que ejecutarlo una. Es robusto pero pone la carga en cada handler — y idempotencia no es trivial cuando el handler emite efectos secundarios externos (HTTP calls, otros mensajes, escrituras a otros sistemas).
-
Inbox con dedup por id: el sistema persiste el mensaje recibido en una tabla local antes de procesarlo; si el id ya existe, descarta. El handler corre como maximo una vez por mensaje, sin importar cuantas veces lo entregue el broker.
El arquetipo eligio inbox + dedup. El sistema dedupea en el listener; el dev escribe sus handlers asumiendo que se invocan a lo mas una vez.
El precio: el id del mensaje pasa a ser load-bearing — debe ser estable entre publish y delivery (los brokers tipicamente lo garantizan, pero un publisher que regenere el id en cada reintento rompe la dedup). Y crece una tabla mas en la base.
Por que cuatro bandejas separadas
Una sola tabla con discriminador (event/command, outbox/inbox) seria mas pequena pero acoplaria operaciones que se benefician de ser independientes:
-
Ciclos de procesamiento distintos: el outbox de eventos drena cada 5 segundos; el inbox cada 2. Una tabla compartida fuerza un solo polling-interval para todos los flujos.
-
Encendido/apagado independiente: en un servicio que solo emite eventos y no consume nada todavia, el outbox-processor debe correr pero el inbox-processor no. Con bandejas separadas, cada uno tiene su flag
enabled. -
Metricas y monitoreo separados: contar "pending" o "stuck" tiene sentido por bandeja, no globalmente. El operador quiere saber si los eventos salen, distinto de si los comandos llegan.
-
Locking independiente: ShedLock toma un lock por (bandeja, tenant). Una tabla compartida obligaria a serializar lo que hoy puede correr en paralelo.
El costo es 4x el numero de tablas y workers — aceptable por la claridad operativa.
Por que events y commands son tipos distintos
Un event y un command no son intercambiables aunque ambos sean "mensajes" — modelan dos cosas distintas del dominio:
-
Event = hecho pasado, sin destinatario.
TaskCreatedEventdescribe algo que paso. Cualquier consumidor puede reaccionar; el emisor no sabe ni le importa quien. -
Command = intencion futura, dirigida.
ArchiveTaskCommandpide que algo ocurra, a un servicio y tenant especificos. Es direccional, no broadcast.
Esta distincion (que viene de DDD y CQRS) tiene consecuencias en la implementacion:
-
Los events llevan
aggregateTypeyaggregateId(de donde vienen) pero no destinatario; los commands llevantargetTenantIdytargetService(a donde van) pero no agregado emisor. -
Los events se emiten desde el agregado (que acumula sus eventos durante la mutacion); los commands se emiten desde el caso de uso (que decide enviar una intencion).
-
El ruteo es por tipo en ambos casos, pero el modelo mental es distinto: events publican-y-suscriben; commands envian-y-procesan.
Mezclarlos en un solo tipo Message obligaria a un campo "es-evento-o-comando" y a documentar la asimetria en codigo en lugar de en tipos.
Por que Spring como transport por default
El sistema podria haber empezado con Kafka, Pub/Sub o RabbitMQ como dependencia obligatoria, pero eso impondria infraestructura en cada deployment — incluyendo desarrollo local, tests, y servicios que no necesitan emitir cross-process.
ApplicationEventPublisher de Spring satisface el mismo contrato (EventsPublisher, CommandPublisher) intra-JVM, sin red ni serializacion. Para un servicio que aun no se integra con otros, o para tests de integracion de la cadena outbox-publish-listener-inbox, esto basta.
Cuando el servicio crece y necesita un broker real, el dev provee su propio EventsPublisher / CommandPublisher como bean Spring; el @ConditionalOnMissingBean del auto-configuration apaga el default. El codigo del dominio, los handlers, los workers, las bandejas — nada de eso cambia. Es el patron de adapter intercambiable de la arquitectura hexagonal aplicado al transporte.
Decisiones de implementacion
El estado PROCESSING no se persiste
El enum MessageStatus define cuatro estados — PENDING, PROCESSING, COMPLETED, FAILED — pero los dispatchers/processors actuales saltan PROCESSING: leen mensajes PENDING, los procesan en memoria, y persisten directamente COMPLETED/FAILED/PENDING (retry).
Dos enfoques posibles:
-
Persistir
PROCESSING: marcar el mensaje como tomado, hacer el trabajo, marcar el resultado. Dos escrituras por mensaje. Si el worker muere a mitad, el mensaje queda visiblemente "stuck en PROCESSING" — un operador puede detectarlo y reiniciar. -
Estado en memoria: una sola escritura por mensaje (al final). Si el worker muere, el mensaje sigue como
PENDINGy el proximo ciclo lo retoma — sin necesidad de intervencion.
El arquetipo eligio la segunda. La consecuencia: si un worker publica al broker y muere antes de hacer updateAll, el mensaje queda PENDING, se retoma, y se publica de nuevo — duplicado en el broker. Para events, el inbox dedup del consumidor absorbe esto. Para commands, depende de si el sistema receptor tiene su propia dedup.
Es un tradeoff explicito: simpleza operativa a cambio de aceptar duplicados raros que el receiver debe tolerar.
Polling en lugar de event-driven
Los workers podrian reaccionar a un trigger Spring (e.g., publicar un OutboxMessageInserted event despues de cada INSERT al outbox y reaccionar a el). En cambio, hacen polling cada polling-interval.
El polling es mas simple: no requiere coordinacion entre la transaccion del INSERT y el trigger, no depende del orden de inicializacion de beans, y tolera trivialmente el caso "el mensaje quedo en la bandeja porque el worker estaba apagado cuando se inserto". El costo es latencia (hasta un polling-interval entre INSERT y publish) y carga de SELECT, que es despreciable con un indice por status.
Sin transacciones distribuidas
El patron outbox/inbox da garantias equivalentes a las de XA/2PC para el caso de uso comun (cambiar estado + emitir evento), sin la complejidad operativa de un coordinador distribuido, sin el bloqueo de recursos cross-services, y sin la dependencia de soporte transaccional en el broker. El precio es entrega asincrona, que en la mayoria de los flujos event-driven es deseable de todos modos.
Alternativas consideradas
-
Event sourcing puro — persistir solo los eventos y reconstruir el estado leyendolos. Es mas radical y exige cambios en el modelo de dominio que el arquetipo prefiere evitar para no imponer una decision tan invasiva.
-
Sagas con coordinadores externos — para flujos cross-aggregate. No reemplaza al outbox: todavia hay que emitir y consumir mensajes de forma confiable. El outbox es complementario, no alternativo.
-
Change Data Capture (CDC) — leer directamente los cambios del binlog/WAL de la base y publicar al broker. Resuelve el dual-write desde la infraestructura, sin codigo de aplicacion. Es mas potente pero requiere un componente externo (Debezium, etc.) y acopla el broker al schema de la base.
-
Transacciones distribuidas (XA/2PC) — descartado por las razones que abren este documento.
Lecturas adicionales
-
Vernon, Vaughn. Implementing Domain-Driven Design — capitulos sobre integration y messaging.
-
Hohpe, Gregor; Woolf, Bobby. Enterprise Integration Patterns — outbox, idempotent receiver, dedup.
-
Pattern: Transactional Outbox (Chris Richardson).
-
Pattern: Idempotent Consumer (Chris Richardson).