Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -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>
|
||||
+1014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,813 @@
|
||||
<!-- wp:html -->
|
||||
|
||||
<p>Every modular monolith starts the same way: a clean diagram, a handful of packages
|
||||
named after the business, and a promise that <code>order</code> will not reach into
|
||||
<code>inventory</code>’s business. Six months and four developers later, somebody
|
||||
adds exactly that reach — a repository field, a quick read, “it’s all
|
||||
one database anyway” — and nothing stops them. The code compiles. The tests
|
||||
that exist still pass. The diagram is now a lie, and nobody finds out until the day
|
||||
someone tries to split the monolith and discovers the boundaries were never real.</p>
|
||||
|
||||
<p>Spring Modulith is Spring’s answer to that specific failure: not a framework
|
||||
for writing microservices-shaped code inside one deployable, but a verification tool
|
||||
that turns your package structure into a contract a test can enforce. This post builds
|
||||
a small, real order-fulfillment app with four modules, breaks the boundary between two
|
||||
of them on purpose, watches the actual test failure, fixes it, and then shows that the
|
||||
"fix" most people reach for first — routing the call through the public API
|
||||
instead of the internals — still fails, for a reason that matters more than the
|
||||
original mistake.</p>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>Versions used in this post, verified against Maven Central’s maven-metadata.xml, not the project’s own <release> tag (which currently points at the 2.2.0-M2 milestone — a GA pointer that has been wrong before is not a GA pointer you can trust).</strong> Spring Boot 4.1.1 · Spring Modulith 2.1.1 (2.1 went GA 11 June 2026; 2.1.1 is the current patch; 2.2 exists only as milestones as of this writing) · JDK 25 LTS · H2 2.4.240 for the demo database.</blockquote>
|
||||
|
||||
<!-- /wp:html -->
|
||||
|
||||
<!--more-->
|
||||
|
||||
<!-- wp:html -->
|
||||
|
||||
<h2>What a "module" is to Spring Modulith</h2>
|
||||
|
||||
<p>Spring Modulith does not introduce a new way to write Spring code. It looks at
|
||||
packages you were probably going to create anyway and gives them meaning. The default
|
||||
convention — and the one this post uses — is <strong>package by
|
||||
feature</strong>: every direct sub-package of the package holding
|
||||
<code>@SpringBootApplication</code> is a module. No XML, no registry, no annotation
|
||||
required to declare one.</p>
|
||||
|
||||
<p>The demo app has four: <code>order</code>, <code>inventory</code>, <code>shipping</code>,
|
||||
and <code>notification</code>, each a sub-package of
|
||||
<code>com.ankurm.modulithdemo</code>. Inside each module, anything directly in the
|
||||
module’s own package is that module’s <strong>public API</strong> —
|
||||
fair game for other modules to depend on. Anything one level deeper, in an
|
||||
<code>internal</code> sub-package, is exactly that: internal, and off-limits to
|
||||
everyone else, regardless of whether the Java compiler marks it <code>public</code>.
|
||||
Java’s own visibility modifiers stop at the package; Spring Modulith is the thing
|
||||
that enforces the rest.</p>
|
||||
|
||||
|
||||
<figure role="img" aria-label="Four modules: Inventory and Notification both depend on Order by listening for its events; Shipping depends on Inventory the same way; Order depends on nothing.">
|
||||
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.t{font:600 15px sans-serif;fill:#2b2f38}
|
||||
.h{font:600 13px sans-serif;fill:#2b2f38}
|
||||
.c{font:12px sans-serif;fill:#5b6270}
|
||||
.m{font:12px monospace;fill:#5b6270}
|
||||
.lbl{font:11px sans-serif;fill:#5b6270}
|
||||
</style>
|
||||
<rect x="0" y="0" width="740" height="320" fill="#ffffff"/>
|
||||
<text x="20" y="28" class="t">Module dependency graph (as generated by Documenter)</text>
|
||||
|
||||
<rect x="300" y="60" width="140" height="56" rx="8" fill="#e8eefc" stroke="#5b7fc7"/>
|
||||
<text x="370" y="93" class="h" text-anchor="middle">order</text>
|
||||
|
||||
<rect x="300" y="200" width="140" height="56" rx="8" fill="#e7f4ea" stroke="#4a9d63"/>
|
||||
<text x="370" y="233" class="h" text-anchor="middle">inventory</text>
|
||||
|
||||
<rect x="80" y="200" width="140" height="56" rx="8" fill="#e7f4ea" stroke="#4a9d63"/>
|
||||
<text x="150" y="233" class="h" text-anchor="middle">shipping</text>
|
||||
|
||||
<rect x="520" y="200" width="160" height="56" rx="8" fill="#e7f4ea" stroke="#4a9d63"/>
|
||||
<text x="600" y="226" class="h" text-anchor="middle">notification</text>
|
||||
|
||||
<!-- inventory -> order -->
|
||||
<line x1="370" y1="200" x2="370" y2="118" stroke="#4a9d63" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="378" y="160" class="lbl">listens to OrderPlaced</text>
|
||||
|
||||
<!-- shipping -> inventory -->
|
||||
<line x1="190" y1="210" x2="300" y2="217" stroke="#4a9d63" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="190" y="200" class="lbl">listens to StockReserved</text>
|
||||
|
||||
<!-- notification -> order -->
|
||||
<path d="M 600 200 C 600 120, 450 90, 440 88" fill="none" stroke="#4a9d63" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="460" y="60" class="lbl">listens to OrderPlaced</text>
|
||||
|
||||
<!-- notification -> shipping -->
|
||||
<path d="M 520 225 C 350 290, 240 260, 222 232" fill="none" stroke="#4a9d63" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="330" y="300" class="lbl">listens to ShipmentScheduled</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 z" fill="#4a9d63"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<text x="20" y="300" class="c">order has zero outgoing arrows. Nothing it does depends on inventory, shipping, or notification — by design.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
|
||||
<p>That diagram is not something I drew by hand and hoped matches the code. It is the
|
||||
real output of Spring Modulith’s own <code>Documenter</code>, reading this
|
||||
repository’s bytecode and generating a PlantUML component diagram and a
|
||||
per-module canvas — see the accordion near the end of this post for the raw
|
||||
PlantUML and the full captured transcript. Every arrow is a <code>"listens to"</code>
|
||||
relationship the tool found by inspecting <code>@ApplicationModuleListener</code>
|
||||
method parameters, not something I asserted.</p>
|
||||
|
||||
<p>Notice the direction. <code>inventory</code> and <code>notification</code> both
|
||||
point at <code>order</code>, and <code>notification</code> also points at
|
||||
<code>shipping</code>. <code>order</code> points at nothing. That is not an accident of
|
||||
this particular app; it is the whole design goal, and it is the thing that gets broken
|
||||
in the next section.</p>
|
||||
|
||||
|
||||
<h2>The test that makes the diagram enforceable</h2>
|
||||
|
||||
<p>The diagram above is only worth anything if something fails the build the moment it
|
||||
stops being true. That something is two lines:</p>
|
||||
|
||||
<pre><code class="language-java">class ModularityTests {
|
||||
|
||||
ApplicationModules modules = ApplicationModules.of(Application.class);
|
||||
|
||||
@Test
|
||||
void verifiesModuleStructure() {
|
||||
modules.verify();
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java">ModularityTests.java</a></p>
|
||||
|
||||
<p><code>ApplicationModules.of(Application.class)</code> scans the bytecode reachable
|
||||
from the application’s main package and builds the module model described above.
|
||||
<code>verify()</code> walks every type dependency it finds — constructor
|
||||
parameters, fields, method bodies, the lot — and throws
|
||||
<code>org.springframework.modulith.core.Violations</code>, a <code>RuntimeException</code>,
|
||||
the moment one of them crosses a module boundary it should not. Put it in a JUnit test
|
||||
and it becomes a build-breaking architecture check that runs on every commit, with zero
|
||||
ArchUnit rules to hand-write.</p>
|
||||
|
||||
<p>The app itself needs nothing special to make this work. Here is the whole bootstrap
|
||||
class:</p>
|
||||
|
||||
<pre><code class="language-java">@SpringBootApplication
|
||||
@Modulithic(systemName = "Order Fulfillment")
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/main/java/com/ankurm/modulithdemo/Application.java">Application.java</a>
|
||||
— <code>@Modulithic</code> is optional (it names the system for the generated
|
||||
documentation) but <code>ApplicationModules.of()</code> works with a plain
|
||||
<code>@SpringBootApplication</code> class just as well.</p>
|
||||
|
||||
<p>The Maven coordinates for all of this are one BOM import and two starters —
|
||||
one for the main app, one for the tests:</p>
|
||||
|
||||
<pre><code class="language-xml"><dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-bom</artifactId>
|
||||
<version>2.1.1</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-starter-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies></code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/pom.xml">pom.xml</a></p>
|
||||
|
||||
|
||||
<h2>Breaking it on purpose</h2>
|
||||
|
||||
<p>Here is the shortcut a real developer takes under real time pressure. Before placing
|
||||
an order, <code>OrderManagement</code> wants to know whether there is enough stock.
|
||||
The honest way is to ask <code>inventory</code>’s public API. The fast way, if you
|
||||
already know the schema and you are in a hurry, is to inject
|
||||
<code>inventory.internal.StockRepository</code> directly and query it yourself:</p>
|
||||
|
||||
<pre><code class="language-java">public class OrderManagement {
|
||||
|
||||
private final OrderRepository orders;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
// the shortcut: read inventory's table directly instead of asking inventory
|
||||
private final StockRepository stockShortcut;
|
||||
|
||||
public OrderManagement(OrderRepository orders,
|
||||
ApplicationEventPublisher events,
|
||||
StockRepository stockShortcut) {
|
||||
this.orders = orders;
|
||||
this.events = events;
|
||||
this.stockShortcut = stockShortcut;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Order placeOrder(String sku, int quantity) {
|
||||
stockShortcut.findBySku(sku); // "it's all one database anyway"
|
||||
var order = orders.save(new Order(sku, quantity, "PLACED"));
|
||||
events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
|
||||
return order;
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<p>This compiles. It runs. If you call the endpoint by hand, you will not see anything
|
||||
wrong. The only thing standing between this code and production is the test from the
|
||||
previous section — and here is exactly what it says when it runs against this
|
||||
version of the code:</p>
|
||||
|
||||
<pre><code class="language-console">org.springframework.modulith.core.Violations:
|
||||
- Cycle detected: Slice inventory ->
|
||||
Slice order ->
|
||||
Slice inventory
|
||||
2. Dependencies of Slice order
|
||||
- Field <...OrderManagement.stockShortcut> has type <...inventory.internal.StockRepository>
|
||||
- Method <...OrderManagement.placeOrder(String, int)> calls method
|
||||
<...inventory.internal.StockRepository.findBySku(String)>
|
||||
- Module 'order' depends on non-exposed type
|
||||
com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'!
|
||||
|
||||
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 -- BUILD FAILURE</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/01-verify-violation-failed.txt">output/01-verify-violation-failed.txt</a></p>
|
||||
|
||||
<p>One field produced two separate violations in the same run, and they are worth
|
||||
telling apart, because they have different fixes.</p>
|
||||
|
||||
<p>The second one — <code>"depends on non-exposed type"</code> — is the
|
||||
one most people expect: <code>StockRepository</code> lives under
|
||||
<code>inventory.internal</code>, and nothing outside <code>inventory</code> is allowed
|
||||
to reference it, public interface or not.</p>
|
||||
|
||||
<p>The first one — <code>"Cycle detected: Slice inventory -> Slice order
|
||||
-> Slice inventory"</code> — is the one that catches people out, and it is the
|
||||
subject of the rest of this section. <code>inventory</code> already depends on
|
||||
<code>order</code>: its <code>@ApplicationModuleListener</code> method takes
|
||||
<code>OrderPlaced</code> as a parameter, and that is a real dependency edge as far as
|
||||
Spring Modulith is concerned, identical in kind to a constructor argument. Adding a
|
||||
field that points the other way — <code>order</code> depending on anything in
|
||||
<code>inventory</code> — closes a two-module cycle, and Spring Modulith treats
|
||||
the module graph as a strict DAG by default. A cycle fails verification even before the
|
||||
tool asks whether the specific type you reached for happens to be internal.</p>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>The instinct to fix this by "going through the public API" is natural, and it is wrong here.</strong> Watch what happens next.</blockquote>
|
||||
|
||||
<p>The obvious-looking fix is to leave the field, but point it at
|
||||
<code>InventoryManagement</code> — inventory’s real, intentional public
|
||||
API — instead of the internal repository:</p>
|
||||
|
||||
<pre><code class="language-java">public class OrderManagement {
|
||||
|
||||
private final OrderRepository orders;
|
||||
private final ApplicationEventPublisher events;
|
||||
private final InventoryManagement inventory; // the real public API this time
|
||||
|
||||
public OrderManagement(OrderRepository orders,
|
||||
ApplicationEventPublisher events,
|
||||
InventoryManagement inventory) {
|
||||
this.orders = orders;
|
||||
this.events = events;
|
||||
this.inventory = inventory;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Order placeOrder(String sku, int quantity) {
|
||||
if (!inventory.isInStock(sku, quantity)) {
|
||||
throw new IllegalStateException("Not enough stock for " + sku);
|
||||
}
|
||||
var order = orders.save(new Order(sku, quantity, "PLACED"));
|
||||
events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
|
||||
return order;
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<p>Run the same test against this version:</p>
|
||||
|
||||
<pre><code class="language-console">org.springframework.modulith.core.Violations: - Cycle detected: Slice inventory ->
|
||||
Slice order ->
|
||||
Slice inventory
|
||||
2. Dependencies of Slice order
|
||||
- Field <...OrderManagement.inventory> has type <...inventory.InventoryManagement>
|
||||
- Method <...OrderManagement.placeOrder(String, int)> calls method
|
||||
<...inventory.InventoryManagement.isInStock(String, int)>
|
||||
|
||||
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 -- BUILD FAILURE
|
||||
|
||||
(No "non-exposed type" complaint this time -- InventoryManagement really is public.
|
||||
Only "Cycle detected" remains.)</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/03-cycle-via-public-api-still-failed.txt">output/03-cycle-via-public-api-still-failed.txt</a></p>
|
||||
|
||||
<p>The non-exposed-type complaint is gone, exactly as expected —
|
||||
<code>InventoryManagement</code> is a perfectly legitimate public type. The cycle is
|
||||
still there, because the cycle was never about which types are public. It is about
|
||||
direction, and the direction was already spoken for the moment
|
||||
<code>inventory</code> chose to listen for <code>OrderPlaced</code>.</p>
|
||||
|
||||
|
||||
<figure role="img" aria-label="Adding a dependency from order back to inventory, even through inventory's public API, closes a two-node cycle that ApplicationModules.verify() rejects.">
|
||||
<svg viewBox="0 0 740 260" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.t{font:600 15px sans-serif;fill:#2b2f38}
|
||||
.h{font:600 13px sans-serif;fill:#2b2f38}
|
||||
.c{font:12px sans-serif;fill:#5b6270}
|
||||
.lbl{font:11px sans-serif;fill:#5b6270}
|
||||
.lbl2{font:11px sans-serif;fill:#c56a54}
|
||||
</style>
|
||||
<rect x="0" y="0" width="740" height="260" fill="#ffffff"/>
|
||||
<text x="20" y="28" class="t">Closing the loop: order ↔ inventory</text>
|
||||
|
||||
<rect x="140" y="80" width="150" height="56" rx="8" fill="#e8eefc" stroke="#5b7fc7"/>
|
||||
<text x="215" y="113" class="h" text-anchor="middle">order</text>
|
||||
|
||||
<rect x="450" y="80" width="150" height="56" rx="8" fill="#e7f4ea" stroke="#4a9d63"/>
|
||||
<text x="525" y="113" class="h" text-anchor="middle">inventory</text>
|
||||
|
||||
<!-- inventory -> order, existing, solid green -->
|
||||
<path d="M 450 100 C 380 60, 310 60, 292 95" fill="none" stroke="#4a9d63" stroke-width="2" marker-end="url(#arrowg)"/>
|
||||
<text x="330" y="55" class="lbl">listens to OrderPlaced (already there)</text>
|
||||
|
||||
<!-- order -> inventory, new, dashed red -->
|
||||
<path d="M 292 125 C 360 165, 420 165, 448 128" fill="none" stroke="#c56a54" stroke-width="2.5" stroke-dasharray="5 4" marker-end="url(#arrowr)"/>
|
||||
<text x="330" y="185" class="lbl2">calls InventoryManagement.isInStock(...) (the "fix")</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowg" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 z" fill="#4a9d63"/>
|
||||
</marker>
|
||||
<marker id="arrowr" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 z" fill="#c56a54"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<text x="20" y="225" class="c">Routing the second arrow through InventoryManagement — a real public type — removes the</text>
|
||||
<text x="20" y="243" class="c">"non-exposed type" violation but not the cycle. verify() still fails: two arrows, two directions, one pair of modules.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
|
||||
<p>The only fix that actually works is to not add the second edge. <code>order</code>
|
||||
publishes <code>OrderPlaced</code> and stops caring what happens to it:</p>
|
||||
|
||||
<pre><code class="language-java">@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"));
|
||||
events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
|
||||
return order;
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/OrderManagement.java">OrderManagement.java</a>
|
||||
(final version)</p>
|
||||
|
||||
<pre><code class="language-console">Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in ModularityTests
|
||||
BUILD SUCCESS
|
||||
|
||||
Same four modules, same ApplicationModules.verify() call, zero violations. The only
|
||||
change from output/01 is that OrderManagement no longer depends on anything in
|
||||
inventory, in either direction.</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/02-verify-passed.txt">output/02-verify-passed.txt</a></p>
|
||||
|
||||
<p>If inventory genuinely cannot satisfy an order, that has to travel back as a new
|
||||
event <code>order</code> deliberately chooses to listen for — a second,
|
||||
intentional relationship, not a shortcut around the first one. Keeping that
|
||||
back-channel from becoming a cycle of its own is a big enough topic that it gets its
|
||||
own post later in this series, on the Saga pattern.</p>
|
||||
|
||||
<div><details>
|
||||
<summary><strong>Going deeper: NamedInterface and open modules — when a module needs to expose more than its base package</strong></summary>
|
||||
<div style="padding:12px 4px 4px 4px">
|
||||
<p>Sometimes a module genuinely needs to expose more than flat types in its base
|
||||
package — an SPI sub-package meant for one or two trusted callers, say. Spring
|
||||
Modulith has two escape hatches, both narrower than they look:</p>
|
||||
<p><code>@NamedInterface("spi")</code> on a <code>package-info.java</code> inside
|
||||
<code>inventory.spi</code> promotes that one sub-package to additional public API,
|
||||
referenced from other modules’ <code>@ApplicationModule(allowedDependencies =
|
||||
"inventory :: spi")</code> declarations. It is scoped and explicit — exactly one
|
||||
extra package, named in the dependency declaration of whoever is allowed to use it.</p>
|
||||
<p><code>@ApplicationModule(type = Type.OPEN)</code> on the module’s
|
||||
<code>package-info.java</code> is the blunt version: every internal type in that module
|
||||
becomes visible to everyone. The reference documentation is direct about this not being
|
||||
recommended for a well-modularized application, and after watching
|
||||
<code>verify()</code> catch the <code>StockRepository</code> shortcut above, it should
|
||||
be obvious why — <code>OPEN</code> does not fix the design problem, it just turns
|
||||
off the thing that was telling you about it.</p>
|
||||
<p>Neither of these would have saved the <code>order</code> → <code>inventory</code>
|
||||
shortcut in this post. They widen who can see a module’s internals; they do
|
||||
nothing about a cycle, which is a statement about direction, not visibility.</p>
|
||||
</div>
|
||||
</details></div>
|
||||
|
||||
|
||||
<h2>How the cascade actually runs underneath</h2>
|
||||
|
||||
<p>Once <code>order</code> publishes <code>OrderPlaced</code> and gets out of the way,
|
||||
three independent listeners pick it up. Here is <code>inventory</code>’s:</p>
|
||||
|
||||
<pre><code class="language-java">@Service
|
||||
public class InventoryManagement {
|
||||
|
||||
private final StockRepository stock;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
public boolean isInStock(String sku, int quantity) {
|
||||
return stock.findBySku(sku).map(s -> s.getAvailable() >= quantity).orElse(false);
|
||||
}
|
||||
|
||||
@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()));
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/main/java/com/ankurm/modulithdemo/inventory/InventoryManagement.java">InventoryManagement.java</a></p>
|
||||
|
||||
<p><code>@ApplicationModuleListener</code> looks like a plain marker annotation. It is
|
||||
not. Reading the actual compiled class file — not the reference docs, the
|
||||
bytecode — shows exactly what it expands to:</p>
|
||||
|
||||
<pre><code class="language-console">$ javap -v org/springframework/modulith/events/ApplicationModuleListener.class
|
||||
|
||||
RuntimeVisibleAnnotations:
|
||||
0: Async
|
||||
1: Transactional(propagation=Propagation.REQUIRES_NEW)
|
||||
2: TransactionalEventListener</code></pre>
|
||||
|
||||
<p>So <code>@ApplicationModuleListener</code> is <code>@Async</code> +
|
||||
<code>@Transactional(propagation = REQUIRES_NEW)</code> +
|
||||
<code>@TransactionalEventListener</code>, stacked as meta-annotations on one
|
||||
declaration. That combination is the whole mechanism:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>@TransactionalEventListener</strong>, default phase <code>AFTER_COMMIT</code>:
|
||||
the listener only runs once the transaction that published the event has actually
|
||||
committed. Publish inside a transaction that rolls back, and the listener never fires
|
||||
— which is correct, and also means publishing an event from code with no active
|
||||
transaction at all silently does nothing. (<code>Scenario.publish()</code>, from the
|
||||
test library, wraps the publish in its own transaction for exactly this reason.)</li>
|
||||
<li><strong>@Async</strong>: the listener runs on a separate thread, after the
|
||||
publishing method has already returned. <code>OrderManagement.placeOrder()</code>
|
||||
finishes and gives the caller an <code>Order</code> back long before
|
||||
<code>inventory</code> has reserved anything.</li>
|
||||
<li><strong>@Transactional(REQUIRES_NEW)</strong>: the listener gets its own
|
||||
transaction, independent of whatever was happening when the event was published. If the
|
||||
listener fails, it cannot roll back the order that triggered it.</li>
|
||||
</ul>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>Do not add your own @Transactional on top of this.</strong> It looks like harmless extra documentation of intent. It is not. A plain @Transactional (default propagation REQUIRED) stacked on an @ApplicationModuleListener method breaks Spring's own listener registration, and the whole ApplicationContext refuses to start:</blockquote>
|
||||
<pre><code class="language-console">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(OrderPlaced)
|
||||
at org.springframework.transaction.annotation
|
||||
.RestrictedTransactionalEventListenerFactory.createApplicationListener(...)
|
||||
|
||||
Caused by: BeanInitializationException: Failed to process @EventListener annotation
|
||||
on bean with name 'inventoryManagement'</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/05-redundant-transactional-failure.txt">output/05-redundant-transactional-failure.txt</a></p>
|
||||
<p>This is a fail-fast mistake, at least — the context simply will not come up, so
|
||||
it cannot ship silently. It is still worth knowing before it costs you a confused ten
|
||||
minutes staring at a stack trace three frames removed from the line you actually
|
||||
wrote.</p>
|
||||
|
||||
<p>Put the three listeners together and one <code>publishEvent()</code> call fans out
|
||||
into four separate invocations, each on its own thread, in causal order but with no
|
||||
ordering guarantee between independent branches:</p>
|
||||
|
||||
|
||||
<figure role="img" aria-label="Timeline: placing an order publishes OrderPlaced, which inventory and notification react to independently; inventory's reservation then triggers shipping, which notification reacts to a second time.">
|
||||
<svg viewBox="0 0 740 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.t{font:600 15px sans-serif;fill:#2b2f38}
|
||||
.h{font:600 12px sans-serif;fill:#2b2f38}
|
||||
.c{font:11px sans-serif;fill:#5b6270}
|
||||
.m{font:11px monospace;fill:#2b2f38}
|
||||
</style>
|
||||
<rect x="0" y="0" width="740" height="300" fill="#ffffff"/>
|
||||
<text x="20" y="26" class="t">One publishEvent() call, four listener invocations</text>
|
||||
|
||||
<!-- lanes -->
|
||||
<text x="20" y="60" class="h">order</text>
|
||||
<text x="20" y="120" class="h">inventory</text>
|
||||
<text x="20" y="180" class="h">shipping</text>
|
||||
<text x="20" y="240" class="h">notification</text>
|
||||
<line x1="110" y1="60" x2="720" y2="60" stroke="#e2e5ea"/>
|
||||
<line x1="110" y1="120" x2="720" y2="120" stroke="#e2e5ea"/>
|
||||
<line x1="110" y1="180" x2="720" y2="180" stroke="#e2e5ea"/>
|
||||
<line x1="110" y1="240" x2="720" y2="240" stroke="#e2e5ea"/>
|
||||
|
||||
<circle cx="150" cy="60" r="6" fill="#5b7fc7"/>
|
||||
<text x="150" y="42" class="m" text-anchor="middle">placeOrder()</text>
|
||||
|
||||
<line x1="150" y1="60" x2="300" y2="120" stroke="#4a9d63" stroke-dasharray="2 3"/>
|
||||
<circle cx="300" cy="120" r="6" fill="#4a9d63"/>
|
||||
<text x="300" y="102" class="m" text-anchor="middle">on(OrderPlaced)</text>
|
||||
|
||||
<line x1="150" y1="60" x2="300" y2="240" stroke="#4a9d63" stroke-dasharray="2 3"/>
|
||||
<circle cx="300" cy="240" r="6" fill="#4a9d63"/>
|
||||
<text x="300" y="262" class="m" text-anchor="middle">on(OrderPlaced)</text>
|
||||
|
||||
<line x1="300" y1="120" x2="480" y2="180" stroke="#4a9d63" stroke-dasharray="2 3"/>
|
||||
<circle cx="480" cy="180" r="6" fill="#4a9d63"/>
|
||||
<text x="480" y="162" class="m" text-anchor="middle">on(StockReserved)</text>
|
||||
|
||||
<line x1="480" y1="180" x2="630" y2="240" stroke="#4a9d63" stroke-dasharray="2 3"/>
|
||||
<circle cx="630" cy="240" r="6" fill="#4a9d63"/>
|
||||
<text x="630" y="262" class="m" text-anchor="middle">on(ShipmentScheduled)</text>
|
||||
|
||||
<text x="20" y="290" class="c">Dashed lines are @ApplicationModuleListener hops — each runs async, in its own REQUIRES_NEW transaction, on its own thread.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
|
||||
<p>Running the whole chain for real, from a test that boots all four modules together,
|
||||
produces exactly that:</p>
|
||||
|
||||
<pre><code class="language-console">INFO [task-2] NotificationManagement : Order 1 received for 3x WIDGET-1
|
||||
INFO [task-4] NotificationManagement : Order 1 is on its way (WIDGET-1)
|
||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/06-event-flow-passed.txt">output/06-event-flow-passed.txt</a></p>
|
||||
|
||||
<p>Getting that test to run at all — and the very specific way it fails the first
|
||||
time — is its own lesson about a different Spring Modulith default, and it is
|
||||
worth a section of its own.</p>
|
||||
|
||||
|
||||
<h2>Testing an event cascade that crosses modules it does not depend on</h2>
|
||||
|
||||
<p><code>@ApplicationModuleTest</code> is Spring Modulith’s replacement for
|
||||
<code>@SpringBootTest</code> in a single module’s tests: instead of booting the
|
||||
entire application, it boots just the module under test, plus whichever other modules
|
||||
<code>mode</code> tells it to include. <code>BootstrapMode.ALL_DEPENDENCIES</code>
|
||||
sounds like exactly what you want for an integration test that follows an event across
|
||||
three modules. Here is the first version of that test, written the way the name
|
||||
suggests it should work:</p>
|
||||
|
||||
<p class="c">(annotation: <code>@ApplicationModuleTest(mode =
|
||||
BootstrapMode.ALL_DEPENDENCIES)</code> on a test in the <code>order</code> package,
|
||||
publishing <code>OrderPlaced</code> and waiting for <code>ShipmentScheduled</code>)</p>
|
||||
|
||||
<pre><code class="language-console">Bootstrapping @ApplicationModuleTest for Order in mode ALL_DEPENDENCIES
|
||||
# Order
|
||||
> Base package: com.ankurm.modulithdemo.order
|
||||
> Direct module dependencies: none
|
||||
> Spring beans:
|
||||
+ ?.OrderController
|
||||
+ ?.OrderManagement
|
||||
o ?.internal.OrderRepository
|
||||
|
||||
org.awaitility.core.ConditionTimeoutException: ... expected the predicate to return
|
||||
<true> but it returned <false> for input of <[]> within 10 seconds.</code></pre>
|
||||
<p class="c">Full transcript: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/04-event-flow-missing-dependency.txt">output/04-event-flow-missing-dependency.txt</a></p>
|
||||
|
||||
<p>Ten seconds of nothing, then a timeout. The log line that explains it is easy to
|
||||
miss on a first read: <strong><code>Direct module dependencies: none</code></strong>.
|
||||
<code>ALL_DEPENDENCIES</code> bootstraps the modules the module under test <em>depends
|
||||
on</em> — the modules it imports from. It does not infer the reverse: modules that
|
||||
depend on <em>it</em>, by listening for its events. <code>order</code> imports nothing
|
||||
from <code>inventory</code>, <code>shipping</code>, or <code>notification</code> —
|
||||
correctly, as the whole first half of this post just finished establishing — so
|
||||
from <code>order</code>’s point of view, in this test, those three modules do not
|
||||
exist. <code>InventoryManagement</code> is never instantiated. The event is published
|
||||
into a context with no listener to receive it, and the test waits for something that
|
||||
was never going to arrive.</p>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>ALL_DEPENDENCIES follows your code's import graph, not your event graph.</strong> If the thing you want to test is a reaction to an event — which, in an event-driven module boundary, is most of what there is to test — you have to say so explicitly.</blockquote>
|
||||
|
||||
<p>The fix is <code>extraIncludes</code>, which names modules to bootstrap regardless of
|
||||
whether the module under test has a code dependency on them:</p>
|
||||
|
||||
<pre><code class="language-java">@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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/test/java/com/ankurm/modulithdemo/order/OrderFulfillmentIntegrationTests.java">OrderFulfillmentIntegrationTests.java</a></p>
|
||||
|
||||
<p>With <code>inventory</code>, <code>shipping</code>, and <code>notification</code>
|
||||
named explicitly, their listener beans exist, the cascade actually runs, and this is
|
||||
the test that produced the passing transcript in the previous section. The
|
||||
<code>Scenario</code> API itself — <code>publish(event).andWaitForEventOfType(X.class)
|
||||
.matching(...).toArriveAndVerify(...)</code> — is Spring Modulith’s
|
||||
Awaitility-backed way of asserting on an async, eventually-consistent chain without a
|
||||
hand-rolled <code>Thread.sleep()</code>.</p>
|
||||
|
||||
|
||||
<h2>What the defaults do not do</h2>
|
||||
|
||||
<p>Everything so far has been about getting the happy path to compile, pass, and run.
|
||||
Two defaults are worth knowing about before this goes anywhere near production, because
|
||||
neither one fails loudly.</p>
|
||||
|
||||
<p>Every <code>@ApplicationModuleListener</code> gets one row in the event publication
|
||||
registry — a table Spring Modulith creates for you (via
|
||||
<code>spring-modulith-starter-jpa</code> or <code>-jdbc</code>) the moment an event is
|
||||
published, before the listener even runs:</p>
|
||||
|
||||
|
||||
<figure role="img" aria-label="Event publication lifecycle: PUBLISHED moves to PROCESSING, then COMPLETED on success or FAILED on exception; FAILED can be resubmitted back to PROCESSING. A staleness monitor that would otherwise rescue a stuck row is disabled by default.">
|
||||
<svg viewBox="0 0 740 260" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.t{font:600 15px sans-serif;fill:#2b2f38}
|
||||
.h{font:600 12px sans-serif;fill:#2b2f38}
|
||||
.c{font:11px sans-serif;fill:#5b6270}
|
||||
</style>
|
||||
<rect x="0" y="0" width="740" height="260" fill="#ffffff"/>
|
||||
<text x="20" y="26" class="t">One row per @ApplicationModuleListener, per event</text>
|
||||
|
||||
<rect x="30" y="70" width="140" height="48" rx="8" fill="#fdeccf" stroke="#c9973f"/>
|
||||
<text x="100" y="99" class="h" text-anchor="middle">PUBLISHED</text>
|
||||
|
||||
<rect x="230" y="70" width="140" height="48" rx="8" fill="#fdeccf" stroke="#c9973f"/>
|
||||
<text x="300" y="99" class="h" text-anchor="middle">PROCESSING</text>
|
||||
|
||||
<rect x="430" y="20" width="140" height="48" rx="8" fill="#e7f4ea" stroke="#4a9d63"/>
|
||||
<text x="500" y="49" class="h" text-anchor="middle">COMPLETED</text>
|
||||
|
||||
<rect x="430" y="120" width="140" height="48" rx="8" fill="#f7d9d3" stroke="#c56a54"/>
|
||||
<text x="500" y="149" class="h" text-anchor="middle">FAILED</text>
|
||||
|
||||
<rect x="630" y="120" width="95" height="48" rx="8" fill="#fdeccf" stroke="#c9973f"/>
|
||||
<text x="677" y="142" class="h" text-anchor="middle" font-size="11">RESUBMITTED</text>
|
||||
|
||||
<line x1="170" y1="94" x2="230" y2="94" stroke="#c9973f" stroke-width="2" marker-end="url(#a3)"/>
|
||||
<line x1="370" y1="85" x2="430" y2="55" stroke="#4a9d63" stroke-width="2" marker-end="url(#a4)"/>
|
||||
<text x="375" y="60" class="c">listener returns</text>
|
||||
<line x1="370" y1="105" x2="430" y2="135" stroke="#c56a54" stroke-width="2" marker-end="url(#a5)"/>
|
||||
<text x="375" y="135" class="c">listener throws</text>
|
||||
<line x1="570" y1="144" x2="630" y2="144" stroke="#c9973f" stroke-width="2" marker-end="url(#a6)"/>
|
||||
<path d="M 677 120 C 677 40, 600 20, 575 35" fill="none" stroke="#c9973f" stroke-width="2" stroke-dasharray="3 3" marker-end="url(#a7)"/>
|
||||
<text x="585" y="15" class="c" font-size="10">on retry success</text>
|
||||
|
||||
<defs>
|
||||
<marker id="a3" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#c9973f"/></marker>
|
||||
<marker id="a4" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#4a9d63"/></marker>
|
||||
<marker id="a5" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#c56a54"/></marker>
|
||||
<marker id="a6" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#c9973f"/></marker>
|
||||
<marker id="a7" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#c9973f"/></marker>
|
||||
</defs>
|
||||
|
||||
<rect x="30" y="190" width="690" height="50" rx="6" fill="#f4f5f7" stroke="#b7bec9" stroke-dasharray="3 3"/>
|
||||
<text x="45" y="210" class="c">A crash between PUBLISHED and PROCESSING leaves a row stuck. The staleness monitor that would</text>
|
||||
<text x="45" y="228" class="c">mark it FAILED is off by default — all three spring.modulith.events.staleness.* durations default to zero.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
|
||||
<p>That row is what makes the registry useful at all — it is how a listener that
|
||||
threw an exception can be found and resubmitted later, and how a process that died
|
||||
between "published" and "handled" does not just lose the event. But two defaults blunt
|
||||
that guarantee more than the reference documentation’s tone suggests:</p>
|
||||
|
||||
<ul>
|
||||
<li>The default completion mode is <code>UPDATE</code>: completed rows are marked
|
||||
complete and left in the table, forever, growing without bound unless something purges
|
||||
them. <code>DELETE</code> removes them (and loses the ability to query completed
|
||||
publications); <code>ARCHIVE</code> moves them to a separate table first, which is
|
||||
usually the one you actually want.</li>
|
||||
<li>The staleness monitor — the background task that would notice a row stuck in
|
||||
<code>PUBLISHED</code> or <code>PROCESSING</code> after a crash and mark it
|
||||
<code>FAILED</code> so it becomes eligible for resubmission — is disabled by
|
||||
default. All three <code>spring.modulith.events.staleness.*</code> durations default to
|
||||
zero, which this library treats as "off", not "immediately stale".</li>
|
||||
</ul>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>A crash at exactly the wrong moment leaves a row nobody is looking for, by default.</strong> Set the staleness durations and a non-UPDATE completion mode before this matters in production:</blockquote>
|
||||
<pre><code class="language-yaml">spring.modulith.events.staleness.published=PT5M
|
||||
spring.modulith.events.staleness.processing=PT5M
|
||||
spring.modulith.events.staleness.resubmitted=PT5M
|
||||
spring.modulith.events.completion-mode=ARCHIVE</code></pre>
|
||||
|
||||
<p>None of this is a defect in Spring Modulith — it is a reasonable default for a
|
||||
library that has to work the same way whether you are prototyping on an in-memory H2
|
||||
database or running a Kafka-backed outbox in production. It is, however, exactly the
|
||||
kind of default that an architecture diagram does not show you, which is the theme of
|
||||
this whole post applied one layer down: the tool that verifies your module boundaries
|
||||
has its own quiet defaults, and they reward the same treatment — read the actual
|
||||
behavior, not the one-paragraph summary.</p>
|
||||
|
||||
|
||||
<h2>Generated documentation, not hand-maintained diagrams</h2>
|
||||
|
||||
<p>The module diagram earlier in this post, and the arrows in it, came from running
|
||||
this:</p>
|
||||
|
||||
<pre><code class="language-java">@Test
|
||||
void writesDocumentation() {
|
||||
new Documenter(modules)
|
||||
.writeModulesAsPlantUml()
|
||||
.writeIndividualModulesAsPlantUml()
|
||||
.writeModuleCanvases()
|
||||
.writeAggregatingDocument();
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/test/java/com/ankurm/modulithdemo/ModularityTests.java">ModularityTests.java</a>
|
||||
(the <code>writesDocumentation()</code> test)</p>
|
||||
|
||||
<p><code>Documenter</code> reads the same <code>ApplicationModules</code> model
|
||||
<code>verify()</code> checks and writes a PlantUML component diagram, one PlantUML
|
||||
diagram per module showing just that module’s direct dependencies, a tabular
|
||||
"canvas" per module (Spring beans, grouped by stereotype), and an Asciidoc file linking
|
||||
all of it together — to <code>target/spring-modulith-docs</code> by default.</p>
|
||||
|
||||
<div><details>
|
||||
<summary><strong>The raw generated output for this app</strong></summary>
|
||||
<div style="padding:12px 4px 4px 4px">
|
||||
<pre><code class="language-console">Rel(Notification, Order, "listens to")
|
||||
Rel(Shipping, Inventory, "listens to")
|
||||
Rel(Inventory, Order, "listens to")
|
||||
Rel(Notification, Shipping, "listens to")
|
||||
|
||||
module-order.adoc:
|
||||
|Spring components
|
||||
|_Controllers_
|
||||
* c.a.m.o.OrderController
|
||||
_Services_
|
||||
* c.a.m.o.OrderManagement</code></pre>
|
||||
<p>Full transcript, including every generated <code>.puml</code> and <code>.adoc</code>
|
||||
file: <a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/output/07-generated-docs.txt">output/07-generated-docs.txt</a>. Note that the diagram the generator
|
||||
produces is a C4-style PlantUML file meant for a build pipeline to render —
|
||||
this post hand-draws the equivalent SVG instead, to match the rest of the
|
||||
site, but the relationships in it are copied directly from this generated
|
||||
output, not invented.</p>
|
||||
</div>
|
||||
</details></div>
|
||||
|
||||
<p>The practical value of this is less "pretty diagrams" and more "diagrams that cannot
|
||||
quietly go stale", because they are regenerated from the actual compiled module
|
||||
structure on every build, the same build that is already failing if that structure
|
||||
violates its own rules.</p>
|
||||
|
||||
|
||||
<h2>One more failure, worth a sentence, because it will happen to you too</h2>
|
||||
|
||||
<p>Before any of the module-boundary content above, the very first time this
|
||||
repository’s tests ran against a real database, they failed on something that
|
||||
has nothing to do with Spring Modulith at all:</p>
|
||||
|
||||
<pre><code class="language-console">org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement
|
||||
"create table [*]order (id bigint ..., primary key (id))"; expected "identifier"</code></pre>
|
||||
|
||||
<p>Hibernate defaults a JPA entity’s table name to the class’s simple name.
|
||||
<code>Order</code> the Java class becomes <code>order</code> the table, and
|
||||
<code>ORDER</code> is a reserved SQL keyword — the <code>ORDER BY</code> clause
|
||||
— in H2, Postgres, MySQL, and the SQL standard generally. The fix is one
|
||||
annotation:</p>
|
||||
|
||||
<pre><code class="language-java">@Entity
|
||||
@Table(name = "orders") // ORDER is a reserved SQL keyword (ORDER BY)
|
||||
public class Order {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
private String sku;
|
||||
private int quantity;
|
||||
private String status;
|
||||
}</code></pre>
|
||||
<p class="c"><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/src/main/java/com/ankurm/modulithdemo/order/internal/Order.java">Order.java</a></p>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>If you are naming an entity after a core domain concept in an order-processing system, check it against your database's reserved-word list before the schema generator does it for you.</strong> ORDER, GROUP, USER, and DATE have all caught people this way at least once.</blockquote>
|
||||
|
||||
|
||||
<h2>Should you reach for this?</h2>
|
||||
|
||||
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>Spring Modulith is worth adding the moment more than one person is working in the same Spring Boot codebase and "please don't reach into that package" has ever been said out loud in a code review.</strong> It is not worth adding to a single-developer side project, or to a monolith you are about to split into services anyway — in that case, write the ArchUnit rules for the specific boundary you are about to cut, run them for a few weeks, and extract. ApplicationModules.verify() earns its keep as a standing, automatically-discovered rule set across a whole codebase over months and years, not as a one-time check before a big refactor.</blockquote>
|
||||
|
||||
<p>The next post in this series uses this same repository to work through the question
|
||||
that follows naturally from everything above: once your modules are genuinely decoupled
|
||||
and verified, when — if ever — does it actually pay to split them into
|
||||
separate services? And two posts after that, the Transactional Outbox post comes back
|
||||
to the event publication registry diagram above and does something with the completion
|
||||
states this one only described: externalizing events to Kafka, and replaying the ones
|
||||
that got stuck.</p>
|
||||
|
||||
<h3>Further reading</h3>
|
||||
<ul>
|
||||
<li><a href="https://ankurm.com/git.app/asmhatre/spring-modulith-demo/src/branch/main/order-fulfillment/README.md">Companion repository README</a> — versions, module table, and an index of every captured transcript</li>
|
||||
<li><a href="https://docs.spring.io/spring-modulith/reference/fundamentals.html" rel="nofollow">Spring Modulith reference — Fundamentals</a></li>
|
||||
<li><a href="https://docs.spring.io/spring-modulith/reference/events.html" rel="nofollow">Spring Modulith reference — Application Events</a></li>
|
||||
<li><a href="https://github.com/spring-projects/spring-modulith/releases/tag/2.1.1" rel="nofollow">Spring Modulith 2.1.1 release notes</a></li>
|
||||
</ul>
|
||||
|
||||
<!-- /wp:html -->
|
||||
Reference in New Issue
Block a user