What Spring Modulith is and how it infers application modules

Spring Modulith is a toolkit for building a modular monolith in Spring Boot: one deployable artifact whose code is divided into modules with explicit, checked boundaries. It does not add a runtime container or a new deployment model. It reads your existing package structure, builds a model of the application's modules, and lets you run checks, tests, and documentation generation against that model.

How modules are inferred

The starting point is the package that holds your @SpringBootApplication class. Spring Modulith treats each direct sub-package of that package as an application module. Suppose the main class lives in com.example.shop:

  • com.example.shop.order is the order module
    • OrderService, OrderCompleted: public types at the module root form its API
    • com.example.shop.order.internal: repositories, mappers, and helpers
  • com.example.shop.inventory is the inventory module
    • InventoryService: API
    • com.example.shop.inventory.internal: implementation

By default, other modules may use only the types in a module's base package. Anything in a nested package is internal, even when it is declared public so Java can wire it together within the module.

Who benefits, and why

This suits teams that ship a single deployable but want the separation they would otherwise get from splitting into services. Package conventions and code review rarely hold for long. One reference from inventory into order.internal saves time under a deadline, the next developer copies that pattern, and the boundary is soon gone. Spring Modulith turns those boundaries into executable checks that fail the build when someone violates them.

package com.example.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

// Assumed layout (ShopApplication is the @SpringBootApplication class):
//   com.example.shop.ShopApplication
//   com.example.shop.order                 -> module "order" (OrderService, OrderCompleted = API)
//   com.example.shop.order.internal        -> internal to "order"
//   com.example.shop.inventory             -> module "inventory" (InventoryService = API)
//   com.example.shop.inventory.internal    -> internal to "inventory"
class ModularityTests {

    // Build the module model from the main application package.
    // Each direct sub-package of com.example.shop becomes one application module.
    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void printsDetectedModules() {
        // Shows the inferred modules, their base packages and dependencies,
        // so you can confirm the model matches your intended design.
        modules.forEach(System.out::println);
    }

    @Test
    void verifiesModuleBoundaries() {
        // Fails the build if, for example, inventory references a type in
        // com.example.shop.order.internal, or if modules form a dependency cycle.
        modules.verify();
    }
}

A JUnit test that builds Spring Modulith's module model from the main application class, prints the inferred modules, and fails the build when one module reaches into another module's internal packages.

Verifying module structure with ApplicationModules.verify()

The entry point is one test class. Inside a plain JUnit test, call ApplicationModules.of(Application.class), passing your @SpringBootApplication class, and then call .verify() on the result. You don't need a Spring context. The model comes from static analysis: Spring Modulith uses ArchUnit to scan the compiled classes under the application's root package. It treats each direct sub-package as an application module and records which types reference which.

verify() checks that model against two rules and throws an exception that lists every violation it finds.

  • Cycles between modules. If orders depends on inventory and inventory depends back on orders, neither can change or be extracted alone. The failure message shows the cycle path, so you can see which types close the loop.
  • Access to internal types. By default, only types in a module's base package are its API. Anything in a sub-package such as orders.internal is private to that module. A reference to it from another module fails verification, even when the Java access modifiers allow the call.

Because this is an ordinary test, CI runs it like any other. A pull request that adds a shortcut import turns the build red, and the reviewer doesn't have to notice it.

The limit is that verify() reads type-level dependencies from bytecode and nothing else. It won't detect two modules writing the same database table, reflective calls, beans looked up by string name, or modules that share configuration properties. Those couplings still need code review, and often a schema-ownership rule as well.

package com.example.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

// Plain JUnit test: no @SpringBootTest, no application context is started.
class ModularityTests {

    // Build the module model by statically analysing compiled classes under
    // com.example.shop. Each direct sub-package (orders, inventory, ...) becomes
    // an application module.
    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void verifiesModuleStructure() {
        // Throws if modules depend on each other in a cycle, or if one module
        // references a type in another module's sub-package (e.g. orders.internal).
        // All violations are reported in a single exception message.
        modules.verify();
    }

    @Test
    void printsDetectedModules() {
        // Optional: print the detected modules and their dependencies so you can
        // check that the model matches the boundaries you intended.
        modules.forEach(System.out::println);
    }
}

// Expected layout (the comments describe a violation verify() would report):
//   com.example.shop.ShopApplication          <- @SpringBootApplication
//   com.example.shop.orders.OrderService       <- API of 'orders'
//   com.example.shop.orders.internal.OrderRepo <- internal to 'orders'
//   com.example.shop.inventory.StockService    <- importing orders.internal.OrderRepo
//                                                 here fails the build in CI

A single JUnit test builds the ApplicationModules model from the @SpringBootApplication class and calls verify(), so module cycles and cross-module access to internal packages fail the test run in CI.

Declaring allowed dependencies and named interfaces

Package conventions are one layer of enforcement. Spring Modulith adds a second layer with annotations that let you state your intent.

Restricting which modules a module may use

