What Spring Modulith is and how it infers application modules
Spring Modulith does not add a container, a module loader or a new deployment model. Your application still starts as an ordinary Spring Boot application and ships as one artifact. Spring Modulith reads your code's package structure, builds a model of its application modules, and checks that model against a small set of rules. Treat it as a verification and design tool for a modular monolith in Java, not as a runtime framework.
The rules come from a package convention. Every direct sub-package of the package that holds your main application class is a module. A module's top-level package is its public API, so other modules may use the types declared there. Nested sub-packages are internal by default. Code outside the module should not reference them.
The convention only works if packages represent business capabilities. Compare a typical layered layout with a restructured one:
| Layer-organized | Capability-organized |
|---|---|
com.example.shop.controller |
com.example.shop.orders |
com.example.shop.service |
com.example.shop.orders.internal |
com.example.shop.repository |
com.example.shop.inventory |
com.example.shop.model |
com.example.shop.inventory.internal |
com.example.shop.shipping |
In the left column, the convention would produce "modules" such as service and repository. Every feature depends on all of them, so the model tells you nothing. In the right column, OrderRepository lives in orders.internal. If shipping reaches into it, the model can detect that as a violation.
The main beneficiaries are teams that run a single deployable whose internal coupling keeps growing. They get enforceable boundaries without having to split the system into separate services.
// Package layout this test inspects (capability-organized):
// com.example.shop.ShopApplication <- @SpringBootApplication class
// com.example.shop.orders <- module 'orders', public API
// com.example.shop.orders.internal <- internal to 'orders'
// com.example.shop.inventory <- module 'inventory', public API
// com.example.shop.inventory.internal <- internal to 'inventory'
// com.example.shop.shipping <- module 'shipping', public API
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
// Build the module model from the package structure below ShopApplication.
// Nothing is started at runtime; this only analyzes the compiled classes.
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void printsDetectedModules() {
// Lists each inferred module so you can confirm the convention
// produced orders, inventory and shipping rather than layer packages.
modules.forEach(System.out::println);
}
@Test
void verifiesModuleBoundaries() {
// Fails the build if, for example, a class in 'shipping' references
// OrderRepository in com.example.shop.orders.internal.
modules.verify();
}
}
A plain JUnit test that asks Spring Modulith to infer modules from the package tree and fail when one module reaches into another module's internal package.
Failing the build on module boundary violations
Enforcement starts with an ordinary unit test. Build an ApplicationModules model from your main application class with ApplicationModules.of(...) and call verify() on it. You don't need a Spring context, so the test runs in about the time it takes to scan your classes, and it fails the build on every run in CI.
Reading a violation
Suppose shipping imports inventory.internal.StockLevel, and inventory also calls a shipping service to schedule pickups. verify() throws an exception that lists each problem separately. The first entry says that shipping depends on a non-exposed type inside inventory, and it names both the source type and the target type. The second entry reports a cycle and lists the modules involved. Work through the entries one at a time. Fix the cycle first, because a cycle usually points to a responsibility in the wrong module, and moving that responsibility often removes the other violations too.
Making exceptions explicit
Sometimes the dependency is legitimate. In that case, declare it instead of moving types around until the test passes. Annotate a selected internal package with @NamedInterface to expose it as part of the module's contract without opening the whole module. On the consuming side, set allowedDependencies on @ApplicationModule to list the modules and named interfaces it may use. Once you do this, every cross-module edge appears in the source as a declaration, so a reviewer sees it in the diff.
These declarations can also erode the boundaries. A named interface that keeps growing, or an allowed-dependencies list that eventually names every module, passes verify() while enforcing nothing. Treat changes to these annotations the way you treat changes to a public API: they need a reviewer and a stated reason.
// src/test/java/com/example/shop/ModularityTests.java
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
// Plain JUnit test with no Spring context: it scans classes and fails on any boundary violation
@Test
void verifiesModuleStructure() {
ApplicationModules.of(ShopApplication.class).verify();
}
}
// src/main/java/com/example/shop/inventory/stock/package-info.java
// Expose only this sub-package of inventory as a named interface called "stock";
// the rest of inventory, including inventory.internal, stays hidden
@org.springframework.modulith.NamedInterface("stock")
package com.example.shop.inventory.stock;
// src/main/java/com/example/shop/shipping/package-info.java
// shipping may depend only on inventory's "stock" interface. Any other edge fails verify().
// Changes to this list need review, like any public API change.
@org.springframework.modulith.ApplicationModule(
allowedDependencies = { "inventory :: stock" }
)
package com.example.shop.shipping;
A unit test that calls ApplicationModules.verify(). It works with package-info declarations that expose one inventory sub-package as a named interface and limit shipping to that dependency.
How application events replace direct cross-module calls
Consider the starting point. OrderService.place() calls InventoryService.reserve() directly, so orders compiles against inventory and every new reaction to an order becomes another edit in orders. The refactor inverts that. Orders publishes an OrderPlaced event through ApplicationEventPublisher inside its transaction. Inventory listens for it.
Before: orders ──calls──▶ inventory
After: orders ──publishes──▶ OrderPlaced (owned by orders)
▲
inventory ──listens to─────┘
The arrow now points from inventory to orders. The publisher depends only on its own event type, so you can add or remove consumers without touching it.
With a plain @TransactionalEventListener, inventory runs after commit. By then the order is durable. If the listener throws, the order cannot be rolled back, and the reservation is simply lost. The listener also does not join the publisher's transaction, because that transaction is already completing. Writes made without a fresh transaction may never be committed.
@ApplicationModuleListener handles both problems in one annotation. It combines @Async, after-commit timing, and REQUIRES_NEW, so inventory does its work on another thread in its own transaction.
That still leaves a gap if the process dies before the listener finishes. The event publication registry closes it. Add a persistence starter, such as the JPA or JDBC variant, and each publication is written in the same transaction as the order. The registry then tracks it until a listener completes successfully. After a crash, it can find incomplete publications and resubmit them. Completed entries accumulate, so purge them periodically. Because resubmission can deliver the same event twice, listeners must be idempotent. For example, key each reservation on the order ID.
// orders module: publishes an event it owns, with no dependency on inventory
public record OrderPlaced(UUID orderId, String sku, int quantity) {}
@Service
class OrderService {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orders, ApplicationEventPublisher events) {
this.orders = orders;
this.events = events;
}
@Transactional
public void place(Order order) {
orders.save(order);
// With a registry starter on the classpath, the publication is stored in this same transaction
events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
}
}
// inventory module: depends on orders' event type, not on OrderService
@Component
class InventoryListener {
private final ReservationRepository reservations;
InventoryListener(ReservationRepository reservations) {
this.reservations = reservations;
}
// Runs async, after the order commits, in its own REQUIRES_NEW transaction
@ApplicationModuleListener
void on(OrderPlaced event) {
// Idempotency guard: a resubmitted publication must not reserve stock twice
if (reservations.existsByOrderId(event.orderId())) return;
reservations.save(new Reservation(event.orderId(), event.sku(), event.quantity()));
}
}
Orders publishes OrderPlaced inside its transaction. Inventory consumes it with @ApplicationModuleListener and an idempotency check, so a redelivery after resubmission does not double-reserve stock.
Testing modules in isolation and generating docs from the verified model
Annotating a test class with @ApplicationModuleTest starts a Spring context for only the module whose package contains the test. The default bootstrap mode is standalone, which loads just that module. You can widen it to direct dependencies or all dependencies when an integration path needs real collaborators. If a bean from another module is missing, the context fails to start. That failure shows a dependency you have not declared. To keep the test narrow, replace the missing bean with a mock. Smaller contexts also start faster, so module tests are cheap to run often.
The Scenario API, injected as a test method parameter, lets you write assertions in terms of events instead of internal calls:
- Action publishes an event. Call
stimulate(...)with an action, such as invoking a service method. Then chainandWaitForEventOfType(...), filter withmatching(...), and finish withtoArrive(). - Listener changes state. Call
publish(...)with an event. Then useandWaitForStateChange(...)with a supplier that queries the result, for example a repository lookup.
Both waits poll until the expected event or state appears, so asynchronous listeners need no hand-written sleeps or latches.
Documentation from the verified model
The same ApplicationModules instance that verify() checks can be passed to Documenter. It writes PlantUML component diagrams of module dependencies and Asciidoc module canvases. A canvas lists each module's exposed API, published and consumed events, and Spring components.
Generate these files in a test so they are rebuilt on every run. The diagrams then show the structure the build enforces, not what someone drew months ago, and no one has to update them by hand.
// src/test/java/example/order/OrderModuleTests.java (imports omitted)
@ApplicationModuleTest // default mode: STANDALONE, loads only the order module
class OrderModuleTests {
// Bean from the inventory module, mocked to keep the context narrow
@MockitoBean InventoryService inventory;
@Autowired OrderService orders;
@Autowired ShipmentRepository shipments;
@Test
void completingAnOrderPublishesEvent(Scenario scenario) {
scenario.stimulate(() -> orders.complete("order-42"))
.andWaitForEventOfType(OrderCompleted.class)
.matching(event -> event.orderId().equals("order-42"))
.toArrive(); // polls until the event appears; no sleeps or latches
}
@Test
void listenerCreatesShipment(Scenario scenario) {
scenario.publish(new OrderCompleted("order-42"))
// waits until the supplier returns a non-empty result
.andWaitForStateChange(() -> shipments.findByOrderId("order-42"))
.andVerify(shipment -> assertThat(shipment).isPresent());
}
}
// src/test/java/example/DocumentationTests.java
class DocumentationTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifyAndDocument() {
modules.verify(); // fail the build on boundary violations
new Documenter(modules)
.writeModulesAsPlantUml() // overall component diagram
.writeIndividualModulesAsPlantUml() // one diagram per module
.writeModuleCanvases(); // Asciidoc canvases
}
}
A standalone module test that uses the Scenario API to check an event is published and a listener changes state, plus a test that verifies the ApplicationModules model and generates PlantUML diagrams and module canvases from it.
Migrating an existing tangled codebase
Start by running the verification test locally and keep it out of the CI pipeline for now. verify() lists every violation it finds, and on a codebase that has grown without boundaries that list is your inventory: which packages reach into which, and where the cycles are. Treat it as a measurement, not a gate.
Next, group code into candidate module packages directly under the application's main package. If the codebase is organized by layer, with top-level controller, service and repository packages, this step is most of the work. Spring Modulith treats each direct subpackage as a module, so a layered layout produces modules that describe technical tiers rather than business capabilities. Verification will run, but its output will not tell you anything useful until code is regrouped by domain.
Fix cycles first. A cycle means two modules cannot be understood, tested or extracted separately, and every other fix is harder while it exists. Then look for the seams with the most violations. These are usually places where one module calls another to trigger a side effect. Replace those calls with published events, which turns a compile-time dependency into a published contract.
Move incrementally because each step stays reviewable and reversible. A single large restructuring touches everything at once, conflicts with ongoing feature work, and makes regressions hard to trace to a cause. Add the test to CI only once it passes, so it guards progress you have already made.
The cost comes early: restructuring time, larger diffs and debates about ownership. The return builds over time. Accidental coupling gets caught at build time instead of in review, and each module has a clearer owner.
When to avoid Spring Modulith and what it does not protect
Spring Modulith enforces boundaries through a test, not the compiler or the JVM. ApplicationModules.verify() fails the build only if the test runs. If someone skips the test, excludes it from CI, or uses reflection to reach an internal type, nothing stops them at compile time or runtime. A boundary that depends on a test is only as strong as your pipeline's refusal to skip it.
Events have costs too. Replacing a direct call with a published event removes the call site an engineer would otherwise follow with "go to definition." Debugging, onboarding and tracing all get harder, because you now have to find every listener for a type to see what happens. After-commit listeners run once the publishing transaction completes. Within a single process, the system is eventually consistent, and a listener failure no longer rolls back the original write. The event publication registry fixes lost events by storing each publication in a database table. Each event then costs an extra write, and completed entries pile up unless you set up retention or cleanup.
The verifier sees only type-level dependencies. Two modules can still read each other's tables through native queries, or map the same table to separate JPA entities, and the verification still passes. A shared schema is the most common coupling that goes undetected.
A modular monolith still deploys, scales and fails as one unit. A memory leak in one module takes down all of them. If you genuinely need independent deployment or fault isolation, module boundaries prepare you for a split. They do not replace one.
Alternatives: JPMS, separate build modules and microservices
Spring Modulith is not the only way to draw these lines. The alternatives trade more upfront cost for stronger guarantees.
| Mechanism | Enforcement | Structural cost | Independent deployment | Fault isolation | Operational overhead |
|---|---|---|---|---|---|
| Spring Modulith | Test-time verification of package conventions | Low: packages in one build unit | No | No | None added |
| JPMS modules | Compiler and runtime, via exports in module-info |
Medium: descriptors, opens for reflection |
No | No | Low |
| Separate Maven/Gradle modules | Compiler, via declared dependencies | Medium: one build module per boundary, often split into API and implementation | No | No | Low: longer build configuration |
| Separate services | Network boundary | High: remote contracts, data ownership | Yes | Yes | High: deployments, observability, distributed failure modes |
JPMS enforces boundaries even outside your tests. Its friction is reflection: Spring, JPA and serialization libraries need packages opened to them, and the descriptors must be maintained by hand. Separate build modules are the more common middle ground. A class cannot reference code the build did not put on its classpath, and both Maven and Gradle reject cyclic module dependencies. The cost is build complexity and slower restructuring when a boundary turns out to be wrong.
Spring Modulith's main advantage is cheap reversibility. Moving a class between packages is a refactor, while moving it between build modules or services is a project. Use it to find stable boundaries first. Promote a module to a build module when verification failures keep recurring. Extract it as a service only when it has a concrete need for its own deployment cadence, scaling profile or failure domain. Event-based communication between modules already gives that future service its integration seam.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring modulith tutorial, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.