Saltar al contenido principal
Arquitectura de software21 min de lectura

Eventos en Spring Modulith: listeners, reintentos y fiabilidad

Cómo comunicar módulos con eventos en Spring Modulith sin acoplarlos y sin perder eventos: @ApplicationModuleListener, Event Publication Registry, un kill -9 a mitad de un listener y cómo probarlo, sobre un repositorio real.

Cristian Osorio · DatIACode

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

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

Llamada directa frente a evento entre módulos
AspectoLlamada directaEvento
Quién conoce a quiénEl llamante conoce al llamadoEl que publica no conoce a nadie
RespuestaInmediata, la necesitas para continuarNo hay respuesta
Si el otro fallaFallas túTú sigues; el fallo es del listener
ConsistenciaInmediataEventual
Ejemplo en el proyectoorder pide nombre y precio a catalogorder 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

order/OrderCompleted.javajava
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.

DefaultOrderService.java · order/internal (extracto)java
@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

OrderCompletedListener.java · inventory/internal (extracto)java
@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())));
	}
}
Las tres anotaciones que combina @ApplicationModuleListener
AnotaciónEfectoPor qué importa
@TransactionalEventListenerEl listener solo se ejecuta si la transacción que publicó el evento hace commitNo reaccionas a pedidos que al final se deshicieron
@AsyncSe ejecuta en otro hiloQuien publica no espera a los listeners
@Transactional(propagation = REQUIRES_NEW)El listener tiene su propia transacciónSi 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:

Petición (respuesta formateada y abreviada)text
$ 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
}
Log de los listeners (líneas recortadas: sin fecha, PID ni nombre de la aplicación)text
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.

OrderConfirmationListener.java · notification/internal (extracto)java
/**
 * 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:

Pedido de un producto sin stocktext
$ 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.

V2__event_publication.sql · db/migration (Flyway, sin los índices)sql
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.

Flujo del Event Publication Registry en Spring Modulith: publicación transaccional, ejecución de listeners y recuperación selectiva de eventos pendientes.
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 vez

Estados de una publicación

Estas son las filas reales del pedido 42:

event_publication del pedido 42text
         listener          |  status   |  publicado   |  completado
---------------------------+-----------+--------------+--------------
 OrderCompletedListener    | COMPLETED | 13:56:16.793 | 13:56:16.853
 OrderConfirmationListener | COMPLETED | 13:56:16.799 | 13:56:16.839
Estados de una publicación en el Event Publication Registry de Spring Modulith 2.x
EstadoSignificado
PUBLISHEDGuardada, esperando a que la recoja su listener
PROCESSINGEl listener la ha reclamado y se está ejecutando
COMPLETEDEl listener terminó bien; tiene fecha de completado
FAILEDEl listener lanzó una excepción, o el monitor de publicaciones obsoletas la marcó como fallida
RESUBMITTEDUna 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.

application.propertiesproperties
# Event Publication Registry: on startup, resubmit publications whose listener did not complete
spring.modulith.events.republish-outstanding-events-on-restart=true

Demo: 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:

application-demo.propertiesproperties
# Crash demo (docs/demo-caida.md): time window to kill the app while the notification listener is running
shop.demo.notification-delay=20s
Arrancamos con dev,demo, creamos un pedido (el 44) y, a los tres segundos, matamos el proceso sin darle opción a cerrar limpiamentebash
pkill -9 -f '[S]hopApplication'
event_publication tras el kill -9text
         listener          |   status   |  publicado   |  completado
---------------------------+------------+--------------+--------------
 OrderCompletedListener    | COMPLETED  | 13:56:26.356 | 13:56:26.426
 OrderConfirmationListener | PROCESSING | 13:56:26.364 |
Tras reiniciar (solo con dev)text
         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:

  1. La publicación se persiste con el pedido, en el mismo commit
  2. El listener la reclama y empieza a ejecutarse
  3. El listener termina su trabajo

    el email sale, el stock baja

  4. El registro marca la publicación como completada
  5. 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.

Idempotencia de los listeners según su efecto
Listener¿Es idempotente?Qué hacer
Enviar un emailNo: un duplicado llega al clienteAceptarlo, o guardar qué pedidos ya se notificaron
Descontar stockNo: descontaría dos vecesRegistrar los orderId ya procesados y saltarse los repetidos
Actualizar un estado (pedido → enviado)Sí, de forma naturalNada
Recalcular un agregado desde ceroSí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.

EventRepublicationTests.java: dos aplicaciones arrancadas una tras otra contra el mismo PostgreSQLjava
@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:

InventoryModuleTests.java · inventoryjava
@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:

NotificationModuleTests.java · notification/internal (extracto)java
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")));
Tiempos de la ejecución con la que se preparó el artículotext
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 s

La 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.

Outbox implementado a mano frente al Event Publication Registry
AspectoOutbox a manoEvent Publication Registry
Tabla de mensajesLa diseñas túevent_publication
Guardar en la misma transacciónCódigo propioAutomático al publicar
Entrega y reintentosUn poller o CDC que escribes túModulith, a listeners de la misma aplicación (al arrancar o con IncompleteEventPublications)
Granularidad del reintentoLa que implementesPor listener
DestinoNormalmente un brokerListeners 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

Errores habituales con eventos en Spring Modulith
ErrorConsecuenciaQué hacer
Publicar fuera de una transacción@TransactionalEventListener no se disparaPublicar desde un método que se ejecute dentro de una transacción, a través del proxy de Spring
Olvidar @EnableAsyncLos listeners no se ejecutan en otro hilo como esperasHabilitarlo en una configuración compartida
Eventos con solo el idLos listeners llaman de vuelta al publicador y reaparece el acoplamientoEl evento lleva los datos que necesitan los listeners
Lanzar excepciones por errores de negocioLa publicación queda FAILED y reintentarla no arregla nadaExcepciones solo para fallos transitorios
Suponer un orden entre listenersFunciona en local y falla en producciónSi el orden importa, encadena eventos
No pensar en duplicadosEmails dobles, stock descontado dos vecesListeners idempotentes
Republicar al arrancar con varias instanciasUna instancia nueva reentrega eventos que otra está procesandoReenviar desde una tarea controlada con IncompleteEventPublications
No limpiar event_publicationLa tabla crece sin límitecompletion-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.

  • Leer artículo
    Arquitectura de software19 min

    Spring Modulith: módulos con fronteras verificadas en un monolito Spring Boot

  • Leer artículo
    Desarrollo con IA12 min

    Structured Output con Spring AI: de texto a objetos Java fiables

  • Leer artículo
    Desarrollo con IA15 min

    Tool Calling con Spring AI: cómo conectar un LLM con tu backend Java

Ver todos los artículos