Annotate a module's root package, in its package-info.java, with @ApplicationModule and list module names in allowedDependencies. For example, an order module might declare that it may depend only on inventory and customer. Verification then rejects any type reference into a module that is not on that list, even if the target type is public and sits in the target's root package. This matters because the default rule only protects module internals. It does nothing to stop the dependency graph from growing one convenient import at a time.

Exposing API from subpackages

A type outside a module's root package is internal by default. Sometimes you want to expose one, such as an SPI or a set of event types kept in a subpackage. Annotate that subpackage's package-info.java with @NamedInterface("spi"). Other modules may now use it. An allow-list can then name the interface rather than the whole module, using the inventory :: spi form. This narrows the contract to exactly what was published.

Reading a violation

Suppose order imports a class from inventory.internal. ApplicationModules.verify() then fails the test with a single exception that aggregates every violation found. Each entry names:

  • the source module,
  • the target module,
  • the offending type,
  • the specific dependency that triggered it (a field, a method parameter, or a supertype).

In CI output, read the entries one at a time. For each one, decide whether to move the type into the target's API, publish it through a named interface, or remove the dependency.

// --- src/main/java/com/example/order/package-info.java ---
// order may use only inventory's published 'spi' interface and the customer module.
@org.springframework.modulith.ApplicationModule(
    allowedDependencies = { "inventory :: spi", "customer" }
)
package com.example.order;

// --- src/main/java/com/example/inventory/spi/package-info.java ---
// Exposes this subpackage as a named interface. It would otherwise be internal.
@org.springframework.modulith.NamedInterface("spi")
package com.example.inventory.spi;

// --- src/test/java/com/example/ModularityTests.java ---
package com.example;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModularityTests {

    // Detects modules from the packages under Application's root package.
    ApplicationModules modules = ApplicationModules.of(Application.class);

    @Test
    void verifiesModuleBoundaries() {
        // Throws a single exception that aggregates every violation found.
        // Example of a deliberate break: a class in com.example.order that
        // imports com.example.inventory.internal.StockLedger. That target is
        // neither in the root package nor in the 'spi' named interface, so
        // this test fails in CI.
        modules.verify();
    }
}

Shows how order restricts its dependencies to inventory :: spi and customer, how inventory publishes a subpackage with @NamedInterface, and the test where ApplicationModules.verify() fails when the boundary is broken on purpose.

How it works: application events and the event publication registry

When the offending dependency is a direct call into another module's service, a third option is often cleaner: stop calling it. Have the order module publish an OrderCompleted event through ApplicationEventPublisher, and let the inventory module react with a transactional listener that runs after the publishing transaction commits. Spring Modulith's @ApplicationModuleListener combines this after-commit behavior with asynchronous execution in a new transaction. The publisher then has no compile-time dependency on the consumer.

On its own, an after-commit listener loses the event if it fails or the JVM stops. The event publication registry closes that gap. Inside the publishing transaction, it writes one entry per event-listener pair. When the listener succeeds, the registry marks that entry complete. When the listener fails, the entry stays incomplete so you can inspect it or resubmit it.

To enable the registry, add the starter that matches your persistence layer: JPA, JDBC or MongoDB. The relational variants need an EVENT_PUBLICATION table. The JDBC starter can create it, but most teams manage it through their usual migration tool.

Recovery approach Mechanism Fits when
Retry on restart spring.modulith.republish-outstanding-events-on-restart=true Single instance, rare failures
Explicit resubmission IncompleteEventPublications, filtered by age or predicate Multiple instances, operator control

Completed entries stay in the table until you remove them. Purge them on a schedule through CompletedEventPublications, or the table grows without bound.

These guarantees have consequences you need to design for:

  • Delivery is at-least-once. A listener can run more than once for the same event, so it must be idempotent.
  • Modules become eventually consistent. The consumer's state lags behind the publisher's commit.
  • Republish on restart does not coordinate across instances. In a multi-instance deployment, every instance that starts can republish the same incomplete entries, which causes duplicates or load spikes.
// order module: publish an event instead of calling inventory's service
@Service
class OrderService {
  private final ApplicationEventPublisher events;
  OrderService(ApplicationEventPublisher events) { this.events = events; }

  @Transactional
  void complete(Order order) {
    order.markCompleted();
    // The registry writes one EVENT_PUBLICATION entry per listener in this transaction
    events.publishEvent(new OrderCompleted(order.getId()));
  }
}

// inventory module: runs async, after commit, in its own transaction
@Component
class InventoryListener {
  private final InventoryService inventory;
  InventoryListener(InventoryService inventory) { this.inventory = inventory; }

  @ApplicationModuleListener // org.springframework.modulith.events
  void on(OrderCompleted event) {
    // At-least-once delivery: make a redelivered event a no-op
    if (inventory.isReservedFor(event.orderId())) return;
    inventory.reserveFor(event.orderId());
  } // normal return marks the entry complete; an exception leaves it incomplete
}

