diff --git a/README.adoc b/README.adoc index b00c1585..013f9b77 100644 --- a/README.adoc +++ b/README.adoc @@ -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 -|=== +|=== \ No newline at end of file diff --git a/modulith-events/.gitattributes b/modulith-events/.gitattributes new file mode 100644 index 00000000..55d79e42 --- /dev/null +++ b/modulith-events/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary \ No newline at end of file diff --git a/modulith-events/.gitignore b/modulith-events/.gitignore new file mode 100644 index 00000000..e48b6be6 --- /dev/null +++ b/modulith-events/.gitignore @@ -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/ \ No newline at end of file diff --git a/modulith-events/README.adoc b/modulith-events/README.adoc new file mode 100644 index 00000000..97a5687b --- /dev/null +++ b/modulith-events/README.adoc @@ -0,0 +1,193 @@ += Spring Modulith: Event Publication Registry with PostgreSQL +Rashidi Zin +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(); + }); + } + +} +---- \ No newline at end of file diff --git a/modulith-events/build.gradle.kts b/modulith-events/build.gradle.kts new file mode 100644 index 00000000..320b427c --- /dev/null +++ b/modulith-events/build.gradle.kts @@ -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") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/modulith-events/settings.gradle.kts b/modulith-events/settings.gradle.kts new file mode 100644 index 00000000..9c2d558a --- /dev/null +++ b/modulith-events/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "modulith-events" \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/ModulithEventsApplication.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/ModulithEventsApplication.java new file mode 100644 index 00000000..9f81b222 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/ModulithEventsApplication.java @@ -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); + } + +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/common/OrderPlacedEvent.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/common/OrderPlacedEvent.java new file mode 100644 index 00000000..4cd511b2 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/common/OrderPlacedEvent.java @@ -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 +) { +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/inventory/InventoryListener.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/inventory/InventoryListener.java new file mode 100644 index 00000000..5ee411af --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/inventory/InventoryListener.java @@ -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()); + } + +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/notification/NotificationListener.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/notification/NotificationListener.java new file mode 100644 index 00000000..9ca08631 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/notification/NotificationListener.java @@ -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()); + } + +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/Order.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/Order.java new file mode 100644 index 00000000..44279550 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/Order.java @@ -0,0 +1,51 @@ +package zin.rashidi.boot.modulith.events.order; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; + +/** + * @author Rashidi Zin + */ +@Table("orders") +class Order { + + @Id + private Long 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; + } + + public Long getId() { + return id; + } + + public String getCustomerEmail() { + return customerEmail; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public OrderStatus getStatus() { + return status; + } + + public void setStatus(OrderStatus status) { + this.status = status; + } + + enum OrderStatus { + CREATED, COMPLETED, CANCELLED + } + +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderRepository.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderRepository.java new file mode 100644 index 00000000..12f0ab58 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderRepository.java @@ -0,0 +1,9 @@ +package zin.rashidi.boot.modulith.events.order; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Rashidi Zin + */ +interface OrderRepository extends CrudRepository { +} \ No newline at end of file diff --git a/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderService.java b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderService.java new file mode 100644 index 00000000..96de188c --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderService.java @@ -0,0 +1,39 @@ +package zin.rashidi.boot.modulith.events.order; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import zin.rashidi.boot.modulith.events.common.OrderPlacedEvent; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * @author Rashidi Zin + */ +@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; + } + +} \ No newline at end of file diff --git a/modulith-events/src/main/resources/application.properties b/modulith-events/src/main/resources/application.properties new file mode 100644 index 00000000..be32f5d1 --- /dev/null +++ b/modulith-events/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=modulith-events +spring.modulith.events.jdbc.schema-initialization.enabled=true \ No newline at end of file diff --git a/modulith-events/src/main/resources/schema.sql b/modulith-events/src/main/resources/schema.sql new file mode 100644 index 00000000..86a15840 --- /dev/null +++ b/modulith-events/src/main/resources/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_email VARCHAR(255) NOT NULL, + total_amount NUMERIC(19, 2) NOT NULL, + status VARCHAR(50) NOT NULL +); \ No newline at end of file diff --git a/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java new file mode 100644 index 00000000..a39f7f8a --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java @@ -0,0 +1,31 @@ +package zin.rashidi.boot.modulith.events; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +import static org.springframework.modulith.docs.Documenter.Options.defaults; + +/** + * @author Rashidi Zin + */ +class ModulithEventsApplicationTests { + + private final ApplicationModules modules = ApplicationModules.of(ModulithEventsApplication.class); + + @Test + @DisplayName("Verify architecture") + void verify() { + modules.verify(); + } + + @Test + @DisplayName("Generate documentation") + void document() { + new Documenter(modules, defaults().withOutputFolder("docs")) + .writeModulesAsPlantUml() + .writeDocumentation(Documenter.DiagramOptions.defaults(), Documenter.CanvasOptions.defaults().revealInternals()); + } + +} \ No newline at end of file diff --git a/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestModulithEventsApplication.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestModulithEventsApplication.java new file mode 100644 index 00000000..2bbe367b --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestModulithEventsApplication.java @@ -0,0 +1,14 @@ +package zin.rashidi.boot.modulith.events; + +import org.springframework.boot.SpringApplication; + +/** + * @author Rashidi Zin + */ +public class TestModulithEventsApplication { + + public static void main(String[] args) { + SpringApplication.from(ModulithEventsApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} \ No newline at end of file diff --git a/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java new file mode 100644 index 00000000..18bea499 --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java @@ -0,0 +1,22 @@ +package zin.rashidi.boot.modulith.events; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * @author Rashidi Zin + */ +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")) + .withInitScripts("schema.sql"); + } + +} \ No newline at end of file diff --git a/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/order/OrderEventPublicationTests.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/order/OrderEventPublicationTests.java new file mode 100644 index 00000000..7bd02c79 --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/order/OrderEventPublicationTests.java @@ -0,0 +1,44 @@ +package zin.rashidi.boot.modulith.events.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.modulith.events.core.EventPublicationRepository; +import zin.rashidi.boot.modulith.events.TestcontainersConfiguration; + +import java.math.BigDecimal; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * @author Rashidi Zin + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest +class OrderEventPublicationTests { + + @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(); + }); + } + +} \ No newline at end of file diff --git a/modulith-events/src/test/resources/schema.sql b/modulith-events/src/test/resources/schema.sql new file mode 100644 index 00000000..6f984a8f --- /dev/null +++ b/modulith-events/src/test/resources/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS orders ( + id BIGSERIAL PRIMARY KEY, + customer_email VARCHAR(255) NOT NULL, + total_amount NUMERIC(19, 2) NOT NULL, + status VARCHAR(50) NOT NULL +); \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b2b64980..05623309 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,8 +23,9 @@ include("data-rest-validation") include("graphql") include("jooq") include("modulith") +include("modulith-events") include("test-execution-listeners") include("test-rest-assured") include("test-slice-tests-rest") include("web-rest-client") -include("web-thymeleaf-xss") +include("web-thymeleaf-xss") \ No newline at end of file