Spring Modulith: Event Publication Registry with PostgreSQL
In monolithic architectures, ensuring event delivery across module boundaries without tight @Bean coupling or data loss is essential. In this tutorial, we will explore how to implement resilient, asynchronous event publication using Spring Modulith and its Event Publication Registry backed by PostgreSQL.
Background
When an application publishes domain events in-memory, failures in asynchronous listeners or unhandled exceptions can result in permanent event loss. The Transactional Outbox pattern solves this by persisting events into the database as part of the primary business transaction.
Spring Modulith provides an out-of-the-box Event Publication Registry that intercepts domain events published within a @Transactional boundary and records them in the event_publication table. Downstream listeners marked with @ApplicationModuleListener consume these events asynchronously and update their completion status in the registry.
Aggregate & Domain Event
Let’s define our shared domain event record in OrderPlacedEvent:
public record OrderPlacedEvent(
UUID orderId,
String customerEmail,
BigDecimal totalAmount,
Instant timestamp
) {
}
Next, we define our Order entity in Order:
@Table("orders")
class Order {
@Id
private UUID id;
private final String customerEmail;
private final BigDecimal totalAmount;
private OrderStatus status;
Order(String customerEmail, BigDecimal totalAmount, OrderStatus status) {
this.customerEmail = customerEmail;
this.totalAmount = totalAmount;
this.status = status;
}
}
Event Publishing Service
In OrderService, we publish OrderPlacedEvent within a transactional method:
@Service
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
public OrderService(OrderRepository repository, ApplicationEventPublisher events) {
this.repository = repository;
this.events = events;
}
@Transactional
public Order placeOrder(String customerEmail, BigDecimal totalAmount) {
var order = repository.save(new Order(customerEmail, totalAmount, Order.OrderStatus.CREATED));
events.publishEvent(new OrderPlacedEvent(
order.getId(),
order.getCustomerEmail(),
order.getTotalAmount(),
Instant.now()
));
return order;
}
}
Asynchronous Listeners
Downstream modules use @ApplicationModuleListener to consume the event asynchronously in an independent transaction.
@Component
class InventoryListener {
private static final Logger log = LoggerFactory.getLogger(InventoryListener.class);
@ApplicationModuleListener
void on(OrderPlacedEvent event) {
log.info("Reserved inventory for order: {}", event.orderId());
}
}
And in NotificationListener:
@Component
class NotificationListener {
private static final Logger log = LoggerFactory.getLogger(NotificationListener.class);
@ApplicationModuleListener
void on(OrderPlacedEvent event) {
log.info("Sent order confirmation email to: {} for order: {}", event.customerEmail(), event.orderId());
}
}
Verification
We will verify both architectural compliance and event publication behavior using @Testcontainers and PostgreSQL.
Architectural Boundaries
In ModulithEventsApplicationTests, we verify module boundaries using ApplicationModules:
class ModulithEventsApplicationTests {
private final ApplicationModules modules = ApplicationModules.of(ModulithEventsApplication.class);
@Test
@DisplayName("Verify modular architecture boundaries and rules")
void verifyModularity() {
modules.verify();
}
@Test
@DisplayName("Generate module documentation")
void renderDocumentation() {
new Documenter(modules).writeDocumentation();
}
}
Outbox Event Completion
In OrderEventPublicationTests, we verify that publishing an order event automatically registers and completes the outbox publication:
@Testcontainers
@SpringBootTest(classes = ModulithEventsApplication.class)
class OrderEventPublicationTests {
@Container
@ServiceConnection
private static final PostgreSQLContainer postgres = new PostgreSQLContainer(DockerImageName.parse("postgres:latest"));
@Autowired
private OrderService orderService;
@Autowired
private EventPublicationRepository publicationRepository;
@Test
@DisplayName("When an order is placed Then domain event is published and completed in the outbox registry")
void placeOrder() {
var order = orderService.placeOrder("[email protected]", BigDecimal.valueOf(99.90));
assertThat(order).isNotNull();
assertThat(order.getId()).isNotNull();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
var incompletePublications = publicationRepository.findIncompletePublications();
assertThat(incompletePublications).isEmpty();
});
}
}