Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ All tutorials are documented in AsciiDoc format and published as an https://anto
|link:graphql[Spring GraphQL] |Getting started with Spring GraphQL
|link:jooq[jOOQ] |Typesafe SQL with jOOQ and Spring Boot
|link:modulith[Spring Modulith] |Building modular applications with Spring Modulith
|link:modulith-events[Spring Modulith: Event Publication Registry] |Implement resilient event publication with Spring Modulith and PostgreSQL Outbox
|link:test-execution-listeners[Spring Test: Custom TestExecutionListener] |Custom `TestExecutionListener` to manage test lifecycle
|link:test-rest-assured[Spring Test: REST Assured Integration] |Integration testing with REST Assured
|link:test-slice-tests-rest[Spring Test: Slice Tests for REST Application] |Implementing Slice Tests for REST application
|link:web-rest-client[Spring Web: Synchronous REST Clients] |Implement synchronous REST client using `RestClient`, `WebClient`, and `RestTemplate`
|link:web-thymeleaf-xss[Spring Web: Cross-Site Scripting (XSS) with Thymeleaf] |Prevent Cross-Site Scripting (XSS) with Thymeleaf
|===
|===
3 changes: 3 additions & 0 deletions modulith-events/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
37 changes: 37 additions & 0 deletions modulith-events/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
193 changes: 193 additions & 0 deletions modulith-events/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
= Spring Modulith: Event Publication Registry with PostgreSQL
Rashidi Zin <rashidi@zin.my>
1.0, August 29, 2026: Initial version
:toc:
:icons: font
:source-highlighter: highlight.js
:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/modulith-events
:source-main: {url-quickref}/src/main/java/zin/rashidi/boot/modulith/events
:source-test: {url-quickref}/src/test/java/zin/rashidi/boot/modulith/events

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 https://spring.io/projects/spring-modulith[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 link:{source-main}/common/OrderPlacedEvent.java[`OrderPlacedEvent`]:

[source,java]
----
public record OrderPlacedEvent(
UUID orderId,
String customerEmail,
BigDecimal totalAmount,
Instant timestamp
) {
}
----

Next, we define our `Order` entity in link:{source-main}/order/Order.java[`Order`]:

[source,java]
----
@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 link:{source-main}/order/OrderService.java[`OrderService`], we publish `OrderPlacedEvent` within a transactional method:

[source,java]
----
@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.

In link:{source-main}/inventory/InventoryListener.java[`InventoryListener`]:

[source,java]
----
@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 link:{source-main}/notification/NotificationListener.java[`NotificationListener`]:

[source,java]
----
@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 link:{source-test}/ModulithEventsApplicationTests.java[`ModulithEventsApplicationTests`], we verify module boundaries using `ApplicationModules`:

[source,java]
----
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 link:{source-test}/order/OrderEventPublicationTests.java[`OrderEventPublicationTests`], we verify that publishing an order event automatically registers and completes the outbox publication:

[source,java]
----
@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("rashidi@zin.my", BigDecimal.valueOf(99.90));

assertThat(order).isNotNull();
assertThat(order.getId()).isNotNull();

await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
var incompletePublications = publicationRepository.findIncompletePublications();
assertThat(incompletePublications).isEmpty();
});
}

}
----
41 changes: 41 additions & 0 deletions modulith-events/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}

group = "zin.rashidi.boot"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}

repositories {
mavenCentral()
}

dependencies {
implementation(platform("org.springframework.modulith:spring-modulith-bom:2.1.0"))

implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.modulith:spring-modulith-starter-core")
implementation("org.springframework.modulith:spring-modulith-starter-jdbc")
implementation("org.springframework.modulith:spring-modulith-events-jdbc")
runtimeOnly("org.postgresql:postgresql")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-data-jdbc-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.testcontainers:testcontainers-postgresql")
testImplementation("org.awaitility:awaitility")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.named<Test>("test") {
useJUnitPlatform()
}
1 change: 1 addition & 0 deletions modulith-events/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "modulith-events"
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package zin.rashidi.boot.modulith.events;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.Modulith;

/**
* @author Rashidi Zin
*/
@Modulith(
sharedModules = "common",
useFullyQualifiedModuleNames = false
)
@SpringBootApplication
public class ModulithEventsApplication {

public static void main(String[] args) {
SpringApplication.run(ModulithEventsApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package zin.rashidi.boot.modulith.events.common;

import java.math.BigDecimal;
import java.time.Instant;

/**
* @author Rashidi Zin
*/
public record OrderPlacedEvent(
Long orderId,
String customerEmail,
BigDecimal totalAmount,
Instant timestamp
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package zin.rashidi.boot.modulith.events.inventory;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import zin.rashidi.boot.modulith.events.common.OrderPlacedEvent;

/**
* @author Rashidi Zin
*/
@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());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package zin.rashidi.boot.modulith.events.notification;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import zin.rashidi.boot.modulith.events.common.OrderPlacedEvent;

/**
* @author Rashidi Zin
*/
@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());
}

}
Loading
Loading