====== 08 - Eventos de aplicación y mensajería ====== La llamada a la API pública de otro módulo es comunicación **síncrona**: quien llama necesita el resultado para continuar. Los **eventos de aplicación** permiten la comunicación inversa: un módulo anuncia que algo ha ocurrido y otros módulos reaccionan, sin que el emisor los conozca. ===== 1. Cuándo usar un evento ===== Un evento encaja cuando la operación auxiliar **no forma parte del flujo principal** de la tarea: ^ Situación ^ Mecanismo ^ | Cancelar un pedido debe liberar la reserva de stock | Llamada a la API pública de stock: es parte del resultado correcto de la operación | | Al cancelar un pedido, enviar un correo de confirmación | Evento: el cliente no espera el correo y el flujo no depende de él | El criterio es la **consistencia**: si la acción auxiliar no ocurre, la operación principal sigue siendo correcta y no deja el sistema en un estado inválido, entonces puede ser un evento. Si el sistema queda inconsistente sin ella, debe estar en el flujo principal. Un evento **no debilita la operación principal**: el emisor no sabe quién consume ni si alguien consume. Nunca se usa un evento para tareas cuyo resultado necesita el flujo: para eso está la llamada síncrona a la API pública. ===== 2. Publicar y consumir eventos ===== **Spring** proporciona un mecanismo de publicación con ''ApplicationEventPublisher''. El evento es un objeto inmutable que describe lo ocurrido; el consumidor declara un método listener: // En el módulo de pedidos class OrderServiceImpl implements OrderService { private final ApplicationEventPublisher events; public OrderSummaryDto cancel(CancelOrderDto request) { Order order = // ... regla de negocio ... order.cancel(); events.publishEvent(new OrderCancelled(order.id())); return mapper.toSummary(order); } } // En el módulo de notificaciones @Component class NotificationListener { @EventListener void on(OrderCancelled event) { // Enviar correo de confirmación. } } El emisor conoce el evento, no a los consumidores: el módulo de pedidos no importa ninguna clase del módulo de notificaciones. Añadir un nuevo consumidor no cambia al emisor. ==== 2.1 Síncrono, transaccional y asíncrono ==== La publicación es **síncrona por defecto**: el listener se ejecuta en la misma llamada y dentro de la transacción de quien publica. Mantener una única transacción conserva la consistencia, pero amplía su alcance: si el listener falla, falla la operación principal. @Async @TransactionalEventListener void on(OrderCancelled event) { // Se ejecuta tras el commit, fuera de la transacción del emisor. } Con ''@TransactionalEventListener'' y ''@Async'' el trabajo secundario se ejecuta después del ''commit'' y desacoplado del flujo. El coste es la fiabilidad: si el proceso cae entre el ''commit'' y la ejecución, la publicación se pierde. ^ Modo ^ Momento de ejecución ^ Consistencia ^ Riesgo ^ | ''@EventListener'' | En la llamada, dentro de la transacción del emisor | Una sola unidad | El fallo del listener afecta a la operación principal | | ''@TransactionalEventListener'' + ''@Async'' | Tras el ''commit'', en otro hilo | El emisor ya confirmó | La publicación puede perderse si el proceso cae | ==== 2.2 Spring Modulith ==== **Spring Modulith** ofrece ''@ApplicationModuleListener'', abreviatura de la combinación habitual para eventos entre módulos: asíncrona y transaccional. Además, su **registro de publicaciones** guarda cada publicación en la base de datos y la marca como completada o pendiente; si el listener falla, la publicación queda registrada para reintentarla, resolviendo el riesgo de pérdida del modo asíncrono. ===== 3. Brokers de mensajes ===== Los eventos de aplicación viven dentro del proceso. Cuando el consumidor está en otra aplicación, la publicación debe atravesar la red: un **broker de mensajes** recibe el mensaje del emisor y lo entrega a los consumidores registrados. **RabbitMQ** y **Kafka** son los brokers más habituales: ^ Aspecto ^ **RabbitMQ** ^ **Kafka** ^ | Modelo | Colas: el broker entrega cada mensaje a un consumidor | Log distribuido: los mensajes se conservan y cada consumidor avanza a su ritmo | | Entrega | Enruta por intercambios y colas | Particiones por clave; orden garantizado dentro de partición | | Conservación | Al confirmar el consumo, el mensaje se retira | Retención por tiempo o tamaño, independiente del consumo | | Uso típico | Tareas a distribuir, enrutado flexible | Flujos de eventos de alto volumen, reconstrucción de estado | @startuml participant "Módulo emisor\n(mismo proceso)" as E queue "Broker\n(RabbitMQ / Kafka)" as B participant "Consumidor interno" as C1 participant "Otra aplicación" as C2 E -> B : publica evento externalizado B -> C1 : entrega B -> C2 : entrega @enduml El broker introduce requisitos nuevos: serialización del mensaje, versiones del contrato, entrega al menos una vez (los consumidores deben tolerar duplicados) y supervisión de mensajes sin procesar. ==== 3.1 Kafka ==== **Kafka** organiza el flujo como un **log distribuido**: cada **topic** se divide en **particiones**, y cada mensaje queda registrado en su partición hasta que la retención lo expira. El orden se garantiza **dentro de la partición**; la clave del mensaje decide la partición, así que los mensajes con la misma clave se procesan en orden. Los consumidores avanzan leyendo un **offset** por partición. Un **consumer group** reparte las particiones entre sus miembros: el paralelismo de consumo es el número de particiones. Como el offset es la posición de cada grupo, un grupo nuevo o suscrito tarde puede leer el histórico completo del topic si la retención lo permite. @startuml component "Productor\n(módulo de pedidos)" as P rectangle "Topic: order-events\n(particiones)" as T queue "Consumer group: notifications" as G P -> T : publica el mensaje;\nla clave decide la partición T -> G : reparto de particiones\nentre los consumidores @enduml // Publicar (spring-kafka) kafkaTemplate.send("order-events", String.valueOf(order.id()), event); // Consumir @KafkaListener(topics = "order-events", groupId = "notifications") void onOrderCancelled(OrderCancelled event) { // Reacción secundaria. } services: kafka: image: apache/kafka ports: - "9092:9092" El contenedor de Kafka en modo **KRaft** arranca sin Zookeeper ni configuración extra; suficiente para desarrollo y pruebas. La misma imagen sirve a **Testcontainers** en las pruebas de integración. ==== 3.2 RabbitMQ ==== **RabbitMQ** usa el modelo de colas con **routing**: el productor envía a un **exchange**, que reparte los mensajes a las **colas** vinculadas según la clave de enrutado. Los tipos habituales: ''fanout'' (difusión a todas), ''direct'' (coincidencia exacta) y ''topic'' (coincidencia por patrón). Al confirmar el consumo, el mensaje se retira de la cola: lo publicado no queda como un log que se pueda volver a leer. // Publicar (spring-amqp) rabbitTemplate.convertAndSend("order-exchange", "order.cancelled", event); // Consumir @RabbitListener(queues = "order-notifications") void onOrderCancelled(OrderCancelled event) { // Reacción secundaria. } services: rabbit: image: rabbitmq:4-management ports: - "5672:5672" - "15672:15672" El puerto ''15672'' abre la consola de gestión (**management UI**), útil para ver colas, bindings y consumidores durante la clase. ===== 4. Externalización de eventos ===== **Spring Modulith** conecta los eventos internos con los brokers mediante la anotación ''@Externalized'', que marca qué eventos salen del proceso y su destino: @Externalized("order-events::${spring.application.name}") public record OrderCancelled(long orderId) {} Con la integración de **Kafka** o **AMQP** (RabbitMQ), el evento se publica en el broker al completarse la transacción, apoyándose en el registro de publicaciones para los reintentos. La aplicación externa consume el mensaje con las herramientas del broker, sin conocer la aplicación emisora. Las integraciones concretas están descritas en [[documentacion:spring-modulith-runtime|Soporte de ejecución]]. La externalización mantiene el contrato de eventos como parte de la API pública del módulo: el mensaje es un DTO con formato estable, porque consumidores externos dependen de él. ===== 5. Pruebas ===== * Probar que la operación principal publica el evento esperado con los datos correctos. * Probar cada listener de forma aislada: recibe el evento y realiza su acción auxiliar. * Probar la integración emisor-consumidor con el contexto de módulos; para listeners asíncronos, esperar la reacción con la API ''Scenario''. * Probar la fiabilidad: un listener que falla deja la publicación pendiente y puede reintentarse. * Probar la externalización de punta a punta contra el broker real en un contenedor efímero (**Testcontainers**): publicar el evento y consumirlo con la integración del broker. @ApplicationModuleTest class OrderCancelledScenarioTest { @Test void notificationReactsToCancellation(Scenario scenario) { scenario.mutate(() -> orderService.cancel(new CancelOrderDto(42L))) .andWaitForEventOfType(NotificationSent.class) .toArriveAndVerify(event -> assertThat(event.orderId()).isEqualTo(42L)); } } La publicación del estímulo se ejecuta en su propia transacción y la espera del evento resultante sustituye a las pausas arbitrarias de los tests con hilos asíncronos. Los eventos desacoplan al emisor de sus consumidores, pero la asincronía trasladó el problema a la fiabilidad: persistir las publicaciones, reintentar los fallos y tolerar entregas duplicadas forma parte del diseño, no es un detalle de configuración.