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());
    }

}
@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();
        });
    }

}