// Requires @EnableScheduling; in multi-instance setups run this on one node only
@Component
class EventPublicationMaintenance {
  private final IncompleteEventPublications incomplete;
  private final CompletedEventPublications completed;
  EventPublicationMaintenance(IncompleteEventPublications i, CompletedEventPublications c) { incomplete = i; completed = c; }

  @Scheduled(fixedDelay = 300_000) // explicit resubmission of stuck publications
  void resubmitStale() { incomplete.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(10)); }

  @Scheduled(cron = "0 0 3 * * *") // completed entries are kept until purged
  void purgeCompleted() { completed.deletePublicationsOlderThan(Duration.ofDays(7)); }
}

An order module publishes OrderCompleted, an idempotent @ApplicationModuleListener in the inventory module consumes it, and a scheduled job resubmits stale incomplete publications and purges completed ones.

Application module tests and asserting on published events

@ApplicationModuleTest bootstraps only the package of the test class as an application module. It does not start the whole application context. By default it runs in standalone mode, so beans from other modules are absent. A missing dependency then fails fast at startup. That failure is useful: it shows exactly which cross-module collaborators the module needs.

Choosing a bootstrap mode

Set bootstrapMode to DIRECT_DEPENDENCIES when the module's behaviour depends on real logic in its immediate neighbours. Use ALL_DEPENDENCIES only when the transitive graph is small. Otherwise, keep the default standalone mode and replace the other modules' beans with mocks through Spring's mock-bean support. Mock their exposed API types, not their internals, so the test depends on the same contract the production code uses.

Asserting on events

Declare a PublishedEvents or AssertablePublishedEvents parameter on a test method to capture every event published during that test. You can then filter the captured events by type and check their payloads. The Scenario API covers both directions of an event flow:

  • Command produces an event: stimulate(...) invokes the command, and andWaitForEventOfType(...) waits for the expected event.
  • Listener reacts to an event: publish(...) sends an event, and andWaitForStateChange(...) polls until the listener's side effect appears.

Both handle asynchronous listeners without manual sleeps.

Mocks encode your assumptions about the other module. If you mock most of the collaborators, a module test can pass while the real modules disagree on payload shape or transaction boundaries. Keep a small set of full @SpringBootTest tests that cover the critical end-to-end flows. Use them to catch wiring and integration faults that the module tests cannot detect by design.

Generating module documentation from the same model

The ApplicationModules instance you use for verification also drives documentation. Spring Modulith's Documenter takes that model and writes two kinds of output. Component diagrams show modules and the dependencies between them, either for the whole application or one diagram per module. Module canvases are tabular summaries of a module's contents, such as its exposed Spring beans, aggregate roots, published events, and events it listens to. By default the diagrams are PlantUML files and the canvases are Asciidoc, written to a directory under your build output.

Generate the docs from a plain unit test, next to the verify() test. No Spring context is needed, because the documenter reads the same static model the verifier inspects. The test runs on every build, so the diagrams describe the code you just compiled. They cannot describe the code as it was when someone last drew a diagram on a wiki. If a new dependency appears between orders and inventory, the diagram shows it in the same build where the change lands.

Publish the generated directory as a CI artifact on every pull request. Reviewers can then see structural effects that line-by-line diffs hide. A diff shows a new import. A component diagram shows that the import created a new edge between modules. It also shows a module that now listens to an event it never handled before. Rendering the PlantUML in the pipeline makes the artifact readable without local tooling. Diffing it against the main branch's artifact turns structural drift into something reviewers can discuss explicitly.

When to use or avoid it, alternatives, and impact on cost and engineering time

Spring Modulith fits Spring Boot monoliths that are new or already organized by feature, where each top-level package maps to a business domain. The module model is inferred from that package layout, so it matches the design only when the packages do. Layered codebases (controller, service, repository) produce one module per technical layer. Verification then reports either nothing useful or a cycle on every feature. Delay adoption until you have restructured by feature, and treat that move as a real refactor, not a configuration change.

Option Enforcement strength Effort Runtime cost
Spring Modulith Test-time checks plus a runtime event registry Low if packages are by feature Registry writes and table storage
Hand-written ArchUnit rules Test-time only, as strong as the rules you write Rules need ongoing upkeep None
Maven/Gradle multi-module Compile-time, hard to bypass Build restructuring and slower iteration across modules None
Microservices Network boundary High: deployment, observability, data ownership Network calls, infrastructure, operations
Broker-based messaging Contract at the broker Broker operations and schema management Broker infrastructure and latency

Internal events are Java types, not an external contract. Moving them to a broker adds serialization, schema evolution and ordering concerns that in-process listeners never had to handle.

The main cost drivers are:

  • Extra writes: the registry adds a write inside each publishing transaction for every transactional listener.
  • Table growth: completed entries accumulate unless you purge them.
  • Restructuring effort: described above.

The time savings come from three places. Boundary violations fail in CI instead of surfacing in code review. Module tests start only the slice they need. Module documentation regenerates on every build, so nobody has to keep it current by hand.

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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.