From 866eceed0aec67631097f87ce6544bc791fa467d Mon Sep 17 00:00:00 2001 From: asmhatre Date: Sat, 3 Oct 2026 18:24:50 +0000 Subject: [PATCH] Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs --- .gitignore | 6 ++ order-fulfillment/README.md | 76 ++++++++++++++++ .../output/00-table-name-collision.txt | 20 +++++ .../output/01-verify-violation-failed.txt | 49 ++++++++++ order-fulfillment/output/02-verify-passed.txt | 17 ++++ .../03-cycle-via-public-api-still-failed.txt | 26 ++++++ .../04-event-flow-missing-dependency.txt | 45 ++++++++++ .../05-redundant-transactional-failure.txt | 20 +++++ .../output/06-event-flow-passed.txt | 19 ++++ .../output/07-generated-docs.txt | 90 +++++++++++++++++++ .../08-javap-listener-meta-annotations.txt | 30 +++++++ order-fulfillment/pom.xml | 89 ++++++++++++++++++ .../com/ankurm/modulithdemo/Application.java | 23 +++++ .../inventory/InventoryManagement.java | 50 +++++++++++ .../modulithdemo/inventory/StockReserved.java | 12 +++ .../inventory/internal/Stock.java | 41 +++++++++ .../inventory/internal/StockRepository.java | 15 ++++ .../inventory/internal/StockSeeder.java | 26 ++++++ .../notification/NotificationManagement.java | 42 +++++++++ .../notification/internal/Notification.java | 37 ++++++++ .../internal/NotificationRepository.java | 9 ++ .../modulithdemo/order/OrderController.java | 25 ++++++ .../modulithdemo/order/OrderManagement.java | 63 +++++++++++++ .../modulithdemo/order/OrderPlaced.java | 13 +++ .../modulithdemo/order/internal/Order.java | 61 +++++++++++++ .../order/internal/OrderRepository.java | 13 +++ .../shipping/ShipmentScheduled.java | 10 +++ .../shipping/ShippingManagement.java | 32 +++++++ .../shipping/internal/Shipment.java | 43 +++++++++ .../shipping/internal/ShipmentRepository.java | 6 ++ .../src/main/resources/application.properties | 27 ++++++ .../ankurm/modulithdemo/ModularityTests.java | 39 ++++++++ .../OrderFulfillmentIntegrationTests.java | 46 ++++++++++ pom.xml | 29 ++++++ 34 files changed, 1149 insertions(+) create mode 100644 .gitignore create mode 100644 order-fulfillment/README.md create mode 100644 order-fulfillment/output/00-table-name-collision.txt create mode 100644 order-fulfillment/output/01-verify-violation-failed.txt create mode 100644 order-fulfillment/output/02-verify-passed.txt create mode 100644 order-fulfillment/output/03-cycle-via-public-api-still-failed.txt create mode 100644 order-fulfillment/output/04-event-flow-missing-dependency.txt create mode 100644 order-fulfillment/output/05-redundant-transactional-failure.txt create mode 100644 order-fulfillment/output/06-event-flow-passed.txt create mode 100644 order-fulfillment/output/07-generated-docs.txt create mode 100644 order-fulfillment/output/08-javap-listener-meta-annotations.txt create mode 100644 order-fulfillment/pom.xml create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/Application.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/InventoryManagement.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/StockReserved.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/Stock.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockRepository.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockSeeder.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/NotificationManagement.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/Notification.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/NotificationRepository.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderController.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderManagement.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderPlaced.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/Order.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/OrderRepository.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShipmentScheduled.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShippingManagement.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/Shipment.java create mode 100644 order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/ShipmentRepository.java create mode 100644 order-fulfillment/src/main/resources/application.properties create mode 100644 order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java create mode 100644 order-fulfillment/src/test/java/com/ankurm/modulithdemo/order/OrderFulfillmentIntegrationTests.java create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8124b0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.class +.idea/ +*.iml +.DS_Store +post/ diff --git a/order-fulfillment/README.md b/order-fulfillment/README.md new file mode 100644 index 0000000..4d65c8a --- /dev/null +++ b/order-fulfillment/README.md @@ -0,0 +1,76 @@ +# order-fulfillment + +Companion code for *[Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith](https://ankurm.com)* on [ankurm.com](https://ankurm.com). + +A 4-module Spring Boot monolith — `order`, `inventory`, `shipping`, `notification` — verified +with Spring Modulith's `ApplicationModules.verify()`. Everything in the post was produced by +actually running this code; see `output/` for every captured transcript, in order. + +## Versions + +| Artifact | Version | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Modulith | 2.1.1 (current GA; 2.2 exists only as milestones as of this writing — verified against Maven Central's `maven-metadata.xml`, not the project's own `` tag, which has pointed at a milestone before) | +| JDK | 25 (LTS) | +| H2 | 2.4.240 (in-memory, for the demo only) | + +## Quickstart + +``` +mvn -pl order-fulfillment -am test +``` + +Runs the module-boundary verification, the documentation generator, and the full +`order → inventory → shipping → notification` event-cascade integration test. + +To run the app itself: + +``` +mvn -pl order-fulfillment -am spring-boot:run +``` + +``` +curl -X POST "http://localhost:8080/orders?sku=WIDGET-1&quantity=3" +curl "http://localhost:8080/orders/1" +``` + +## Modules + +| Module | Public API | Internal | Listens for | +|---|---|---|---| +| `order` | `OrderManagement`, `OrderController` | `internal.Order`, `internal.OrderRepository` | — | +| `inventory` | `InventoryManagement` | `internal.Stock`, `internal.StockRepository`, `internal.StockSeeder` | `OrderPlaced` | +| `shipping` | `ShippingManagement` | `internal.Shipment`, `internal.ShipmentRepository` | `StockReserved` | +| `notification` | `NotificationManagement` | `internal.Notification`, `internal.NotificationRepository` | `OrderPlaced`, `ShipmentScheduled` | + +`order` has zero module dependencies — on purpose. See the Javadoc on `OrderManagement` +for why, and `output/03-cycle-via-public-api-still-failed.txt` for what happens the first +time that seems like a good idea to "fix" by adding one back. + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/orders?sku={sku}&quantity={n}` | Place an order | +| `GET` | `/orders/{id}` | Fetch an order | + +## Captured output (`output/`) + +Every number and every stack trace in the post traces to one of these, byte for byte: + +| File | What it captures | +|---|---| +| `00-table-name-collision.txt` | H2 rejecting `CREATE TABLE order` — `ORDER` is a reserved word | +| `01-verify-violation-failed.txt` | `ApplicationModules.verify()` failing on a direct reference to `inventory.internal.StockRepository` — both a cycle and a non-exposed-type violation | +| `02-verify-passed.txt` | The same test, green, after removing the dependency on `inventory` entirely | +| `03-cycle-via-public-api-still-failed.txt` | Why routing the same call through `InventoryManagement` (the real public API) still fails — a cycle isn't a visibility problem | +| `04-event-flow-missing-dependency.txt` | `@ApplicationModuleTest(mode = ALL_DEPENDENCIES)` bootstrapping *zero* other modules for `order`, because `order` has no outgoing dependencies, and the resulting event-cascade test timing out | +| `05-redundant-transactional-failure.txt` | Stacking a plain `@Transactional` on top of `@ApplicationModuleListener` failing bean registration outright | +| `06-event-flow-passed.txt` | The full `order → inventory → shipping → notification` cascade, fixed, with both notification hops firing | +| `07-generated-docs.txt` | The real output of `Documenter.writeModulesAsPlantUml().writeModuleCanvases()...` — the PlantUML component diagram and per-module canvases this repo's own structure produces | +| `08-javap-listener-meta-annotations.txt` | `javap -v` on the compiled `@ApplicationModuleListener` class, showing its real meta-annotations (`@Async` + `@Transactional(REQUIRES_NEW)` + `@TransactionalEventListener`) | + +No `docs/` chapter directory in this repository — the intermediate and reference-depth +material that would normally live there is in accordion sections inside the WordPress +post itself. diff --git a/order-fulfillment/output/00-table-name-collision.txt b/order-fulfillment/output/00-table-name-collision.txt new file mode 100644 index 0000000..eccc8fd --- /dev/null +++ b/order-fulfillment/output/00-table-name-collision.txt @@ -0,0 +1,20 @@ +$ mvn -pl order-fulfillment -am test +(captured before adding @Table(name = "orders") to com.ankurm.modulithdemo.order.internal.Order) + +Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table [*]order (id bigint generated by default as identity, quantity integer not null, sku varchar(255), status varchar(255), primary key (id))"; expected "identifier"; SQL statement: +create table order (id bigint generated by default as identity, quantity integer not null, sku varchar(255), status varchar(255), primary key (id)) [42001-240] + at org.h2.message.DbException.getJdbcSQLException(DbException.java:514) ~[h2-2.4.240.jar:2.4.240] + at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) ~[h2-2.4.240.jar:2.4.240] + at org.h2.message.DbException.getSyntaxError(DbException.java:261) ~[h2-2.4.240.jar:2.4.240] + at org.h2.command.Parser.readIdentifier(Parser.java:5568) ~[h2-2.4.240.jar:2.4.240] + at org.h2.command.Parser.parseCreateTable(Parser.java:8908) ~[h2-2.4.240.jar:2.4.240] + at org.h2.command.Parser.parseCreate(Parser.java:6483) ~[h2-2.4.240.jar:2.4.240] + at org.hibernate.tool.schema.internal.AbstractSchemaMigrator.createTable(AbstractSchemaMigrator.java:305) ~[hibernate-core-7.4.5.Final.jar:7.4.5.Final] + at org.hibernate.tool.schema.internal.GroupedSchemaMigratorImpl.performTablesMigration(GroupedSchemaMigratorImpl.java:80) ~[hibernate-core-7.4.5.Final.jar:7.4.5.Final] + +Root cause: JPA defaults an entity's table name to its simple name. `Order` the Java class +becomes `order` the table name, and ORDER is a reserved word in H2 (and in the SQL +standard, and in Postgres, and in MySQL) because of the ORDER BY clause. Hibernate's +schema-update path logs this as an error and carries on, which is worse than failing fast +-- the table partially/never exists and every INSERT against it fails later with its own, +less obvious error. Fixed with @Table(name = "orders") on the entity. diff --git a/order-fulfillment/output/01-verify-violation-failed.txt b/order-fulfillment/output/01-verify-violation-failed.txt new file mode 100644 index 0000000..88156bf --- /dev/null +++ b/order-fulfillment/output/01-verify-violation-failed.txt @@ -0,0 +1,49 @@ +$ mvn -pl order-fulfillment -am test -Dtest=ModularityTests#verifiesModuleStructure +(captured with OrderManagement holding a direct field of type + com.ankurm.modulithdemo.inventory.internal.StockRepository) + +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 1.768 s <<< FAILURE! -- in com.ankurm.modulithdemo.ModularityTests +[ERROR] com.ankurm.modulithdemo.ModularityTests.verifiesModuleStructure -- Time elapsed: 0.120 s <<< ERROR! +org.springframework.modulith.core.Violations: +- Cycle detected: Slice inventory -> + Slice order -> + Slice inventory + 1. Dependencies of Slice inventory + - Method has parameter of type in (InventoryManagement.java:0) + - Method calls method in (InventoryManagement.java:44) + - Method calls method in (InventoryManagement.java:45) + - Method calls method in (InventoryManagement.java:48) + 2. Dependencies of Slice order + - Constructor (..., StockRepository)> has parameter of type in (OrderManagement.java:0) + - Field has type in (OrderManagement.java:0) + - Method calls method in (OrderManagement.java:39) +- Module 'order' depends on non-exposed type com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'! + OrderManagement declares constructor OrderManagement(OrderRepository, ApplicationEventPublisher, StockRepository) in (OrderManagement.java:0) +- Module 'order' depends on non-exposed type com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'! + Method calls method in (OrderManagement.java:39) +- Module 'order' depends on non-exposed type com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'! + Field has type in (OrderManagement.java:0) + at org.springframework.modulith.core.Violations.and(Violations.java:141) + at org.springframework.modulith.core.ApplicationModules.detectViolations(ApplicationModules.java:497) + at org.springframework.modulith.core.ApplicationModules.verify(ApplicationModules.java:451) + at org.springframework.modulith.core.ApplicationModules.verify(ApplicationModules.java:435) + at com.ankurm.modulithdemo.ModularityTests.verifiesModuleStructure(ModularityTests.java:28) + +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 + +One field in OrderManagement produced TWO distinct violation types in the same run: + +1. "Cycle detected" - inventory already depends on order (it listens for OrderPlaced, + so it necessarily knows about order's event type). Order reaching back into + inventory's internals closes a cycle: order -> inventory -> order. Spring Modulith + treats module dependencies as a DAG by default; a cycle is a structural violation + even before asking whether the specific type reached for was internal. + +2. "depends on non-exposed type" - independent of the cycle, StockRepository lives + under inventory.internal, which nothing outside the inventory package is allowed + to reference, exposed or not. + +Either violation alone would have failed the build. This one shortcut produced both at +once, which is the realistic case: an internal type reached across a boundary usually +also reverses or closes a dependency cycle, because the "proper" event-based direction +was already established the other way. diff --git a/order-fulfillment/output/02-verify-passed.txt b/order-fulfillment/output/02-verify-passed.txt new file mode 100644 index 0000000..31d3eb6 --- /dev/null +++ b/order-fulfillment/output/02-verify-passed.txt @@ -0,0 +1,17 @@ +$ mvn -pl order-fulfillment -am test +(captured after removing the dependency on inventory entirely from OrderManagement -- + see the Javadoc on OrderManagement for why routing it through InventoryManagement's + public API still was not enough, in output/03-cycle-via-public-api-still-failed.txt) + +[INFO] Running com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests +2026-10-03T23:53:21.630+05:30 INFO 2118 --- [order-fulfillment] [ task-2] c.a.m.n.NotificationManagement : Order 1 received for 3x WIDGET-1 +2026-10-03T23:53:21.685+05:30 INFO 2118 --- [order-fulfillment] [ task-4] c.a.m.n.NotificationManagement : Order 1 is on its way (WIDGET-1) +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.748 s -- in com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests +[INFO] Running com.ankurm.modulithdemo.ModularityTests +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.471 s -- in com.ankurm.modulithdemo.ModularityTests +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +Same ApplicationModules.verify() call, same four modules, zero violations. The only +change from output/01-verify-violation-failed.txt is that OrderManagement no longer +depends on anything in inventory, in either direction. diff --git a/order-fulfillment/output/03-cycle-via-public-api-still-failed.txt b/order-fulfillment/output/03-cycle-via-public-api-still-failed.txt new file mode 100644 index 0000000..da9b0ab --- /dev/null +++ b/order-fulfillment/output/03-cycle-via-public-api-still-failed.txt @@ -0,0 +1,26 @@ +$ mvn -pl order-fulfillment -am test -Dtest=ModularityTests#verifiesModuleStructure +(captured with OrderManagement depending on InventoryManagement -- inventory's actual + public API, not an internal type -- expecting this to fix the earlier violation) + +org.springframework.modulith.core.Violations: - Cycle detected: Slice inventory -> + Slice order -> + Slice inventory + 1. Dependencies of Slice inventory + - Method has parameter of type in (InventoryManagement.java:0) + - Method calls method in (InventoryManagement.java:44) + - Method calls method in (InventoryManagement.java:45) + - Method calls method in (InventoryManagement.java:48) + - Method calls method in (InventoryManagement.java:48) + 2. Dependencies of Slice order + - Constructor (com.ankurm.modulithdemo.order.internal.OrderRepository, org.springframework.context.ApplicationEventPublisher, com.ankurm.modulithdemo.inventory.InventoryManagement)> has parameter of type in (OrderManagement.java:0) + - Field has type in (OrderManagement.java:0) + - Method calls method in (OrderManagement.java:40) + +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 -- BUILD FAILURE + +Notice: no "non-exposed type" complaint this time -- InventoryManagement is inventory's +real public API, exactly as advertised. Only "Cycle detected" remains, which proves the +point: going through the public API fixes visibility violations, not direction +violations. inventory already depends on order (its listener takes OrderPlaced as a +parameter), so order depending on inventory in return -- through any type, public or +not -- closes a cycle. The fix is to remove the dependency, not to launder it. diff --git a/order-fulfillment/output/04-event-flow-missing-dependency.txt b/order-fulfillment/output/04-event-flow-missing-dependency.txt new file mode 100644 index 0000000..683a8b7 --- /dev/null +++ b/order-fulfillment/output/04-event-flow-missing-dependency.txt @@ -0,0 +1,45 @@ +2026-10-03T23:48:20.007+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Order in mode ALL_DEPENDENCIES (class com.ankurm.modulithdemo.Application)? +2026-10-03T23:48:20.016+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : +2026-10-03T23:48:20.024+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : # Order +2026-10-03T23:48:20.024+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : > Logical name: order +2026-10-03T23:48:20.024+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : > Base package: com.ankurm.modulithdemo.order +2026-10-03T23:48:20.024+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : > Excluded packages: none +2026-10-03T23:48:20.025+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : > Direct module dependencies: none +2026-10-03T23:48:20.025+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : > Spring beans: +2026-10-03T23:48:20.025+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : + ?.OrderController +2026-10-03T23:48:20.025+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : + ?.OrderManagement +2026-10-03T23:48:20.025+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : o ?.internal.OrderRepository +2026-10-03T23:48:20.032+05:30 INFO 1451 --- [order-fulfillment] [ main] ustomizerFactory$ModuleContextCustomizer : +2026-10-03T23:48:20.042+05:30 INFO 1451 --- [order-fulfillment] [ main] c.a.m.o.OrderFulfillmentIntegrationTests : Starting OrderFulfillmentIntegrationTests using Java 25.0.4.1 with PID 1451 (started by root in /home/claude/spring-modulith-demo/order-fulfillment) +2026-10-03T23:48:23.449+05:30 DEBUG 1451 --- [order-fulfillment] [ main] .s.m.e.c.DefaultEventPublicationRegistry : Looking up incomplete event publications ? +2026-10-03T23:48:23.793+05:30 DEBUG 1451 --- [order-fulfillment] [ main] .s.m.e.c.DefaultEventPublicationRegistry : No publication found. +2026-10-03T23:48:23.807+05:30 INFO 1451 --- [order-fulfillment] [ main] o.s.s.config.TaskSchedulerRouter : No TaskScheduler/ScheduledExecutorService bean found for scheduled processing +2026-10-03T23:48:23.811+05:30 INFO 1451 --- [order-fulfillment] [ main] c.a.m.o.OrderFulfillmentIntegrationTests : Started OrderFulfillmentIntegrationTests in 4.061 seconds (process running for 6.7) +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 16.48 s <<< FAILURE! -- in com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests +[ERROR] com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests.placingAnOrderCascadesThroughInventoryAndShipping(Scenario) -- Time elapsed: 10.50 s <<< ERROR! +org.awaitility.core.ConditionTimeoutException: Lambda expression in org.springframework.modulith.test.Scenario$When$EventResult expected the predicate to return but it returned for input of <[]> within 10 seconds. + at org.awaitility.core.ConditionAwaiter.await(ConditionAwaiter.java:167) + at org.awaitility.core.AbstractHamcrestCondition.await(AbstractHamcrestCondition.java:86) + at org.awaitility.core.ConditionFactory.until(ConditionFactory.java:1160) + at org.awaitility.core.ConditionFactory.until(ConditionFactory.java:712) + at org.awaitility.core.ConditionFactory.until(ConditionFactory.java:729) + at org.springframework.modulith.test.Scenario$When.awaitInternal(Scenario.java:420) + at org.springframework.modulith.test.Scenario$When$EventResult.toArriveAndVerifyInternal(Scenario.java:657) + at org.springframework.modulith.test.Scenario$When$EventResult.toArriveAndVerify(Scenario.java:583) + at com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests.placingAnOrderCascadesThroughInventoryAndShipping(OrderFulfillmentIntegrationTests.java:29) + +Note the line: "> Direct module dependencies: none" +That line is the whole bug. ALL_DEPENDENCIES bootstraps the modules `order` depends ON +(what order imports), not the modules that depend on order (what listens to order's +events). Since nothing in `order` imports from inventory/shipping/notification, none of +their beans - including InventoryManagement, the @ApplicationModuleListener that reacts +to OrderPlaced - exist in this test's ApplicationContext. The event is published, +there is no listener to receive it, and awaitility times out waiting for a +ShipmentScheduled that was never going to arrive. Fixed by adding +extraIncludes = {"inventory", "shipping", "notification"} to @ApplicationModuleTest. diff --git a/order-fulfillment/output/05-redundant-transactional-failure.txt b/order-fulfillment/output/05-redundant-transactional-failure.txt new file mode 100644 index 0000000..698f7a8 --- /dev/null +++ b/order-fulfillment/output/05-redundant-transactional-failure.txt @@ -0,0 +1,20 @@ + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) ~[surefire-booter-3.2.5.jar:3.2.5] + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) ~[surefire-booter-3.2.5.jar:3.2.5] + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) ~[surefire-booter-3.2.5.jar:3.2.5] + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) ~[surefire-booter-3.2.5.jar:3.2.5] +Caused by: java.lang.IllegalStateException: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced) + at org.springframework.transaction.annotation.RestrictedTransactionalEventListenerFactory.createApplicationListener(RestrictedTransactionalEventListenerFactory.java:52) ~[spring-tx-7.0.9.jar:7.0.9] + at org.springframework.context.event.EventListenerMethodProcessor.processBean(EventListenerMethodProcessor.java:187) ~[spring-context-7.0.9.jar:7.0.9] + at org.springframework.context.event.EventListenerMethodProcessor.afterSingletonsInstantiated(EventListenerMethodProcessor.java:141) ~[spring-context-7.0.9.jar:7.0.9] + ... 92 common frames omitted + +2026-10-03T23:49:39.346+05:30 WARN 1623 --- [order-fulfillment] [ main] o.s.test.context.TestContextManager : Caught exception while allowing TestExecutionListener [org.springframework.test.context.web.ServletTestExecutionListener] to prepare test instance [com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests@646af766] + +Root cause: @ApplicationModuleListener already carries @Transactional(propagation = +REQUIRES_NEW) as a meta-annotation (confirmed by javap -v on the annotation class itself - +see the post's "how it really works underneath" section). Redeclaring a plain +@Transactional (default propagation REQUIRED) on the same method is not harmless +layering - Spring's RestrictedTransactionalEventListenerFactory refuses to even register +the listener, and the whole ApplicationContext fails to start. Fixed by deleting the +redundant @Transactional from InventoryManagement.on(OrderPlaced) and +ShippingManagement.on(StockReserved). diff --git a/order-fulfillment/output/06-event-flow-passed.txt b/order-fulfillment/output/06-event-flow-passed.txt new file mode 100644 index 0000000..c8ba2dc --- /dev/null +++ b/order-fulfillment/output/06-event-flow-passed.txt @@ -0,0 +1,19 @@ +$ mvn -pl order-fulfillment -am test + +2026-10-03T23:50:25.993+05:30 INFO 1737 --- [order-fulfillment] [ task-2] c.a.m.n.NotificationManagement : Order 1 received for 3x WIDGET-1 +2026-10-03T23:50:26.062+05:30 INFO 1737 --- [order-fulfillment] [ task-4] c.a.m.n.NotificationManagement : Order 1 is on its way (WIDGET-1) +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.888 s -- in com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests +[INFO] Running com.ankurm.modulithdemo.ModularityTests +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.437 s -- in com.ankurm.modulithdemo.ModularityTests +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +All three modules fired in sequence from a single published OrderPlaced, on two +different async listener threads (task-2, task-4 - each @ApplicationModuleListener +runs on its own thread from the pool, which is why ordering between independent +listeners on the SAME event is not guaranteed, only causal ordering between an event +and what it triggers): + + order -> inventory (reserve stock, publish StockReserved) + -> shipping (schedule shipment, publish ShipmentScheduled) + -> notification (fires twice: once reacting to OrderPlaced, once to ShipmentScheduled) diff --git a/order-fulfillment/output/07-generated-docs.txt b/order-fulfillment/output/07-generated-docs.txt new file mode 100644 index 0000000..706f11b --- /dev/null +++ b/order-fulfillment/output/07-generated-docs.txt @@ -0,0 +1,90 @@ +=== target/spring-modulith-docs/components.puml === +@startuml +title Order Fulfillment + +set separator none +top to bottom direction + + + +!include +!include +!include + +System_Boundary("OrderFulfillment_boundary", "Order Fulfillment", $tags="") { + Container_Boundary("OrderFulfillment.OrderFulfillment_boundary", "Order Fulfillment", $tags="") { + Component(OrderFulfillment.OrderFulfillment.Order, "Order", $techn="Module", $descr="", $tags="", $link="") + Component(OrderFulfillment.OrderFulfillment.Inventory, "Inventory", $techn="Module", $descr="", $tags="", $link="") + Component(OrderFulfillment.OrderFulfillment.Shipping, "Shipping", $techn="Module", $descr="", $tags="", $link="") + Component(OrderFulfillment.OrderFulfillment.Notification, "Notification", $techn="Module", $descr="", $tags="", $link="") + } + +} + +Rel(OrderFulfillment.OrderFulfillment.Notification, OrderFulfillment.OrderFulfillment.Order, "listens to", $techn="", $tags="", $link="") +Rel(OrderFulfillment.OrderFulfillment.Shipping, OrderFulfillment.OrderFulfillment.Inventory, "listens to", $techn="", $tags="", $link="") +Rel(OrderFulfillment.OrderFulfillment.Inventory, OrderFulfillment.OrderFulfillment.Order, "listens to", $techn="", $tags="", $link="") +Rel(OrderFulfillment.OrderFulfillment.Notification, OrderFulfillment.OrderFulfillment.Shipping, "listens to", $techn="", $tags="", $link="") + +SHOW_LEGEND(true) +hide stereotypes +@enduml +=== target/spring-modulith-docs/module-order.adoc === +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`com.ankurm.modulithdemo.order` +|Spring components +|_Controllers_ + +* `c.a.m.o.OrderController` + +_Services_ + +* `c.a.m.o.OrderManagement` +|=== + +=== target/spring-modulith-docs/module-inventory.adoc === +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`com.ankurm.modulithdemo.inventory` +|Spring components +|_Services_ + +* `c.a.m.i.InventoryManagement` +|Events listened to +|* `c.a.m.o.OrderPlaced` (async) +|=== + +=== target/spring-modulith-docs/module-shipping.adoc === +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`com.ankurm.modulithdemo.shipping` +|Spring components +|_Services_ + +* `c.a.m.s.ShippingManagement` +|Events listened to +|* `c.a.m.i.StockReserved` (async) +|=== + +=== target/spring-modulith-docs/module-notification.adoc === +[%autowidth.stretch, cols="h,a"] +|=== +|Base package +|`com.ankurm.modulithdemo.notification` +|Spring components +|_Services_ + +* `c.a.m.n.NotificationManagement` +|Events listened to +|* `c.a.m.s.ShipmentScheduled` (async) +* `c.a.m.o.OrderPlaced` (async) +|=== diff --git a/order-fulfillment/output/08-javap-listener-meta-annotations.txt b/order-fulfillment/output/08-javap-listener-meta-annotations.txt new file mode 100644 index 0000000..2255d0c --- /dev/null +++ b/order-fulfillment/output/08-javap-listener-meta-annotations.txt @@ -0,0 +1,30 @@ +$ CP=$(find ~/.m2 -iname "spring-modulith-events-api-2.1.1.jar") +$ unzip -o -q "$CP" "org/springframework/modulith/events/ApplicationModuleListener.class" -d /tmp/inspect +$ javap -v /tmp/inspect/org/springframework/modulith/events/ApplicationModuleListener.class + +RuntimeVisibleAnnotations: + 0: #28() + org.springframework.scheduling.annotation.Async + 1: #14(#22=e#24.#25) + org.springframework.transaction.annotation.Transactional( + propagation=Lorg/springframework/transaction/annotation/Propagation;.REQUIRES_NEW + ) + 2: #29() + org.springframework.transaction.event.TransactionalEventListener + 3: #30() + java.lang.annotation.Documented + 4: #31(#32=[e#33.#34,e#33.#35]) + java.lang.annotation.Target( + value=[Ljava/lang/annotation/ElementType;.METHOD,Ljava/lang/annotation/ElementType;.ANNOTATION_TYPE] + ) + 5: #36(#32=e#37.#38) + java.lang.annotation.Retention( + value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME + ) + +This is the compiled annotation class itself, not documentation describing it -- +@ApplicationModuleListener is literally @Async + @Transactional(propagation = +REQUIRES_NEW) + @TransactionalEventListener (default phase AFTER_COMMIT), stacked as +meta-annotations. Confirmed this way rather than taken from prose because the reference +documentation does not spell out the propagation level, and getting it wrong is exactly +what produces the failure in output/05-redundant-transactional-failure.txt. diff --git a/order-fulfillment/pom.xml b/order-fulfillment/pom.xml new file mode 100644 index 0000000..58b1d20 --- /dev/null +++ b/order-fulfillment/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + com.ankurm + spring-modulith-demo + 1.0.0 + + + order-fulfillment + jar + order-fulfillment + 4-module Spring Boot monolith (order, inventory, shipping, notification) verified with + Spring Modulith's ApplicationModules.verify(), demonstrating a real boundary violation that fails + then passes, module events via @ApplicationModuleListener, and generated module documentation. + + + 25 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.modulith + spring-modulith-bom + ${spring-modulith.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.modulith + spring-modulith-starter-jpa + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.modulith + spring-modulith-starter-test + test + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + + + order-fulfillment + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/Application.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/Application.java new file mode 100644 index 0000000..a7f1efe --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/Application.java @@ -0,0 +1,23 @@ +package com.ankurm.modulithdemo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.modulith.Modulithic; + +/** + * Entry point for the order-fulfillment demo. + * + *

