Parte 4 — Eventos, Outbox Pattern y Externalización¶
Duración: 25 minutos
Objetivo: Implementar comunicación event-driven entre módulos con garantías de entrega
¿Qué hacemos aquí?¶
La Parte 3 usó eventos internos al módulo catalog para sincronizar CQRS. Ahora implementamos eventos entre módulos — la comunicación entre orders e inventory.
También aprovechamos esta parte para mejorar el diseño de orders: una orden real tiene múltiples ítems, no un solo producto. Hacemos ese refactor antes de agregar los eventos, para que todo quede coherente.
Evolución 1: @EventListener — el básico¶
@Component
class OrderEventsInventoryHandler {
@EventListener
void on(OrderCreatedEvent event) {
inventoryService.decreaseStock(event.productCode(), event.quantity());
}
}
Funciona, pero se ejecuta dentro de la misma transacción que creó la orden:
BEGIN TRANSACTION
1. OrderService guarda la orden
2. @EventListener ejecuta el handler inmediatamente
3. InventoryService descuenta stock
COMMIT
Si el paso 3 falla, el ROLLBACK también deshace la orden. Los módulos están acoplados transaccionalmente aunque estén en paquetes separados.
Evolución 2: @TransactionalEventListener — separar las transacciones¶
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
void on(OrderCreatedEvent event) {
inventoryService.decreaseStock(event.productCode(), event.quantity());
}
Las transacciones ahora son independientes. Si inventory falla, la orden quedó guardada. Mejor. Pero hay un problema nuevo.
El problema del evento perdido¶
BEGIN TRANSACTION (orders)
1. OrderService guarda la orden
2. El evento queda en memoria RAM
COMMIT ✅
← el servidor se reinicia aquí por cualquier razón
→ El evento nunca llega a inventory
→ La orden existe, el stock no se descontó
→ Nadie sabe. Sin logs de error. Sin alertas.
Evolución 3: @ApplicationModuleListener + Event Publication Registry¶
Spring Modulith guarda el evento en la base de datos dentro de la misma transacción que lo publicó. Si la app cae después del commit, el evento sigue en la BD y se reintenta al reiniciar.
Agregar dependencias al pom.xml¶
<!-- Event Publication Registry: persiste los eventos en BD (Outbox Pattern) -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
<!-- Externalización de eventos a RabbitMQ -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-amqp</artifactId>
</dependency>
Configurar application.properties¶
# Crea la tabla event_publication automáticamente al arrancar
spring.modulith.events.jdbc.schema-initialization.enabled=true
# "delete" → borra el registro cuando el handler completa exitosamente
# "archive" → lo mueve a event_publication_archive (útil para auditoría)
spring.modulith.events.completion-mode=delete
# Si la app reinicia con eventos pendientes, los republica automáticamente
spring.modulith.events.republish-outstanding-events-on-restart=true
El flujo con estas dependencias activas:
BEGIN TRANSACTION (orders)
1. OrderService guarda la orden en BD
2. Spring Modulith persiste el evento en event_publication
(misma transacción — si falla la orden, no hay evento)
COMMIT ✅
(el evento está en BD, garantizado)
(thread separado, transacción independiente)
BEGIN TRANSACTION (inventory)
3. @ApplicationModuleListener ejecuta on()
4. InventoryService descuenta stock
5. Spring Modulith marca el evento como completado
COMMIT ✅
Comparación de los tres mecanismos¶
| Mecanismo | ¿Cuándo ejecuta? | ¿Transacción propia? | ¿Resistente a caídas? |
|---|---|---|---|
@EventListener |
Dentro del publish | Misma que el publisher | ❌ No |
@TransactionalEventListener |
Después del commit | Nueva transacción | ❌ No (RAM) |
@ApplicationModuleListener |
Después del commit | Nueva transacción | ✅ Sí (BD) |
Para comunicación entre módulos en producción: siempre @ApplicationModuleListener.
Paso 1: Refactorizar a órdenes multi-ítem¶
El diseño del starter tiene un solo producto por orden. Una orden real puede tener varios. Hacemos el refactor ahora, antes de implementar los eventos, para que todo quede alineado.
orders/domain/OrderItemEntity.java — nueva entidad¶
Cada ítem de la orden vive en su propia tabla. El precio se guarda como snapshot — el precio que el cliente pagó en ese momento, independientemente de cambios futuros en el catálogo.
package com.geovannycode.bookstore.orders.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
/**
* Un ítem dentro de una orden: qué producto, a qué precio y cuántas unidades.
*
* El precio es un snapshot del momento de la compra.
* Si el precio del catálogo cambia mañana, las órdenes antiguas
* conservan el precio que el cliente efectivamente pagó.
*/
@Entity
@Table(name = "order_items")
public class OrderItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_item_seq")
@SequenceGenerator(
name = "order_item_seq",
sequenceName = "order_item_id_seq",
allocationSize = 50
)
private Long id;
@Column(nullable = false)
private String productCode;
@Column(nullable = false)
private String productName;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal productPrice;
@Column(nullable = false)
private int quantity;
protected OrderItemEntity() {}
public OrderItemEntity(String productCode, String productName,
BigDecimal productPrice, int quantity) {
this.productCode = productCode;
this.productName = productName;
this.productPrice = productPrice;
this.quantity = quantity;
}
public Long getId() { return id; }
public String getProductCode() { return productCode; }
public String getProductName() { return productName; }
public BigDecimal getProductPrice() { return productPrice; }
public int getQuantity() { return quantity; }
}
orders/domain/OrderEntity.java — actualizar¶
Los campos productCode, productName, productPrice, quantity salen de OrderEntity. En su lugar entra la relación @OneToMany a OrderItemEntity.
package com.geovannycode.bookstore.orders.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_id_seq", allocationSize = 50)
private Long id;
@Column(nullable = false, unique = true)
private String orderNumber;
@Column(nullable = false)
private String customerName;
@Column(nullable = false)
private String customerEmail;
@Column(nullable = false)
private String customerPhone;
@Column(nullable = false)
private String deliveryAddress;
/**
* Los ítems de la orden.
*
* CascadeType.ALL: al guardar la orden, los ítems se guardan automáticamente.
* orphanRemoval: si se elimina un ítem de la lista, se borra de BD.
* @JoinColumn: la tabla order_items tiene la FK order_id que apunta aquí.
*/
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id", nullable = false)
private List<OrderItemEntity> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@Column(nullable = false, updatable = false)
private Instant createdAt;
private Instant updatedAt;
@PrePersist
void onPrePersist() {
this.createdAt = Instant.now();
if (this.status == null) this.status = OrderStatus.NEW;
}
@PreUpdate
void onPreUpdate() {
this.updatedAt = Instant.now();
}
protected OrderEntity() {}
public OrderEntity(String orderNumber, String customerName, String customerEmail,
String customerPhone, String deliveryAddress,
List<OrderItemEntity> items) {
this.orderNumber = orderNumber;
this.customerName = customerName;
this.customerEmail = customerEmail;
this.customerPhone = customerPhone;
this.deliveryAddress = deliveryAddress;
this.items.addAll(items);
this.status = OrderStatus.NEW;
}
public Long getId() { return id; }
public String getOrderNumber() { return orderNumber; }
public String getCustomerName() { return customerName; }
public String getCustomerEmail() { return customerEmail; }
public String getCustomerPhone() { return customerPhone; }
public String getDeliveryAddress() { return deliveryAddress; }
public List<OrderItemEntity> getItems() { return List.copyOf(items); }
public OrderStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setStatus(OrderStatus status) { this.status = status; }
}
orders/domain/CreateOrderRequest.java — actualizar¶
package com.geovannycode.bookstore.orders.domain;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/**
* Request para crear una orden con múltiples ítems.
*
* Los datos del cliente van en el nivel raíz.
* Cada ítem especifica qué producto y cuántas unidades.
*/
public record CreateOrderRequest(
@NotBlank(message = "El nombre del cliente es obligatorio")
String customerName,
@NotBlank(message = "El email es obligatorio")
@Email(message = "El email no tiene un formato válido")
String customerEmail,
@NotBlank(message = "El teléfono es obligatorio")
String customerPhone,
@NotBlank(message = "La dirección de entrega es obligatoria")
String deliveryAddress,
@NotEmpty(message = "La orden debe tener al menos un ítem")
@Valid
List<Item> items
) {
/**
* Qué producto y cuántas unidades.
* La validación de que el producto existe ocurre en OrderService
* al consultar CatalogApi — no aquí.
*/
public record Item(
@NotBlank(message = "El código del producto es obligatorio")
String productCode,
@Min(value = 1, message = "La cantidad mínima es 1")
@Max(value = 100, message = "La cantidad máxima por ítem es 100")
int quantity
) {}
}
Paso 2: Actualizar OrderCreatedEvent¶
El evento ahora lleva una lista de ítems y un Customer embebido. @Externalized indica a Spring Modulith que este evento debe publicarse también en RabbitMQ además de entregarse internamente a inventory.
// orders/OrderCreatedEvent.java
package com.geovannycode.bookstore.orders;
import org.springframework.modulith.events.Externalized;
import java.math.BigDecimal;
import java.util.List;
/**
* Evento publicado cuando una orden es creada.
*
* Está en la raíz del módulo orders para que inventory
* (y cualquier otro módulo) pueda importarlo.
*
* @Externalized le dice a Spring Modulith:
* "publica este evento en RabbitMQ además de entregarlo internamente".
* El formato es "exchangeName::routingKey".
*
* Customer e Item van embebidos para que los consumidores externos
* tengan toda la información sin necesitar consultas adicionales.
*/
@Externalized("bookstore.orders::order.created")
public record OrderCreatedEvent(
String orderNumber,
List<Item> items,
Customer customer
) {
public record Item(
String productCode,
String productName,
BigDecimal productPrice,
int quantity
) {}
public record Customer(
String name,
String email,
String phone,
String deliveryAddress
) {}
}
Paso 3: Actualizar OrderService¶
OrderService ahora itera la lista de ítems, valida cada producto contra CatalogApi y construye las listas de entidades y eventos en paralelo.
package com.geovannycode.bookstore.orders.domain;
import com.geovannycode.bookstore.catalog.CatalogApi;
import com.geovannycode.bookstore.catalog.Product;
import com.geovannycode.bookstore.orders.OrderCreatedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final OrderRepository orderRepository;
private final CatalogApi catalogApi;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository,
CatalogApi catalogApi,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.catalogApi = catalogApi;
this.eventPublisher = eventPublisher;
}
public CreateOrderResponse create(CreateOrderRequest request) {
// Validar cada producto en el catálogo y construir los ítems
List<OrderItemEntity> orderItems = new ArrayList<>();
List<OrderCreatedEvent.Item> eventItems = new ArrayList<>();
for (CreateOrderRequest.Item item : request.items()) {
Product product = catalogApi.getByCode(item.productCode())
.orElseThrow(() -> new InvalidOrderException(
"Producto no encontrado: " + item.productCode()));
orderItems.add(new OrderItemEntity(
product.code(), product.name(),
product.price(), item.quantity()
));
eventItems.add(new OrderCreatedEvent.Item(
product.code(), product.name(),
product.price(), item.quantity()
));
}
var orderNumber = "ORD-" + UUID.randomUUID()
.toString().substring(0, 8).toUpperCase();
var order = new OrderEntity(
orderNumber,
request.customerName(), request.customerEmail(),
request.customerPhone(), request.deliveryAddress(),
orderItems
);
var saved = orderRepository.save(order);
log.info("Orden creada: orderNumber={}, items={}",
orderNumber, orderItems.size());
// Spring Modulith persiste este evento en event_publication
// dentro de esta transacción. Si falla el commit, no hay evento.
// Si la app cae después del commit, el evento queda en BD
// y se reintenta al reiniciar.
eventPublisher.publishEvent(new OrderCreatedEvent(
saved.getOrderNumber(),
eventItems,
new OrderCreatedEvent.Customer(
request.customerName(), request.customerEmail(),
request.customerPhone(), request.deliveryAddress()
)
));
return new CreateOrderResponse(orderNumber);
}
@Transactional(readOnly = true)
public OrderEntity getByOrderNumber(String orderNumber) {
return orderRepository.findByOrderNumber(orderNumber)
.orElseThrow(() -> new OrderNotFoundException(orderNumber));
}
}
Paso 4: Actualizar OrderEventsInventoryHandler¶
El handler ahora itera la lista de ítems del evento para descontar stock de cada producto:
package com.geovannycode.bookstore.inventory;
import com.geovannycode.bookstore.orders.OrderCreatedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
/**
* Descuenta stock cuando se crea una orden.
*
* inventory no sabe nada de cómo funciona orders internamente.
* Solo conoce el evento público OrderCreatedEvent.
*
* @ApplicationModuleListener garantiza:
* - Se ejecuta DESPUÉS del commit de la transacción de orders
* - En su propia transacción independiente
* - Si falla, la orden ya está guardada — no hay rollback cruzado
* - Si la app cae antes de ejecutarse, Spring Modulith reintenta al reiniciar
*/
@Component
class OrderEventsInventoryHandler {
private static final Logger log =
LoggerFactory.getLogger(OrderEventsInventoryHandler.class);
private final InventoryService inventoryService;
OrderEventsInventoryHandler(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@ApplicationModuleListener
void on(OrderCreatedEvent event) {
for (OrderCreatedEvent.Item item : event.items()) {
log.info("Actualizando stock → order={}, product={}, qty={}",
event.orderNumber(), item.productCode(), item.quantity());
inventoryService.decreaseStock(item.productCode(), item.quantity());
}
}
}
Si @ApplicationModuleListener no resuelve en el IDE
Verifica que el import sea exactamente:
Si sigue sin resolver, usa las tres anotaciones equivalentes mientras se descarga la dependencia:@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
void on(OrderCreatedEvent event) { ... }
@ApplicationModuleListener es un alias de las tres juntas.
Paso 5: Configurar RabbitMQConfig¶
Para que el evento llegue a RabbitMQ necesitamos declarar dónde publicarlo y en qué formato.
El exchange recibe los mensajes y los distribuye. La queue es donde aterrizan. El routing key es la dirección que le dice al exchange a qué queue mandar cada mensaje.
OrderCreatedEvent
│
▼
exchange: "bookstore.orders" ← @Externalized("bookstore.orders::order.created")
│ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
│ routing key: "order.created" ↑↑↑↑↑↑↑↑↑↑↑↑↑
▼
queue: "bookstore.order.created"
JacksonJsonMessageConverter convierte el OrderCreatedEvent a JSON al publicarlo. Sin este bean, RabbitMQ recibiría bytes sin formato y los consumidores no podrían deserializar el mensaje.
package com.geovannycode.bookstore.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Bean
TopicExchange ordersExchange() {
// Coincide con la parte izquierda de @Externalized:
// @Externalized("bookstore.orders::order.created")
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
return new TopicExchange("bookstore.orders", true, false);
}
@Bean
Queue orderCreatedQueue() {
return new Queue("bookstore.order.created", true);
}
@Bean
Binding orderCreatedBinding(Queue orderCreatedQueue, TopicExchange ordersExchange) {
// Coincide con la parte derecha de @Externalized:
// @Externalized("bookstore.orders::order.created")
// ↑↑↑↑↑↑↑↑↑↑↑↑↑
return BindingBuilder
.bind(orderCreatedQueue)
.to(ordersExchange)
.with("order.created");
}
@Bean
JacksonJsonMessageConverter messageConverter() {
// Spring Modulith AMQP detecta este bean automáticamente
// y lo usa al externalizar eventos con @Externalized
return new JacksonJsonMessageConverter();
}
}
Paso 6: Migración Flyway V5¶
Esta migración crea la tabla order_items, migra los datos de las órdenes existentes y elimina las columnas de producto que ya no pertenecen a orders.
Crea src/main/resources/db/migration/V5__orders_multi_item.sql:
-- V5__orders_multi_item.sql
--
-- Antes: cada orden tenía un solo producto (product_code, product_name,
-- product_price, quantity directamente en la tabla orders).
--
-- Ahora: una orden puede tener N ítems. Los datos del producto se
-- mueven a la nueva tabla order_items.
-- ── Secuencia para order_items ──────────────────────────────────────
CREATE SEQUENCE order_item_id_seq START WITH 100 INCREMENT BY 50;
-- ── Nueva tabla de ítems ─────────────────────────────────────────────
CREATE TABLE order_items (
id BIGINT NOT NULL DEFAULT nextval('order_item_id_seq'),
order_id BIGINT NOT NULL,
product_code VARCHAR(50) NOT NULL,
product_name VARCHAR(255) NOT NULL,
product_price NUMERIC(10, 2) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
PRIMARY KEY (id),
CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
-- ── Migrar datos existentes de orders → order_items ──────────────────
INSERT INTO order_items (id, order_id, product_code, product_name, product_price, quantity)
SELECT nextval('order_item_id_seq'), id, product_code, product_name, product_price, quantity
FROM orders
WHERE product_code IS NOT NULL;
-- ── Eliminar columnas de producto de orders ──────────────────────────
ALTER TABLE orders DROP COLUMN product_code;
ALTER TABLE orders DROP COLUMN product_name;
ALTER TABLE orders DROP COLUMN product_price;
ALTER TABLE orders DROP COLUMN quantity;
DROP INDEX IF EXISTS idx_orders_product_code;
Paso 7: Prerequisito — crear productos en el catálogo¶
Los productos de la BD del starter no están en el catálogo CQRS
El starter cargó los productos directamente en la tabla public.products (schema público). Ahora el módulo catalog tiene sus propias tablas catalog.products y catalog.product_views — y es ahí donde CatalogApi.getByCode() busca.
Antes de crear una orden, tienes que crear el producto en el catálogo nuevo. Si intentas crear una orden con P001 sin haber creado el producto primero, recibirás:
Crea primero el producto:
POST http://localhost:8080/api/catalog/products
Content-Type: application/json
{
"code": "P001",
"name": "Clean Code",
"description": "Un manual para crear software ágil y de calidad.",
"price": 45.99,
"category": "Ingeniería de Software"
}
Y si quieres probar multi-ítem, crea también P003:
POST http://localhost:8080/api/catalog/products
Content-Type: application/json
{
"code": "P003",
"name": "Designing Data-Intensive Applications",
"description": "Principios y paradigmas para sistemas de datos a escala.",
"price": 59.99,
"category": "Sistemas Distribuidos"
}
Paso 8: Demo completa¶
Levanta la aplicación:
Crear la orden (multi-ítem)¶
POST http://localhost:8080/api/orders
Content-Type: application/json
{
"customerName": "Geovanny Mendoza",
"customerEmail": "geo@barranquillajug.com",
"customerPhone": "+57 300 1234567",
"deliveryAddress": "Calle 72 #45-10, Barranquilla",
"items": [
{ "productCode": "P001", "quantity": 2 },
{ "productCode": "P003", "quantity": 1 }
]
}
Respuesta:
Anota el orderNumber — lo usas en las verificaciones.
Observar los logs¶
INFO OrderService : Orden creada: orderNumber=ORD-5A3133F2, items=2
INFO OrderEventsInventoryHandler: Actualizando stock → order=ORD-5A3133F2, product=P001, qty=2
INFO InventoryService : Stock disminuido: product=P001, quantity=2, remaining=596
INFO OrderEventsInventoryHandler: Actualizando stock → order=ORD-5A3133F2, product=P003, qty=1
INFO InventoryService : Stock disminuido: product=P003, quantity=1, remaining=298
Los logs del handler aparecen después del de OrderService — confirma que @ApplicationModuleListener ejecuta post-commit.
Verificar en PostgreSQL¶
Conéctate a la base de datos:
Reemplaza ORD-5A3133F2 con tu orderNumber real:
-- La orden existe
SELECT order_number, status, customer_name
FROM orders
WHERE order_number = 'ORD-5A3133F2';
-- Los ítems de la orden (la tabla orders ya no tiene product_code ni quantity)
SELECT oi.product_code, oi.product_name, oi.product_price, oi.quantity
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.order_number = 'ORD-5A3133F2';
-- El stock se descontó para cada producto
SELECT product_code, stock_level
FROM stock
WHERE product_code IN ('P001', 'P003');
-- P001: 596 (estaba 598, restamos 2)
-- P003: 298 (estaba 299, restamos 1)
-- Event Publication Registry — debe estar vacío si completion-mode=delete
SELECT * FROM event_publication;
-- 0 rows = los eventos se entregaron y se borraron exitosamente
Si ves filas con completion_date = NULL
El handler no terminó de procesar todavía. Espera 2-3 segundos y vuelve a consultar. Si persiste, revisa los logs de la app para ver si OrderEventsInventoryHandler lanzó una excepción.
Verificar en RabbitMQ¶
1. Abre http://localhost:15672 con guest / guest
2. Ve a Queues and Streams → haz clic en bookstore.order.created
3. Baja hasta la sección Get messages → haz clic para expandirla
4. Deja el campo Count en 1 → haz clic en Get Message(s)
Verás el mensaje con el OrderCreatedEvent serializado como JSON:
{
"orderNumber": "ORD-5A3133F2",
"items": [
{
"productCode": "P001",
"productName": "Clean Code",
"productPrice": 45.99,
"quantity": 2
},
{
"productCode": "P003",
"productName": "Designing Data-Intensive Applications",
"productPrice": 59.99,
"quantity": 1
}
],
"customer": {
"name": "Geovanny Mendoza",
"email": "geo@barranquillajug.com",
"phone": "+57 300 1234567",
"deliveryAddress": "Calle 72 #45-10, Barranquilla"
}
}
El mensaje vuelve a la cola después de verlo
Al leer desde el admin de RabbitMQ el mensaje se reencola (modo nack por defecto). En producción un consumer real lo consumiría y confirmaría. En el workshop no tenemos consumer externo — solo verificamos que el mensaje llegó.
Diagrama del flujo completo¶
POST /api/orders
│
▼
OrderService.create()
│
├─→ order_items (BD) ─────────────────────────────┐
├─→ orders (BD) ─────────────────────────────┤
└─→ event_publication (BD) ─────────── misma transacción
│
COMMIT
│
┌────────────────────────────────┤
│ │
┌──────────▼──────────┐ ┌─────────────▼────────────┐
│ inventory │ │ RabbitMQ │
│ │ │ │
│ itera event.items()│ │ bookstore.orders │
│ decreaseStock() │ │ → bookstore.order.created│
└─────────────────────┘ └───────────────────────────┘
@ApplicationModuleListener @Externalized
Checklist de la Parte 4¶
- [ ]
spring-modulith-starter-jdbcyspring-modulith-events-amqpagregados alpom.xml - [ ]
application.propertiescon Event Publication Registry configurado - [ ]
OrderItemEntity.javacreado enorders/domain/ - [ ]
OrderEntityactualizado — quitados campos de producto, agregado@OneToMany items - [ ]
CreateOrderRequestactualizado —List<Item> itemscon sub-record - [ ]
OrderCreatedEventactualizado —@Externalized,List<Item>,Customerinner record - [ ]
OrderServiceactualizado — itera ítems, valida cada producto, construye listas - [ ]
OrderEventsInventoryHandlercon@ApplicationModuleListener, logger e iteración de ítems - [ ]
RabbitMQConfigconJacksonJsonMessageConverter - [ ] V5 de Flyway creada — tabla
order_items, migración de datos, columnas eliminadas deorders - [ ] Productos creados en el catálogo antes de crear la orden ✅
- [ ] POST a
/api/ordersconitems[]retornaorderNumber✅ - [ ] Logs muestran handler ejecutando post-commit por cada ítem ✅
- [ ] SQL de verificación en
order_itemsystockfunciona ✅ - [ ] RabbitMQ admin muestra mensaje con la lista de ítems ✅
- [ ]
ModularityTestsigue pasando ✅
Anterior: Parte 3 — CQRS en Catalog Siguiente: Parte 5 — Testing en Aislamiento