Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs

This commit is contained in:
2026-10-03 18:45:37 +00:00
commit 866eceed0a
34 changed files with 1149 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
target/
*.class
.idea/
*.iml
.DS_Store
post/
+76
View File
@@ -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.
+89
View File
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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));
}
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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> {
}
@@ -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) {
}
@@ -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()));
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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());
}
});
}
}
+29
View File
@@ -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>