Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
post/
|
||||
@@ -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 `<release>` 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.
|
||||
@@ -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.
|
||||
@@ -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 <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> has parameter of type <com.ankurm.modulithdemo.order.OrderPlaced> in (InventoryManagement.java:0)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.sku()> in (InventoryManagement.java:44)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.quantity()> in (InventoryManagement.java:45)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.orderId()> in (InventoryManagement.java:48)
|
||||
2. Dependencies of Slice order
|
||||
- Constructor <com.ankurm.modulithdemo.order.OrderManagement.<init>(..., StockRepository)> has parameter of type <com.ankurm.modulithdemo.inventory.internal.StockRepository> in (OrderManagement.java:0)
|
||||
- Field <com.ankurm.modulithdemo.order.OrderManagement.stockShortcut> has type <com.ankurm.modulithdemo.inventory.internal.StockRepository> in (OrderManagement.java:0)
|
||||
- Method <com.ankurm.modulithdemo.order.OrderManagement.placeOrder(java.lang.String, int)> calls method <com.ankurm.modulithdemo.inventory.internal.StockRepository.findBySku(java.lang.String)> 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 <com.ankurm.modulithdemo.order.OrderManagement.placeOrder(java.lang.String, int)> calls method <com.ankurm.modulithdemo.inventory.internal.StockRepository.findBySku(java.lang.String)> in (OrderManagement.java:39)
|
||||
- Module 'order' depends on non-exposed type com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'!
|
||||
Field <com.ankurm.modulithdemo.order.OrderManagement.stockShortcut> has type <com.ankurm.modulithdemo.inventory.internal.StockRepository> 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.
|
||||
@@ -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.
|
||||
@@ -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 <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> has parameter of type <com.ankurm.modulithdemo.order.OrderPlaced> in (InventoryManagement.java:0)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.sku()> in (InventoryManagement.java:44)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.quantity()> in (InventoryManagement.java:45)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.orderId()> in (InventoryManagement.java:48)
|
||||
- Method <com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)> calls method <com.ankurm.modulithdemo.order.OrderPlaced.sku()> in (InventoryManagement.java:48)
|
||||
2. Dependencies of Slice order
|
||||
- Constructor <com.ankurm.modulithdemo.order.OrderManagement.<init>(com.ankurm.modulithdemo.order.internal.OrderRepository, org.springframework.context.ApplicationEventPublisher, com.ankurm.modulithdemo.inventory.InventoryManagement)> has parameter of type <com.ankurm.modulithdemo.inventory.InventoryManagement> in (OrderManagement.java:0)
|
||||
- Field <com.ankurm.modulithdemo.order.OrderManagement.inventory> has type <com.ankurm.modulithdemo.inventory.InventoryManagement> in (OrderManagement.java:0)
|
||||
- Method <com.ankurm.modulithdemo.order.OrderManagement.placeOrder(java.lang.String, int)> calls method <com.ankurm.modulithdemo.inventory.InventoryManagement.isInStock(java.lang.String, int)> 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.
|
||||
@@ -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 <true> but it returned <false> 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.
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -0,0 +1,90 @@
|
||||
=== target/spring-modulith-docs/components.puml ===
|
||||
@startuml
|
||||
title <size:24>Order Fulfillment</size>
|
||||
|
||||
set separator none
|
||||
top to bottom direction
|
||||
|
||||
<style>
|
||||
root {
|
||||
BackgroundColor: #ffffff
|
||||
FontColor: #444444
|
||||
}
|
||||
</style>
|
||||
|
||||
!include <C4/C4>
|
||||
!include <C4/C4_Context>
|
||||
!include <C4/C4_Component>
|
||||
|
||||
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)
|
||||
|===
|
||||
@@ -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.
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>spring-modulith-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>order-fulfillment</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<name>order-fulfillment</name>
|
||||
<description>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.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-bom</artifactId>
|
||||
<version>${spring-modulith.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-starter-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>order-fulfillment</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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.
|
||||
*
|
||||
* <p>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()));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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<Stock, Long> {
|
||||
Optional<Stock> findBySku(String sku);
|
||||
}
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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<Notification, Long> {
|
||||
List<Notification> findByOrderId(Long orderId);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p><b>Why there is no call into {@code inventory} here, not even through its public
|
||||
* API.</b> 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.
|
||||
* <p>
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p><b>{@code @Table(name = "orders")} is not decoration.</b> {@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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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<Order, Long> {
|
||||
}
|
||||
+10
@@ -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) {
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.modulithdemo.shipping.internal;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ShipmentRepository extends JpaRepository<Shipment, Long> {
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
* <p>
|
||||
* 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}).
|
||||
* <p>
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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.
|
||||
*
|
||||
* <p><b>{@code mode = ALL_DEPENDENCIES} alone is not enough here</b> — 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}
|
||||
* <i>imports from</i>. 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>spring-modulith-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>spring-modulith-demo</name>
|
||||
<description>
|
||||
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
|
||||
</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<spring-boot.version>4.1.1</spring-boot.version>
|
||||
<spring-modulith.version>2.1.1</spring-modulith.version>
|
||||
</properties>
|
||||
|
||||
<modules>
|
||||
<module>order-fulfillment</module>
|
||||
</modules>
|
||||
</project>
|
||||
Reference in New Issue
Block a user