Four application modules live as direct sub-packages of this class's package: + * {@code order}, {@code inventory}, {@code shipping} and {@code notification}. Spring + * Modulith treats each one as a module automatically (package-by-feature convention) — + * see {@link com.ankurm.modulithdemo.ModularityTests} for the boundary verification and + * documentation-generation tests that prove it. + */ +@SpringBootApplication +@Modulithic(systemName = "Order Fulfillment") +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/InventoryManagement.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/InventoryManagement.java new file mode 100644 index 0000000..3c8beac --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/InventoryManagement.java @@ -0,0 +1,50 @@ +package com.ankurm.modulithdemo.inventory; + +import com.ankurm.modulithdemo.inventory.internal.Stock; +import com.ankurm.modulithdemo.inventory.internal.StockRepository; +import com.ankurm.modulithdemo.order.OrderPlaced; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Service; + +/** + * The {@code inventory} module's public API. {@link #isInStock(String, int)} is the + * sanctioned way for another module to ask about stock levels — it is the method the + * "fixed" version of {@code OrderManagement} calls instead of reaching into + * {@link com.ankurm.modulithdemo.inventory.internal.StockRepository} directly. + */ +@Service +public class InventoryManagement { + + private final StockRepository stock; + private final ApplicationEventPublisher events; + + public InventoryManagement(StockRepository stock, ApplicationEventPublisher events) { + this.stock = stock; + this.events = events; + } + + public boolean isInStock(String sku, int quantity) { + return stock.findBySku(sku).map(s -> s.getAvailable() >= quantity).orElse(false); + } + + /** + * Reacts to an order being placed, in its own transaction, asynchronously — this is + * what {@code @ApplicationModuleListener} buys over a plain + * {@code @EventListener}: {@code OrderManagement.placeOrder()} already returned + * before this method runs, and a failure here cannot roll back the order. + * + *

Note there is no explicit {@code @Transactional} here — {@code + * @ApplicationModuleListener} already carries {@code @Transactional(propagation = + * REQUIRES_NEW)} as a meta-annotation. Adding a second one on top doesn't layer, it + * breaks bean registration outright; see {@code output/05-redundant-transactional-failure.txt}. + */ + @ApplicationModuleListener + public void on(OrderPlaced event) { + var item = stock.findBySku(event.sku()).orElseThrow(); + item.reserve(event.quantity()); + stock.save(item); + + events.publishEvent(new StockReserved(event.orderId(), event.sku(), item.getAvailable())); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/StockReserved.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/StockReserved.java new file mode 100644 index 0000000..060edfc --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/StockReserved.java @@ -0,0 +1,12 @@ +package com.ankurm.modulithdemo.inventory; + +/** + * Published once stock has been decremented for an order. {@code shipping} and + * {@code notification} both react to this. + * + * @param orderId the order the stock was reserved for + * @param sku the product reserved + * @param remaining units left in stock after this reservation + */ +public record StockReserved(Long orderId, String sku, int remaining) { +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/Stock.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/Stock.java new file mode 100644 index 0000000..ba63c1c --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/Stock.java @@ -0,0 +1,41 @@ +package com.ankurm.modulithdemo.inventory.internal; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class Stock { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String sku; + private int available; + + protected Stock() { + } + + public Stock(String sku, int available) { + this.sku = sku; + this.available = available; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public int getAvailable() { + return available; + } + + public void reserve(int quantity) { + this.available -= quantity; + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockRepository.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockRepository.java new file mode 100644 index 0000000..7e31b2f --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockRepository.java @@ -0,0 +1,15 @@ +package com.ankurm.modulithdemo.inventory.internal; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * The repository behind {@code inventory}'s stock table. This is exactly the kind of type + * Spring Modulith expects to stay inside its own module — see + * {@link com.ankurm.modulithdemo.order.OrderManagement} in the "boundary violation" branch + * of the post for what happens when another module imports it anyway. + */ +public interface StockRepository extends JpaRepository { + Optional findBySku(String sku); +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockSeeder.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockSeeder.java new file mode 100644 index 0000000..1349b70 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/internal/StockSeeder.java @@ -0,0 +1,26 @@ +package com.ankurm.modulithdemo.inventory.internal; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * Seeds a starting stock level so the demo has something to reserve against. Internal — + * nothing outside {@code inventory} should ever need this. + */ +@Component +class StockSeeder implements ApplicationRunner { + + private final StockRepository stock; + + StockSeeder(StockRepository stock) { + this.stock = stock; + } + + @Override + public void run(ApplicationArguments args) { + if (stock.findBySku("WIDGET-1").isEmpty()) { + stock.save(new Stock("WIDGET-1", 100)); + } + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/NotificationManagement.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/NotificationManagement.java new file mode 100644 index 0000000..85b11f0 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/NotificationManagement.java @@ -0,0 +1,42 @@ +package com.ankurm.modulithdemo.notification; + +import com.ankurm.modulithdemo.notification.internal.Notification; +import com.ankurm.modulithdemo.notification.internal.NotificationRepository; +import com.ankurm.modulithdemo.order.OrderPlaced; +import com.ankurm.modulithdemo.shipping.ShipmentScheduled; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Service; + +/** + * The {@code notification} module's public API. It depends on two other modules' events + * — {@link OrderPlaced} from {@code order} and {@link ShipmentScheduled} from + * {@code shipping} — but never on either module's service bean or persistence layer. Two + * separate {@code @ApplicationModuleListener} methods, two separate hops in the chain. + */ +@Service +public class NotificationManagement { + + private static final Logger log = LoggerFactory.getLogger(NotificationManagement.class); + + private final NotificationRepository notifications; + + public NotificationManagement(NotificationRepository notifications) { + this.notifications = notifications; + } + + @ApplicationModuleListener + public void on(OrderPlaced event) { + var message = "Order " + event.orderId() + " received for " + event.quantity() + "x " + event.sku(); + log.info(message); + notifications.save(new Notification(event.orderId(), message)); + } + + @ApplicationModuleListener + public void on(ShipmentScheduled event) { + var message = "Order " + event.orderId() + " is on its way (" + event.sku() + ")"; + log.info(message); + notifications.save(new Notification(event.orderId(), message)); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/Notification.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/Notification.java new file mode 100644 index 0000000..ae44101 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/Notification.java @@ -0,0 +1,37 @@ +package com.ankurm.modulithdemo.notification.internal; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long orderId; + private String message; + + protected Notification() { + } + + public Notification(Long orderId, String message) { + this.orderId = orderId; + this.message = message; + } + + public Long getId() { + return id; + } + + public Long getOrderId() { + return orderId; + } + + public String getMessage() { + return message; + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/NotificationRepository.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/NotificationRepository.java new file mode 100644 index 0000000..e693fc4 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/notification/internal/NotificationRepository.java @@ -0,0 +1,9 @@ +package com.ankurm.modulithdemo.notification.internal; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + List findByOrderId(Long orderId); +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderController.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderController.java new file mode 100644 index 0000000..511c6ea --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderController.java @@ -0,0 +1,25 @@ +package com.ankurm.modulithdemo.order; + +import com.ankurm.modulithdemo.order.internal.Order; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/orders") +public class OrderController { + + private final OrderManagement orders; + + public OrderController(OrderManagement orders) { + this.orders = orders; + } + + @PostMapping + public Order place(@RequestParam String sku, @RequestParam int quantity) { + return orders.placeOrder(sku, quantity); + } + + @GetMapping("/{id}") + public Order get(@PathVariable Long id) { + return orders.get(id); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderManagement.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderManagement.java new file mode 100644 index 0000000..7c0d0a6 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderManagement.java @@ -0,0 +1,63 @@ +package com.ankurm.modulithdemo.order; + +import com.ankurm.modulithdemo.order.internal.Order; +import com.ankurm.modulithdemo.order.internal.OrderRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * The {@code order} module's public API. Everything another module is allowed to call + * about orders goes through this class and the events it publishes — see + * {@link OrderPlaced}. + * + *

Why there is no call into {@code inventory} here, not even through its public + * API. An earlier version of this class reached straight into + * {@code inventory.internal.StockRepository} to check stock before placing an order. + * {@code ApplicationModules.verify()} failed that with two violations: a dependency + * cycle, and a reference to a non-exposed type (see {@code + * output/01-verify-violation-failed.txt}). The instinctive fix — route the same call + * through {@link com.ankurm.modulithdemo.inventory.InventoryManagement}, inventory's + * actual public API — turns out to still fail with "Cycle detected" (see {@code + * output/03-cycle-via-public-api-still-failed.txt}), because {@code inventory} already + * depends on {@code order}: its {@code @ApplicationModuleListener} takes + * {@link OrderPlaced} as a parameter, which is a dependency edge regardless of whether + * the listening module calls anything synchronously. Going through the public API only + * fixes the "non-exposed type" violation; it cannot fix a cycle, because the cycle isn't + * about which types are public — it's about the direction already being spoken for. + *

+ * The actual fix is to not add the second edge: {@code order} publishes {@link + * OrderPlaced} and stops caring what happens next. If inventory can't satisfy the order, + * that's inventory's problem to signal back — as a new event {@code order} chooses to + * listen for, which is a deliberate second module relationship, not a shortcut around + * the first one. (Sorting out that back-channel without re-introducing a cycle is exactly + * what the Saga pattern post in this series is for.) + */ +@Service +public class OrderManagement { + + private final OrderRepository orders; + private final ApplicationEventPublisher events; + + public OrderManagement(OrderRepository orders, ApplicationEventPublisher events) { + this.orders = orders; + this.events = events; + } + + @Transactional + public Order placeOrder(String sku, int quantity) { + var order = orders.save(new Order(sku, quantity, "PLACED")); + + // Published in the same transaction as the insert above. Spring Modulith's event + // publication registry logs one row per @ApplicationModuleListener before this + // method returns, so the event can never be lost even if the process dies the + // instant after commit — see the "What the defaults do not do" section of the post. + events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity())); + + return order; + } + + public Order get(Long orderId) { + return orders.findById(orderId).orElseThrow(); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderPlaced.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderPlaced.java new file mode 100644 index 0000000..4b6a5e0 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderPlaced.java @@ -0,0 +1,13 @@ +package com.ankurm.modulithdemo.order; + +/** + * Published once an order has been persisted. This is the {@code order} module's public + * API surface for the event — every field here is a value the receiving module is allowed + * to depend on, nothing more. + * + * @param orderId the persisted order's id + * @param sku the product being ordered + * @param quantity how many units + */ +public record OrderPlaced(Long orderId, String sku, int quantity) { +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/Order.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/Order.java new file mode 100644 index 0000000..59e8936 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/Order.java @@ -0,0 +1,61 @@ +package com.ankurm.modulithdemo.order.internal; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * The order module's internal JPA entity. Nothing outside the {@code order} package is + * meant to see this class — other modules only ever see the {@code orderId} and + * {@code sku} carried on {@link com.ankurm.modulithdemo.order.OrderPlaced}. + * + *

{@code @Table(name = "orders")} is not decoration. {@code ORDER} is a + * reserved SQL keyword (it's the {@code ORDER BY} clause) and H2 — like most databases — + * rejects {@code CREATE TABLE order (...)} outright. Hibernate will happily default the + * table name to the entity's simple name, so this is caught the first time schema + * generation runs, not at compile time. See {@code output/00-table-name-collision.txt}. + */ +@Entity +@Table(name = "orders") +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String sku; + private int quantity; + private String status; + + protected Order() { + // for JPA + } + + public Order(String sku, int quantity, String status) { + this.sku = sku; + this.quantity = quantity; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public int getQuantity() { + return quantity; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/OrderRepository.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/OrderRepository.java new file mode 100644 index 0000000..36e1164 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/OrderRepository.java @@ -0,0 +1,13 @@ +package com.ankurm.modulithdemo.order.internal; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Internal persistence port for the {@code order} module. Package-private visibility is + * not required for Spring Modulith's default module model — everything under + * {@code order.internal} is internal by convention, public or not — but this interface + * is deliberately public anyway, because the whole point of {@link OrderManagement} is to + * show what happens when something outside this package reaches for it directly. + */ +public interface OrderRepository extends JpaRepository { +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShipmentScheduled.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShipmentScheduled.java new file mode 100644 index 0000000..6fee58b --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShipmentScheduled.java @@ -0,0 +1,10 @@ +package com.ankurm.modulithdemo.shipping; + +/** + * Published once a shipment has been scheduled for a reserved order. + * + * @param orderId the order being shipped + * @param sku the product shipped + */ +public record ShipmentScheduled(Long orderId, String sku) { +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShippingManagement.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShippingManagement.java new file mode 100644 index 0000000..f892729 --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/ShippingManagement.java @@ -0,0 +1,32 @@ +package com.ankurm.modulithdemo.shipping; + +import com.ankurm.modulithdemo.inventory.StockReserved; +import com.ankurm.modulithdemo.shipping.internal.Shipment; +import com.ankurm.modulithdemo.shipping.internal.ShipmentRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Service; + +/** + * The {@code shipping} module's public API. Reacts to {@link StockReserved} published by + * {@code inventory} — this is the second hop in the chain, proof that module events chain + * across more than one listener without any module calling another's service bean + * directly. + */ +@Service +public class ShippingManagement { + + private final ShipmentRepository shipments; + private final ApplicationEventPublisher events; + + public ShippingManagement(ShipmentRepository shipments, ApplicationEventPublisher events) { + this.shipments = shipments; + this.events = events; + } + + @ApplicationModuleListener + public void on(StockReserved event) { + shipments.save(new Shipment(event.orderId(), event.sku(), "SCHEDULED")); + events.publishEvent(new ShipmentScheduled(event.orderId(), event.sku())); + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/Shipment.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/Shipment.java new file mode 100644 index 0000000..383defe --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/Shipment.java @@ -0,0 +1,43 @@ +package com.ankurm.modulithdemo.shipping.internal; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class Shipment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long orderId; + private String sku; + private String status; + + protected Shipment() { + } + + public Shipment(Long orderId, String sku, String status) { + this.orderId = orderId; + this.sku = sku; + this.status = status; + } + + public Long getId() { + return id; + } + + public Long getOrderId() { + return orderId; + } + + public String getSku() { + return sku; + } + + public String getStatus() { + return status; + } +} diff --git a/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/ShipmentRepository.java b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/ShipmentRepository.java new file mode 100644 index 0000000..1dfa85f --- /dev/null +++ b/order-fulfillment/src/main/java/com/ankurm/modulithdemo/shipping/internal/ShipmentRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.modulithdemo.shipping.internal; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ShipmentRepository extends JpaRepository { +} diff --git a/order-fulfillment/src/main/resources/application.properties b/order-fulfillment/src/main/resources/application.properties new file mode 100644 index 0000000..7948018 --- /dev/null +++ b/order-fulfillment/src/main/resources/application.properties @@ -0,0 +1,27 @@ +spring.application.name=order-fulfillment + +spring.datasource.url=jdbc:h2:mem:modulith;DB_CLOSE_DELAY=-1 +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false + +# Spring Modulith's JDBC/JPA event publication registry creates its own table +# on top of whatever schema-generation strategy is already in play. +spring.modulith.events.jdbc.schema-initialization.enabled=true + +# Republish anything left PUBLISHED/PROCESSING by an unclean shutdown. +spring.modulith.events.republish-outstanding-events-on-restart=true + +# NOT enabled for this demo (left commented so the defaults above are what actually ran +# for every captured transcript in output/). In production, turn these on: +# spring.modulith.events.staleness.published=PT5M +# spring.modulith.events.staleness.processing=PT5M +# spring.modulith.events.staleness.resubmitted=PT5M +# spring.modulith.events.completion-mode=ARCHIVE +# Without them: completed rows accumulate forever (completion-mode defaults to UPDATE, +# which never deletes), and a row stuck in PUBLISHED/PROCESSING after a crash has no +# staleness monitor to mark it FAILED and eligible for resubmission -- all three +# staleness durations default to zero, which this library treats as "off". + +logging.level.com.ankurm.modulithdemo=INFO +logging.level.org.springframework.modulith=INFO diff --git a/order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java b/order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java new file mode 100644 index 0000000..2c7dc00 --- /dev/null +++ b/order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java @@ -0,0 +1,39 @@ +package com.ankurm.modulithdemo; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.docs.Documenter; + +/** + * The two tests every Spring Modulith project ends up with: + *

+ * 1. {@link #verifiesModuleStructure()} — fails the build the moment any module reaches + * across a boundary it shouldn't. This test is what turned RED when + * {@code OrderManagement} imported {@code inventory.internal.StockRepository} + * directly (captured in {@code output/01-verify-violation-failed.txt}) and GREEN again + * once that was replaced with a call through {@code InventoryManagement} (captured in + * {@code output/02-verify-passed.txt}). + *

+ * 2. {@link #writesDocumentation()} — generates a PlantUML component diagram, one diagram + * per module, and a canvas (beans / events / properties) for each module, straight from + * the bytecode. Output lands in {@code target/spring-modulith-docs}; a copy of what it + * produced for this project is captured in {@code output/03-generated-docs-listing.txt}. + */ +class ModularityTests { + + ApplicationModules modules = ApplicationModules.of(Application.class); + + @Test + void verifiesModuleStructure() { + modules.verify(); + } + + @Test + void writesDocumentation() { + new Documenter(modules) + .writeModulesAsPlantUml() + .writeIndividualModulesAsPlantUml() + .writeModuleCanvases() + .writeAggregatingDocument(); + } +} diff --git a/order-fulfillment/src/test/java/com/ankurm/modulithdemo/order/OrderFulfillmentIntegrationTests.java b/order-fulfillment/src/test/java/com/ankurm/modulithdemo/order/OrderFulfillmentIntegrationTests.java new file mode 100644 index 0000000..dd82da5 --- /dev/null +++ b/order-fulfillment/src/test/java/com/ankurm/modulithdemo/order/OrderFulfillmentIntegrationTests.java @@ -0,0 +1,46 @@ +package com.ankurm.modulithdemo.order; + +import com.ankurm.modulithdemo.inventory.StockReserved; +import com.ankurm.modulithdemo.shipping.ShipmentScheduled; +import org.junit.jupiter.api.Test; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.modulith.test.Scenario; + +import java.time.Duration; + +/** + * Runs the whole chain for real: publishing {@link OrderPlaced} should make + * {@code inventory} reserve stock and publish {@link StockReserved}, which should make + * {@code shipping} schedule a shipment and publish {@link ShipmentScheduled} — three + * modules, two hops, no module ever calling another module's service bean directly. + * + *

{@code mode = ALL_DEPENDENCIES} alone is not enough here — and the first + * version of this test proved it, timing out after 10 seconds waiting for an event that + * never arrived (captured in {@code output/04-event-flow-missing-dependency.txt}). + * {@code ALL_DEPENDENCIES} bootstraps {@code order} plus the modules {@code order} + * imports from. But {@code order} doesn't import anything from {@code inventory}, + * {@code shipping} or {@code notification} — the dependency runs the other way: those + * modules import {@code order}'s event types and listen for them. So from {@code order}'s + * point of view it has, correctly, zero module dependencies, and + * {@code InventoryManagement}, {@code ShippingManagement} and + * {@code NotificationManagement} are never instantiated — the event is published into a + * context with no listeners, and simply vanishes. {@code extraIncludes} pulls them in + * explicitly. + */ +@ApplicationModuleTest( + mode = ApplicationModuleTest.BootstrapMode.ALL_DEPENDENCIES, + extraIncludes = { "inventory", "shipping", "notification" }) +class OrderFulfillmentIntegrationTests { + + @Test + void placingAnOrderCascadesThroughInventoryAndShipping(Scenario scenario) { + scenario.publish(new OrderPlaced(1L, "WIDGET-1", 3)) + .andWaitForEventOfType(ShipmentScheduled.class) + .matching(event -> event.orderId().equals(1L)) + .toArriveAndVerify(event -> { + if (!event.sku().equals("WIDGET-1")) { + throw new AssertionError("expected WIDGET-1, got " + event.sku()); + } + }); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9cd5681 --- /dev/null +++ b/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + com.ankurm + spring-modulith-demo + 1.0.0 + pom + + spring-modulith-demo + + Companion repository for the ankurm.com Spring Modulith series. + Each Maven module backs one post; modules are added over time, one commit each. + - order-fulfillment : Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith + + + + 25 + UTF-8 + 4.1.1 + 2.1.1 + + + + order-fulfillment + +