From c5523fa3286bfab1672d84e8b90871e509a1f9fd Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sat, 29 Aug 2026 11:02:40 +0800 Subject: [PATCH 1/4] feat(modulith): implement transactional event publication with postgresql outbox --- README.adoc | 3 +- docs/modules/ROOT/pages/modulith-events.adoc | 187 ++++++++++++++++++ modulith-events/.gitattributes | 3 + modulith-events/.gitignore | 37 ++++ modulith-events/README.adoc | 187 ++++++++++++++++++ modulith-events/build.gradle.kts | 46 +++++ modulith-events/settings.gradle.kts | 1 + .../events/ModulithEventsApplication.java | 21 ++ .../events/common/OrderPlacedEvent.java | 16 ++ .../events/inventory/InventoryListener.java | 22 +++ .../notification/NotificationListener.java | 22 +++ .../boot/modulith/events/order/Order.java | 52 +++++ .../events/order/OrderRepository.java | 11 ++ .../modulith/events/order/OrderService.java | 39 ++++ .../src/main/resources/application.properties | 1 + modulith-events/src/main/resources/schema.sql | 6 + .../ModulithEventsApplicationTests.java | 29 +++ .../events/TestModulithEventsApplication.java | 14 ++ .../events/TestcontainersConfiguration.java | 21 ++ .../order/OrderEventPublicationTests.java | 52 +++++ settings.gradle.kts | 3 +- 21 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 docs/modules/ROOT/pages/modulith-events.adoc create mode 100644 modulith-events/.gitattributes create mode 100644 modulith-events/.gitignore create mode 100644 modulith-events/README.adoc create mode 100644 modulith-events/build.gradle.kts create mode 100644 modulith-events/settings.gradle.kts create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/ModulithEventsApplication.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/common/OrderPlacedEvent.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/inventory/InventoryListener.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/notification/NotificationListener.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/Order.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderRepository.java create mode 100644 modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderService.java create mode 100644 modulith-events/src/main/resources/application.properties create mode 100644 modulith-events/src/main/resources/schema.sql create mode 100644 modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java create mode 100644 modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestModulithEventsApplication.java create mode 100644 modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java create mode 100644 modulith-events/src/test/java/zin/rashidi/boot/modulith/events/order/OrderEventPublicationTests.java 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/docs/modules/ROOT/pages/modulith-events.adoc b/docs/modules/ROOT/pages/modulith-events.adoc new file mode 100644 index 00000000..3c210bc7 --- /dev/null +++ b/docs/modules/ROOT/pages/modulith-events.adoc @@ -0,0 +1,187 @@ += 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(); + } + +} +---- + +=== 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/.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..3c210bc7 --- /dev/null +++ b/modulith-events/README.adoc @@ -0,0 +1,187 @@ += 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(); + } + +} +---- + +=== 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..fce02bbd --- /dev/null +++ b/modulith-events/build.gradle.kts @@ -0,0 +1,46 @@ +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() +} + +extra["springModulithVersion"] = "2.1.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-jdbc") + 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") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.modulith:spring-modulith-bom:${property("springModulithVersion")}") + } +} + +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..6c54c712 --- /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.Modulithic; + +/** + * @author Rashidi Zin + */ +@Modulithic( + 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..5d7c8bee --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/common/OrderPlacedEvent.java @@ -0,0 +1,16 @@ +package zin.rashidi.boot.modulith.events.common; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * @author Rashidi Zin + */ +public record OrderPlacedEvent( + UUID 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..30f79c3a --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/Order.java @@ -0,0 +1,52 @@ +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; +import java.util.UUID; + +/** + * @author Rashidi Zin + */ +@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; + } + + public UUID 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..e8d978d6 --- /dev/null +++ b/modulith-events/src/main/java/zin/rashidi/boot/modulith/events/order/OrderRepository.java @@ -0,0 +1,11 @@ +package zin.rashidi.boot.modulith.events.order; + +import org.springframework.data.repository.CrudRepository; + +import java.util.UUID; + +/** + * @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..5983ac6d --- /dev/null +++ b/modulith-events/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=modulith-events \ 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..263a55a1 --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java @@ -0,0 +1,29 @@ +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; + +/** + * @author Rashidi Zin + */ +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() + .writeIndividualFilesAsPlantUml(); + } + +} \ 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..4bb76b41 --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java @@ -0,0 +1,21 @@ +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) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + } + +} \ 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..eca10162 --- /dev/null +++ b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/order/OrderEventPublicationTests.java @@ -0,0 +1,52 @@ +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.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.modulith.events.core.EventPublicationRepository; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; +import zin.rashidi.boot.modulith.events.ModulithEventsApplication; + +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 + */ +@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/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 From 8644c820574daa91ed65049a2db1683ce04f2701 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sat, 29 Aug 2026 11:07:10 +0800 Subject: [PATCH 2/4] fix(modulith): use writeDocumentation() and non-deprecated PostgreSQLContainer --- docs/modules/ROOT/pages/modulith-events.adoc | 6 ++++++ modulith-events/README.adoc | 6 ++++++ .../modulith/events/ModulithEventsApplicationTests.java | 4 +--- .../boot/modulith/events/TestcontainersConfiguration.java | 6 +++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/modules/ROOT/pages/modulith-events.adoc b/docs/modules/ROOT/pages/modulith-events.adoc index 3c210bc7..97a5687b 100644 --- a/docs/modules/ROOT/pages/modulith-events.adoc +++ b/docs/modules/ROOT/pages/modulith-events.adoc @@ -146,6 +146,12 @@ class ModulithEventsApplicationTests { modules.verify(); } + @Test + @DisplayName("Generate module documentation") + void renderDocumentation() { + new Documenter(modules).writeDocumentation(); + } + } ---- diff --git a/modulith-events/README.adoc b/modulith-events/README.adoc index 3c210bc7..97a5687b 100644 --- a/modulith-events/README.adoc +++ b/modulith-events/README.adoc @@ -146,6 +146,12 @@ class ModulithEventsApplicationTests { modules.verify(); } + @Test + @DisplayName("Generate module documentation") + void renderDocumentation() { + new Documenter(modules).writeDocumentation(); + } + } ---- 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 index 263a55a1..8c462ed4 100644 --- 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 @@ -21,9 +21,7 @@ void verifyModularity() { @Test @DisplayName("Generate module documentation") void renderDocumentation() { - new Documenter(modules) - .writeDocumentation() - .writeIndividualFilesAsPlantUml(); + new Documenter(modules).writeDocumentation(); } } \ 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 index 4bb76b41..9fb5b49c 100644 --- 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 @@ -3,7 +3,7 @@ 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.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; /** @@ -14,8 +14,8 @@ class TestcontainersConfiguration { @Bean @ServiceConnection - PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer(DockerImageName.parse("postgres:latest")); } } \ No newline at end of file From ac958a2dbeb60d0b4f4ce45546d5648d04773b36 Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sat, 29 Aug 2026 11:14:02 +0800 Subject: [PATCH 3/4] fix(modulith): align structure and test configuration with spring modulith conventions --- modulith-events/build.gradle.kts | 11 +++-------- .../events/ModulithEventsApplication.java | 4 ++-- .../modulith/events/common/OrderPlacedEvent.java | 3 +-- .../boot/modulith/events/order/Order.java | 5 ++--- .../modulith/events/order/OrderRepository.java | 4 +--- .../src/main/resources/application.properties | 3 ++- .../events/ModulithEventsApplicationTests.java | 14 +++++++++----- .../events/TestcontainersConfiguration.java | 9 +++++---- .../events/order/OrderEventPublicationTests.java | 16 ++++------------ modulith-events/src/test/resources/schema.sql | 6 ++++++ 10 files changed, 35 insertions(+), 40 deletions(-) create mode 100644 modulith-events/src/test/resources/schema.sql diff --git a/modulith-events/build.gradle.kts b/modulith-events/build.gradle.kts index fce02bbd..320b427c 100644 --- a/modulith-events/build.gradle.kts +++ b/modulith-events/build.gradle.kts @@ -17,10 +17,11 @@ repositories { mavenCentral() } -extra["springModulithVersion"] = "2.1.1" - 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") @@ -35,12 +36,6 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } -dependencyManagement { - imports { - mavenBom("org.springframework.modulith:spring-modulith-bom:${property("springModulithVersion")}") - } -} - tasks.named("test") { useJUnitPlatform() } \ 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 index 6c54c712..9f81b222 100644 --- 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 @@ -2,12 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.modulith.Modulithic; +import org.springframework.modulith.Modulith; /** * @author Rashidi Zin */ -@Modulithic( +@Modulith( sharedModules = "common", useFullyQualifiedModuleNames = false ) 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 index 5d7c8bee..4cd511b2 100644 --- 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 @@ -2,13 +2,12 @@ import java.math.BigDecimal; import java.time.Instant; -import java.util.UUID; /** * @author Rashidi Zin */ public record OrderPlacedEvent( - UUID orderId, + Long orderId, String customerEmail, BigDecimal totalAmount, Instant timestamp 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 index 30f79c3a..44279550 100644 --- 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 @@ -4,7 +4,6 @@ import org.springframework.data.relational.core.mapping.Table; import java.math.BigDecimal; -import java.util.UUID; /** * @author Rashidi Zin @@ -13,7 +12,7 @@ class Order { @Id - private UUID id; + private Long id; private final String customerEmail; private final BigDecimal totalAmount; @@ -25,7 +24,7 @@ class Order { this.status = status; } - public UUID getId() { + public Long getId() { return id; } 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 index e8d978d6..12f0ab58 100644 --- 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 @@ -2,10 +2,8 @@ import org.springframework.data.repository.CrudRepository; -import java.util.UUID; - /** * @author Rashidi Zin */ -interface OrderRepository extends CrudRepository { +interface OrderRepository extends CrudRepository { } \ No newline at end of file diff --git a/modulith-events/src/main/resources/application.properties b/modulith-events/src/main/resources/application.properties index 5983ac6d..be32f5d1 100644 --- a/modulith-events/src/main/resources/application.properties +++ b/modulith-events/src/main/resources/application.properties @@ -1 +1,2 @@ -spring.application.name=modulith-events \ No newline at end of file +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/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/ModulithEventsApplicationTests.java index 8c462ed4..a39f7f8a 100644 --- 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 @@ -5,6 +5,8 @@ import org.springframework.modulith.core.ApplicationModules; import org.springframework.modulith.docs.Documenter; +import static org.springframework.modulith.docs.Documenter.Options.defaults; + /** * @author Rashidi Zin */ @@ -13,15 +15,17 @@ class ModulithEventsApplicationTests { private final ApplicationModules modules = ApplicationModules.of(ModulithEventsApplication.class); @Test - @DisplayName("Verify modular architecture boundaries and rules") - void verifyModularity() { + @DisplayName("Verify architecture") + void verify() { modules.verify(); } @Test - @DisplayName("Generate module documentation") - void renderDocumentation() { - new Documenter(modules).writeDocumentation(); + @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/TestcontainersConfiguration.java b/modulith-events/src/test/java/zin/rashidi/boot/modulith/events/TestcontainersConfiguration.java index 9fb5b49c..18bea499 100644 --- 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 @@ -3,19 +3,20 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; -import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; /** * @author Rashidi Zin */ @TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration { +public class TestcontainersConfiguration { @Bean @ServiceConnection - PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer(DockerImageName.parse("postgres:latest")); + 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 index eca10162..7bd02c79 100644 --- 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 @@ -4,13 +4,9 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; import org.springframework.modulith.events.core.EventPublicationRepository; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; -import org.testcontainers.utility.DockerImageName; -import zin.rashidi.boot.modulith.events.ModulithEventsApplication; +import zin.rashidi.boot.modulith.events.TestcontainersConfiguration; import java.math.BigDecimal; import java.time.Duration; @@ -21,14 +17,10 @@ /** * @author Rashidi Zin */ -@Testcontainers -@SpringBootTest(classes = ModulithEventsApplication.class) +@Import(TestcontainersConfiguration.class) +@SpringBootTest class OrderEventPublicationTests { - @Container - @ServiceConnection - private static final PostgreSQLContainer postgres = new PostgreSQLContainer(DockerImageName.parse("postgres:latest")); - @Autowired private OrderService orderService; 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 From 922414f75e5504f9f963958e4cae1dc0365892ca Mon Sep 17 00:00:00 2001 From: Rashidi Zin Date: Sat, 29 Aug 2026 12:13:16 +0800 Subject: [PATCH 4/4] Remove antora --- docs/modules/ROOT/pages/modulith-events.adoc | 193 ------------------- 1 file changed, 193 deletions(-) delete mode 100644 docs/modules/ROOT/pages/modulith-events.adoc diff --git a/docs/modules/ROOT/pages/modulith-events.adoc b/docs/modules/ROOT/pages/modulith-events.adoc deleted file mode 100644 index 97a5687b..00000000 --- a/docs/modules/ROOT/pages/modulith-events.adoc +++ /dev/null @@ -1,193 +0,0 @@ -= 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