Eventos en Spring Modulith: listeners, reintentos y fiabilidad

Un OrderService de Spring Boot que, al aceptar un pedido, descuenta stock, envía un email de confirmación y suma puntos de fidelidad. Tres servicios inyectados y tres llamadas seguidas. Funciona. Hasta que llega la cuarta funcionalidad (avisar a logística) y vuelves a abrir OrderService. Y la quinta. Al cabo de un año, el módulo de pedidos conoce a medio sistema, el acoplamiento ha crecido con cada cambio y un fallo en el envío de emails tumba la creación de pedidos.
La solución conocida es invertir la dependencia: que pedidos avise de lo que ha pasado y que cada interesado reaccione por su cuenta. Es lo que resuelven los eventos entre módulos de Spring Modulith. Lo difícil no es publicar un evento. Lo difícil es no perderlo cuando algo falla entre medias, y de eso trata la mayor parte de este artículo.
Es el segundo artículo de la serie sobre Spring Modulith. En el primero, Spring Modulith: módulos con fronteras verificadas, organizamos la aplicación en módulos y convertimos sus fronteras en un test con verify(). Ahora avanzamos hacia la comunicación entre esos módulos mediante eventos.
El código está en Datiacode-Tech/spring-modulith-shop, tag blog-2-eventos. Las salidas se capturaron ejecutando ese tag, y al revisar el artículo repetimos los tests y la demo de caída en Docker sobre el mismo tag.
Eventos en Spring Modulith, en 30 segundos
- Un módulo publica un evento con el ApplicationEventPublisher de siempre; los demás lo escuchan con @ApplicationModuleListener.
- @ApplicationModuleListener es un atajo para tres anotaciones de Spring: el listener se ejecuta después del commit, en otro hilo y en su propia transacción.
- El Event Publication Registry guarda, en la misma transacción que tus datos, una entrada por cada listener interesado. Si la aplicación cae antes de que un listener termine, esa entrada queda incompleta y se puede volver a enviar.
- El reenvío es por listener, no por evento, y la entrega es al menos una vez: los listeners tienen que tolerar ejecutarse más de una vez.
- @ApplicationModuleTest y Scenario permiten probar un módulo aislado estimulándolo con eventos.
En este artículo
- Llamar o avisar
- Publicar dentro de la transacción
- @ApplicationModuleListener: tres anotaciones en una
- El precio: consistencia eventual
- El Event Publication Registry
- Reenviar lo pendiente al arrancar
- Demo: kill -9 a mitad de un listener
- Al menos una vez: idempotencia
- Probar con @ApplicationModuleTest y Scenario
- Event Publication Registry y Outbox
- Errores habituales
El problema: el servicio que conoce a todo el sistema
El enfoque de llamadas directas tiene tres costes que crecen con cada funcionalidad nueva.
Acoplamiento. order depende de inventory, de notification, de loyalty... Cada módulo nuevo que reacciona a un pedido es una dependencia más de pedidos.
Fallos en cascada. Si el servidor de correo no responde, create() lanza una excepción y el pedido no se guarda. Un problema de un módulo secundario rompe el principal.
Transacciones gigantes. Todo ocurre dentro de la transacción del pedido: la conexión a base de datos sigue ocupada mientras se envía el email.
En un monolito modular hay además un problema estructural: si inventory necesita saber de los pedidos y order llama a inventory, tienes un ciclo, y ApplicationModules.verify() lo rechaza. Es el caso real que obligó a separar catalog de inventory en el primer artículo.
Llamar o avisar
| Aspecto | Llamada directa | Evento |
|---|---|---|
| Quién conoce a quién | El llamante conoce al llamado | El que publica no conoce a nadie |
| Respuesta | Inmediata, la necesitas para continuar | No hay respuesta |
| Si el otro falla | Fallas tú | Tú sigues; el fallo es del listener |
| Consistencia | Inmediata | Eventual |
| Ejemplo en el proyecto | order pide nombre y precio a catalog | order avisa de que se aceptó un pedido |
La regla práctica: si necesitas el resultado para continuar, llama. Si solo avisas de algo que ya ha pasado, publica un evento. En el proyecto, order → catalog es una llamada (sin precio no hay pedido) y todo lo que pasa después es un evento.
El evento: un record en la API del módulo
package com.datiacode.shop.order;
/**
* Published when an order has been accepted and stored. The order module does not know who listens.
*/
public record OrderCompleted(Long orderId, String customer, List<Line> lines, BigDecimal total, Instant completedAt) {
public OrderCompleted {
lines = List.copyOf(lines);
}
public record Line(String sku, String productName, int quantity) {
}
}- Vive en la raíz del módulo order, así que forma parte de su API pública. Los listeners de otros módulos pueden usarlo sin violar ninguna frontera.
- Es inmutable: un record con la lista copiada. El evento describe algo que ya pasó, y nadie debería poder modificarlo por el camino.
- Lleva los datos que los listeners necesitan, no solo el id. Si OrderCompleted llevara únicamente orderId, inventory tendría que llamar a order para saber qué productos descontar, y volveríamos a acoplar los módulos en sentido contrario.
Publicar dentro de la transacción
El evento se publica desde DefaultOrderService, la implementación interna de OrderService. Lo que importa para la fiabilidad está en dos líneas: la anotación de la clase y la llamada a publishEvent.
@Service
@Transactional
class DefaultOrderService implements OrderService {
// ...
@Override
public OrderDetails create(NewOrder newOrder) {
// Synchronous call: name and price are needed *before* the order can be stored
List<OrderLine> lines = newOrder.items().stream().map(this::toLine).toList();
var order = orders.save(Order.completed(newOrder.customer(), lines, Instant.now(clock)));
// Published inside this transaction: Modulith stores it in event_publication on the same commit
events.publishEvent(toEvent(order));
return toDetails(order);
}
}- @Transactional está en la clase, no en el método. Se aplica a todos sus métodos públicos, y create() no declara nada propio; findById() sí, para marcarse como de solo lectura.
- La transacción la abre el proxy de Spring. OrderController recibe un OrderService inyectado, que es el proxy transaccional de DefaultOrderService: al llamar a create(), el proxy empieza la transacción y hace commit al volver. Todo lo que ocurre dentro, incluido publishEvent, forma parte de ella.
- La atomicidad depende de ese contexto. Si create() se invocara sin pasar por el proxy (por ejemplo, desde otro método de la misma clase) o fuera de una transacción, ni el pedido y su registro se guardarían juntos ni los listeners transaccionales se dispararían.
events es un ApplicationEventPublisher normal de Spring. No hay API propia de Modulith para publicar: lo que cambia es lo que ocurre alrededor de esa llamada.
@ApplicationModuleListener: tres anotaciones en una
@Component
class OrderCompletedListener {
private final StockItemRepository stock;
OrderCompletedListener(StockItemRepository stock) {
this.stock = stock;
}
@ApplicationModuleListener
void on(OrderCompleted event) {
event.lines().forEach(line -> stock.findById(line.sku()).ifPresentOrElse(
item -> take(item, line.quantity(), event.orderId()),
() -> log.warn("Order {}: product {} is not tracked in inventory", event.orderId(), line.sku())));
}
}| Anotación | Efecto | Por qué importa |
|---|---|---|
| @TransactionalEventListener | El listener solo se ejecuta si la transacción que publicó el evento hace commit | No reaccionas a pedidos que al final se deshicieron |
| @Async | Se ejecuta en otro hilo | Quien publica no espera a los listeners |
| @Transactional(propagation = REQUIRES_NEW) | El listener tiene su propia transacción | Si falla, no afecta al pedido, que ya está guardado |
@ApplicationModuleListener no es una pieza nueva: es la combinación que la documentación de eventos propone como forma por defecto de integrar módulos, con una anotación en lugar de tres.
Lo que se ve en los logs
Un pedido real creado sobre el tag blog-2-eventos:
$ curl -X POST localhost:8080/orders -H 'Content-Type: application/json' \
-d '{"customer":"alice","items":[{"sku":"KB-001","quantity":1},{"sku":"MS-001","quantity":2}]}'
{
"id": 42,
"customer": "alice",
"status": "COMPLETED",
...
"total": 149.7
}13:56:16.839 INFO [shop-async-2] c.d.s.notification.internal.EmailSender: Email to alice@example.com | Order #42 confirmed | ...
13:56:16.847 WARN [shop-async-1] c.d.s.i.internal.OrderCompletedListener: Order 42: stock shortage for KB-001 (requested 1, available 0)
13:56:16.848 INFO [shop-async-1] c.d.s.i.internal.OrderCompletedListener: Order 42: took 2 x MS-001 from stock, 6 left- Hilos distintos: notification en shop-async-2, inventory en shop-async-1. Ninguno corre en el hilo HTTP que atendió la petición.
- Sin orden garantizado: el email se envió antes de que inventory terminara. Si tu lógica depende de que un listener corra antes que otro, los eventos no son la herramienta (o necesitas un segundo evento).
- Un caso que no preparamos: después de muchas pruebas, ya no quedaba stock de KB-001. El pedido se aceptó igualmente, e inventory registró la falta de stock después. Es la consistencia eventual en vivo, y la tratamos más abajo.
Añadir un consumidor sin tocar order
notification es el segundo módulo que escucha OrderCompleted. Para añadirlo no hubo que cambiar ni una línea de order. En el modelo de Modulith, la dependencia va ahora de notification hacia order (es quien conoce el evento), y no al revés: order sigue sin saber cuántos módulos le escuchan.
/**
* Second consumer of {@link OrderCompleted}: adding it did not require any change in the order module.
*/
@Component
class OrderConfirmationListener {
@ApplicationModuleListener
void on(OrderCompleted event) throws InterruptedException {
Thread.sleep(demoDelay);
var items = event.lines().stream()
.map(line -> line.quantity() + " x " + line.productName())
.collect(Collectors.joining(", "));
emailSender.send(event.customer() + "@example.com",
"Order #%d confirmed".formatted(event.orderId()),
"Thanks for your order: %s. Total: %s EUR.".formatted(items, event.total()));
}
}demoDelay vale 0 salvo con el perfil demo, que usaremos más abajo para provocar una caída.
El precio: consistencia eventual
Al separar los módulos, el stock ya no se comprueba antes de aceptar el pedido. Lo que se ve en un pedido de un producto agotado:
$ curl -X POST localhost:8080/orders ... -d '{"customer":"alice","items":[{"sku":"HD-001","quantity":1}]}'
HTTP 201 · pedido 43 · estado COMPLETED
13:56:18.001 WARN [shop-async-3] c.d.s.i.internal.OrderCompletedListener: Order 43: stock shortage for HD-001 (requested 1, available 0)- El cliente recibe un 201 y, milisegundos después, inventory descubre que no hay unidades. Para una tienda de ejemplo basta con un aviso en el log; en un sistema real, ese listener publicaría un StockShortage para que alguien (otro módulo, una persona) decida qué hacer.
- Fíjate en una decisión del listener: ante la falta de stock registra el problema y termina bien, no lanza una excepción. Si lanzara, la publicación quedaría marcada como fallida, y reprocesarla no crearía stock. Una excepción tiene sentido cuando reintentar puede funcionar (un servicio caído), no cuando el problema es de negocio.
Un diseño simplificado a propósito
La demo es útil porque enseña la consistencia eventual sin adornos, pero conviene decirlo claro: el modelo del ejemplo está simplificado con fines didácticos. En el tag, Order.completed() crea el pedido directamente en estado COMPLETED, y el endpoint responde 201 antes de que nadie haya mirado el almacén. Aceptar un pedido, confirmar que hay existencias y dar el pedido por completado son, en un sistema real, estados de negocio distintos que aquí se han fundido en uno.
Hay varias formas de separarlos, y ninguna es la correcta en general:
- Reserva síncrona de existencias antes de aceptar el pedido: order llama a inventory y solo acepta si la reserva tiene éxito. Ganas consistencia inmediata y vuelves a acoplar ambos módulos, con el ciclo que hay que resolver aparte.
- Un estado intermedio, como PENDING_STOCK: el pedido se acepta como pendiente y pasa a confirmado cuando inventory publica que ha reservado las unidades.
- Eventos posteriores para resolver incidencias: inventory publica StockShortage o StockReserved, y order (u otro módulo) reacciona cambiando el estado del pedido o avisando al cliente.
- Compensaciones: si una operación no puede completarse, se deshace lo ya hecho con otra operación de negocio (cancelar el pedido, devolver el cobro), no con un rollback.
Elegir qué va por evento es una decisión de negocio. Para un pedido de una tienda, aceptar y gestionar la falta de stock después suele ser aceptable; para un pago o la reserva de un recurso único, probablemente no. Depende de las reglas del negocio y de las garantías de consistencia que necesites: los eventos no eliminan esa decisión, la hacen explícita. El código del tag no cambia; es la versión mínima para enseñar el mecanismo.
Lo que puede salir mal después del commit
@TransactionalEventListener resuelve un problema y abre otro. El pedido ya está guardado (commit hecho) y los listeners se ejecutan después, en otro hilo. ¿Qué pasa si la aplicación se cae en ese hueco? Un despliegue, un OutOfMemoryError, un kill -9.
Con los eventos de Spring «a pelo», el evento vive solo en memoria. Si el proceso muere, el evento desaparece: el pedido existe, pero el stock no se descontó y el email nunca se envió. Nadie se entera.
Es el problema clásico de la doble escritura: tienes que actualizar la base de datos y notificar algo, y no hay forma de hacer las dos cosas atómicamente... salvo que la notificación también se guarde en la base de datos.
El Event Publication Registry
Eso es exactamente lo que hace Spring Modulith. Con un starter de persistencia (en el proyecto, spring-modulith-starter-jpa), al publicar el evento el registro averigua qué listeners transaccionales lo van a recibir y escribe una entrada por cada uno en la tabla event_publication, como parte de la misma transacción que el pedido. Por defecto cuentan todos los listeners (meta)anotados con @TransactionalEventListener, y @ApplicationModuleListener lo es.
CREATE TABLE event_publication (
id UUID NOT NULL PRIMARY KEY,
listener_id TEXT NOT NULL,
event_type TEXT NOT NULL,
serialized_event TEXT NOT NULL,
publication_date TIMESTAMP(6) WITH TIME ZONE NOT NULL,
completion_date TIMESTAMP(6) WITH TIME ZONE,
status VARCHAR(20)
CHECK (status IN ('PUBLISHED', 'PROCESSING', 'COMPLETED', 'FAILED', 'RESUBMITTED')),
completion_attempts INTEGER NOT NULL,
last_resubmission_date TIMESTAMP(6) WITH TIME ZONE
);Cómo fluye una publicación
El diagrama resume el recorrido completo de un pedido, del commit a la recuperación tras una caída. Los pasos 1 a 3 son el caso normal; el 4 es la demo que veremos más abajo.
Ver en texto · Flujo del Event Publication Registry
┌─ una sola transacción ─────────────────────────────────────────┐
│ 1. Publicación │
│ DefaultOrderService.create() (@Transactional en la clase) │
│ orders.save(pedido) │
│ events.publishEvent(new OrderCompleted(…)) │
│ 2. Persistencia │
│ orders pedido 44 · COMPLETED │
│ event_publication OrderCompletedListener PUBLISHED │
│ OrderConfirmationListener PUBLISHED │
│ COMMIT: pedido y filas se guardan juntos, o no se guarda nada│
└─────────────────────────────────────────────────────────────────┘
3. Procesamiento (tras el commit, cada listener en su hilo y su transacción)
inventory OrderCompletedListener → COMPLETED
notification OrderConfirmationListener → PROCESSING (pausa de 20 s, perfil demo)
4. Recuperación
kill -9 → la fila de notification se queda en PROCESSING
reinicio → republish-outstanding-events-on-restart=true reenvía solo esa fila
fin → notification COMPLETED; inventory no se ejecuta otra vezEstados de una publicación
Estas son las filas reales del pedido 42:
listener | status | publicado | completado
---------------------------+-----------+--------------+--------------
OrderCompletedListener | COMPLETED | 13:56:16.793 | 13:56:16.853
OrderConfirmationListener | COMPLETED | 13:56:16.799 | 13:56:16.839| Estado | Significado |
|---|---|
| PUBLISHED | Guardada, esperando a que la recoja su listener |
| PROCESSING | El listener la ha reclamado y se está ejecutando |
| COMPLETED | El listener terminó bien; tiene fecha de completado |
| FAILED | El listener lanzó una excepción, o el monitor de publicaciones obsoletas la marcó como fallida |
| RESUBMITTED | Una publicación fallida se ha vuelto a enviar y está pendiente de nuevo |
Una fila por listener: ese detalle es lo que hace fino el diseño. Si un listener falla, sabes exactamente cuál, sin tocar los demás. El monitor de publicaciones obsoletas está desactivado por defecto: se activa dando una duración a spring.modulith.events.staleness.published, processing o resubmitted, y entonces marca como FAILED las publicaciones que llevan demasiado tiempo en esos estados.
Reenviar lo pendiente al arrancar
Que una fila quede pendiente no sirve de nada si nadie la reenvía. La opción más sencilla es una propiedad, desactivada por defecto: al arrancar, Modulith busca las publicaciones incompletas y las vuelve a entregar a su listener.
# Event Publication Registry: on startup, resubmit publications whose listener did not complete
spring.modulith.events.republish-outstanding-events-on-restart=trueDemo: kill -9 a mitad de un listener
Para tener tiempo de matar la aplicación mientras un listener trabaja, el proyecto tiene un perfil demo que añade una pausa de 20 segundos al listener de notification:
# Crash demo (docs/demo-caida.md): time window to kill the app while the notification listener is running
shop.demo.notification-delay=20spkill -9 -f '[S]hopApplication' listener | status | publicado | completado
---------------------------+------------+--------------+--------------
OrderCompletedListener | COMPLETED | 13:56:26.356 | 13:56:26.426
OrderConfirmationListener | PROCESSING | 13:56:26.364 | listener | status | publicado | completado
---------------------------+-----------+--------------+--------------
OrderCompletedListener | COMPLETED | 13:56:26.356 | 13:56:26.426
OrderConfirmationListener | COMPLETED | 13:56:26.364 | 13:56:38.382
13:56:38.382 INFO [shop-async-1] c.d.s.notification.internal.EmailSender: Email to alice@example.com | Order #44 confirmed | ...
Descuentos de stock tras reiniciar: 0- inventory ya había terminado cuando llegó el kill -9; notification estaba en mitad de su pausa, con su fila en PROCESSING.
- Al reiniciar solo se reprocesó el listener que no había terminado. El stock no se descontó dos veces.
- Al revisar el artículo repetimos la demo sobre el mismo tag, en Docker y con otro pedido. El resultado fue el mismo: tras el kill -9, la fila de notification seguía en PROCESSING; al reiniciar pasó a COMPLETED con completion_attempts igual a 2, y el stock del producto no cambió.
Al menos una vez: los listeners tienen que ser idempotentes
La demo muestra el caso bueno. Para ver el incómodo hay que separar lo que ocurre en cada publicación:
- La publicación se persiste con el pedido, en el mismo commit
- El listener la reclama y empieza a ejecutarse
- El listener termina su trabajo
el email sale, el stock baja
- El registro marca la publicación como completada
- Si la aplicación cae entre el 3 y el 4, la publicación sigue incompleta y se ejecuta otra vez
El registro no hace idempotentes los efectos
El trabajo del listener y la marca de completado no son atómicos con los efectos que salen de la base de datos: un email enviado no se deshace con un rollback. Si el proceso muere justo después de enviarlo y antes de marcar la fila, al reiniciar el email se envía otra vez.
No es un defecto de Modulith: es lo que significa entrega al menos una vez, y es la garantía que puede dar cualquier sistema que reintenta. El registro asegura que el listener se ejecute; no convierte sus efectos en idempotentes. Evitar los duplicados, cuando importan, sigue siendo responsabilidad de la aplicación.
| Listener | ¿Es idempotente? | Qué hacer |
|---|---|---|
| Enviar un email | No: un duplicado llega al cliente | Aceptarlo, o guardar qué pedidos ya se notificaron |
| Descontar stock | No: descontaría dos veces | Registrar los orderId ya procesados y saltarse los repetidos |
| Actualizar un estado (pedido → enviado) | Sí, de forma natural | Nada |
| Recalcular un agregado desde cero | Sí | Nada |
Automatizar la demo: EventRepublicationTests
Una demo manual convence una vez. Un test lo demuestra en cada build.
FlakyListener es un listener del propio test que lanza una excepción mientras failing está activo. El primer contexto deja la publicación incompleta; el segundo, arrancado contra la misma base de datos (un PostgreSQL de Testcontainers que la clase arranca en @BeforeAll y para en @AfterAll), la completa al arrancar.
@Test
@DisplayName("a publication whose listener failed is completed after restarting the application")
void republishesIncompletePublicationOnRestart() {
FlakyListener.failing.set(true);
try (var app = startApplication()) {
app.getBean(OrderService.class).create(new NewOrder("alice", List.of(new NewOrder.Item("MS-001", 1))));
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(FlakyListener.attempts).hasValueGreaterThanOrEqualTo(1);
assertThat(incompleteFlakyPublications(app)).isEqualTo(1);
});
}
// The "bug" is fixed and the application restarts
FlakyListener.failing.set(false);
try (var app = startApplication()) {
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(FlakyListener.handled).hasValue(1);
assertThat(incompleteFlakyPublications(app)).isZero();
});
}
}En la ejecución con la que se preparó el artículo, el test tardó 3,15 s; al repetirlo en Docker, 3,86 s.
Probar un módulo aislado: @ApplicationModuleTest y Scenario
@SpringBootTest arranca la aplicación entera. @ApplicationModuleTest arranca, por defecto, solo el módulo del paquete en el que está el test. Combinado con Scenario, que se recibe como parámetro del método de test, permite estimular el módulo con un evento como si otro módulo lo hubiera publicado.
En el test, order no está arrancado: el evento lo publica el propio test. Scenario se encarga de la espera asíncrona (andWaitForStateChange) sin Thread.sleep ni bucles a mano:
@ApplicationModuleTest
@Import(TestcontainersConfiguration.class)
class InventoryModuleTests {
private final InventoryService inventory;
InventoryModuleTests(InventoryService inventory) {
this.inventory = inventory;
}
@Test
@DisplayName("OrderCompleted takes the ordered units out of stock")
void orderCompletedDecreasesStock(Scenario scenario) {
int before = inventory.stockOf("MS-001");
var event = new OrderCompleted(42L, "alice", List.of(new OrderCompleted.Line("MS-001", "Wireless mouse", 3)),
new BigDecimal("89.70"), Instant.now());
scenario.publish(event)
.andWaitForStateChange(() -> inventory.stockOf("MS-001"), stock -> stock != before)
.andVerify(stock -> assertThat(stock).isEqualTo(before - 3));
}
}Sin predicado, andWaitForStateChange considera que el estado ha cambiado cuando el valor devuelto no es null, un Optional no vacío, una colección no vacía o true; aquí el valor es el stock, un número que nunca es null, así que sin predicado la espera terminaría al instante: por eso el test pasa stock -> stock != before.
El test de notification y los tiempos
Para notification, el test sustituye EmailSender por un mock con @MockitoBean y espera a que reciba la llamada:
scenario.publish(event)
.andWaitForStateChange(() -> !mockingDetails(emailSender).getInvocations().isEmpty())
.andVerify(sent -> verify(emailSender).send(eq("alice@example.com"), eq("Order #7 confirmed"),
contains("1 x Mechanical keyboard")));EventRepublicationTests 1 tests, 0 fallos, 3.15 s
InventoryModuleTests 1 tests, 0 fallos, 2.67 s
OrderCompletedListenerTest 3 tests, 0 fallos, 0.11 s
NotificationModuleTests 1 tests, 0 fallos, 9.01 sLa lógica del listener, además, tiene su test unitario de siempre (OrderCompletedListenerTest, JUnit 5 + Mockito): @ApplicationModuleTest no sustituye a los tests unitarios, prueba el cableado del módulo. En la repetición en Docker pasaron los 22 tests del tag.
Event Publication Registry: una solución similar a Outbox integrada en Spring Modulith
Si has trabajado con microservicios, todo esto te suena al patrón Outbox: guardar el mensaje en la misma transacción que los datos y entregarlo después. El parecido es real, pero no son lo mismo.
En qué se parecen. Las publicaciones se persisten en la misma transacción que los datos de negocio; las entregas pendientes se pueden recuperar tras una caída; productor y consumidor quedan desacoplados, porque order no sabe quién escucha; y una publicación incompleta se puede reprocesar.
En qué se diferencian. El registro está orientado a las publicaciones que gestiona Spring Modulith para los listeners transaccionales de la propia aplicación. En el ejemplo, toda la comunicación es entre módulos del mismo proceso. Un Outbox pensado para sistemas externos necesita además mecanismos de distribución: un relay o CDC que lea la tabla, un broker, serialización con contrato estable entre equipos y consumidores en otros procesos que deduplican por su cuenta.
| Aspecto | Outbox a mano | Event Publication Registry |
|---|---|---|
| Tabla de mensajes | La diseñas tú | event_publication |
| Guardar en la misma transacción | Código propio | Automático al publicar |
| Entrega y reintentos | Un poller o CDC que escribes tú | Modulith, a listeners de la misma aplicación (al arrancar o con IncompleteEventPublications) |
| Granularidad del reintento | La que implementes | Por listener |
| Destino | Normalmente un broker | Listeners internos; hacia un broker hace falta externalizar los eventos |
La ventaja práctica es que, para la comunicación entre módulos, no tienes que implementarlo: viene integrado y lo ves funcionar en una tabla. Sacar los eventos de la aplicación hacia un broker es otro problema, y es el tema del siguiente artículo.
Lo que queda del lado del equipo
El registro resuelve la entrega. Hay tres cosas que siguen siendo responsabilidad tuya:
- Que la tabla no crezca sin límite. Por defecto (completion-mode UPDATE), las publicaciones completadas se quedan en la tabla con su fecha. La propiedad spring.modulith.events.completion-mode permite borrarlas al completar (DELETE) o moverlas a una tabla de archivo (ARCHIVE); también puedes limpiarlas periódicamente con CompletedEventPublications y su método deletePublicationsOlderThan(Duration).
- Qué hacer con los listeners que fallan siempre. Un reintento al arrancar está bien para caídas puntuales. Para un listener con un bug, reintentar en cada arranque solo repite el error: necesitas alertas sobre las publicaciones FAILED y, si hay riesgo de que se queden colgadas, configurar el monitor de publicaciones obsoletas.
- Idempotencia, como se vio arriba.
Errores habituales
| Error | Consecuencia | Qué hacer |
|---|---|---|
| Publicar fuera de una transacción | @TransactionalEventListener no se dispara | Publicar desde un método que se ejecute dentro de una transacción, a través del proxy de Spring |
| Olvidar @EnableAsync | Los listeners no se ejecutan en otro hilo como esperas | Habilitarlo en una configuración compartida |
| Eventos con solo el id | Los listeners llaman de vuelta al publicador y reaparece el acoplamiento | El evento lleva los datos que necesitan los listeners |
| Lanzar excepciones por errores de negocio | La publicación queda FAILED y reintentarla no arregla nada | Excepciones solo para fallos transitorios |
| Suponer un orden entre listeners | Funciona en local y falla en producción | Si el orden importa, encadena eventos |
| No pensar en duplicados | Emails dobles, stock descontado dos veces | Listeners idempotentes |
| Republicar al arrancar con varias instancias | Una instancia nueva reentrega eventos que otra está procesando | Reenviar desde una tarea controlada con IncompleteEventPublications |
| No limpiar event_publication | La tabla crece sin límite | completion-mode o limpieza periódica |
Cuándo usar eventos y cuándo una llamada
Evento cuando avisas de algo que ya ha pasado y no necesitas respuesta; cuando varios módulos pueden reaccionar y quieres poder añadir más sin tocar el publicador; cuando un fallo del consumidor no debe afectar al productor; y cuando la consistencia eventual es aceptable para el negocio.
Llamada directa cuando necesitas el resultado para continuar (el precio de un producto), cuando la operación tiene que ser atómica con la tuya o cuando solo hay un consumidor posible y no prevés más.
Conclusión
Publicar un evento es fácil; lo que distingue a Spring Modulith es lo que pasa cuando las cosas van mal. Con el Event Publication Registry, un evento no se pierde porque la aplicación caiga a mitad de un listener: queda escrito en la misma transacción que los datos, por listener, y se puede reenviar cuando la aplicación vuelve. Lo que no hace es decidir por ti qué ocurre con los duplicados ni qué estados de negocio necesita tu dominio.
Los eventos no hacen desaparecer el acoplamiento: lo convierten en un contrato explícito, el evento, que puedes versionar, probar y, el día que haga falta, sacar del proceso.
En el siguiente artículo de la serie, ese contrato sale del monolito: externalización de eventos a Kafka con @Externalized y qué pasa con el usuario autenticado cuando un listener cambia de hilo.
Fuentes técnicas
Preguntas frecuentes
¿Necesito Kafka o RabbitMQ para usar eventos en Spring Modulith?
No. Los eventos entre módulos son eventos de aplicación de Spring, dentro de la misma JVM, y el registro se guarda en tu propia base de datos. Un broker solo hace falta si los eventos tienen que salir de la aplicación.
¿Qué bases de datos soporta el Event Publication Registry?
Hay starters para JPA, JDBC, MongoDB y Neo4j. El de MongoDB necesita un replica set para tener transacciones. El proyecto de ejemplo usa JPA sobre PostgreSQL.
¿El reenvío repite el evento a todos los listeners?
No. Hay una entrada por listener, y solo se reenvía a los listeners cuya entrada no está completada. En la demo, inventory no volvió a ejecutarse.
¿Qué pasa si el listener lanza una excepción?
Su transacción se deshace, la publicación queda marcada como FAILED y el pedido original no se ve afectado, porque ya estaba guardado. Se puede volver a enviar al arrancar, si está activa spring.modulith.events.republish-outstanding-events-on-restart, o con IncompleteEventPublications. Es lo que comprueba EventRepublicationTests.
¿Puedo tener listeners síncronos?
Sí, con un @EventListener normal, que se ejecuta en la transacción y el hilo del publicador. Pierdes las ventajas del desacoplamiento: un fallo del listener deshace la operación principal. Para comunicar módulos, el asíncrono es el valor por defecto razonable.
¿Cómo pruebo un listener sin arrancar toda la aplicación?
Con @ApplicationModuleTest en el paquete del módulo y Scenario.publish(evento) para estimularlo. Las dependencias de otros módulos se sustituyen con @MockitoBean.
¿Esto sustituye a una saga?
No. El registro garantiza que cada listener recibe el evento. Una saga coordina varios pasos con compensaciones si uno falla. Puedes construir una saga sobre eventos de Modulith, pero la lógica de compensación sigue siendo tuya.
Sigue leyendo
Ver todos los artículos- Leer artículo
Arquitectura de software · JavaArquitectura de software19 minSpring Modulith: módulos con fronteras verificadas en un monolito Spring Boot
- Leer artículo
Desarrollo con IA · JavaDesarrollo con IA12 minStructured Output con Spring AI: de texto a objetos Java fiables
- Leer artículo
Desarrollo con IA · JavaDesarrollo con IA15 minTool Calling con Spring AI: cómo conectar un LLM con tu backend Java
