Enterprise Java

Spring Modulith 2.0: Enforcing Module Boundaries Before Microservices

Most Java teams hit a wall sooner or later. The monolith that felt manageable at launch gradually turns into a tangled mess where a change in the order package somehow breaks inventory, and nobody is quite sure why. At that point, the instinct is to reach for microservices. More often than not, that instinct fires too early — and teams end up with a distributed monolith that combines the worst of both worlds.

There is, however, a third path. Spring Modulith lets you enforce clear, verifiable domain boundaries inside a single deployable unit. With the 2.0 GA release that shipped in November 2025 alongside Spring Boot 4, the tooling took a major step forward. This article walks through what changed, how ApplicationModules.verify() actually works, and when you should reach for event publication instead of direct calls.

Spring Modulith 2.0 GA was released on 21 November 2025, built on Spring Boot 4 and Spring Framework 7. It is the first major version of the project and incorporates all learnings from the 1.x generation.

Why teams jump to microservices too soon

The appeal of microservices is real. Independent deployments, isolated failure domains, team autonomy — those are genuinely valuable properties. The problem is that you pay for them upfront, in operational complexity, network overhead, and distributed transaction pain, even when your team is five engineers and your traffic fits on a single box.

What most teams actually need, especially early on, is not deployment independence but code independence: a way to work on the orders domain without accidentally stepping on the inventory domain. That is precisely what a modular monolith gives you. Furthermore, when you do eventually need to extract a service, clean module boundaries make that extraction dramatically cheaper. The domain lines are already drawn.

The distributed monolith trap: Splitting a tangled codebase into separate services without first untangling it just moves the coupling across a network. You now have all the complexity of distributed systems and none of the benefits of clean isolation.

What Spring Modulith actually does

Spring Modulith is, at its core, a toolkit that maps your Java package structure to a domain module model and then enforces that model. It works by treating every direct sub-package of your application’s root package as a module. Anything declared inside a sub-package (say com.example.app.inventory.internal) is automatically private to that module. Other modules can only access types that are explicitly part of the module’s public API, meaning types that live directly in the root package of that module, or are annotated with @NamedInterface.

The verification is powered by ArchUnit under the hood. That means boundaries are checked at test time, not just at runtime, and violations produce clear, actionable error messages telling you exactly which class is accessing which internal type across a module boundary.

Setting up the dependency

To get started, add the Spring Modulith starter to your Spring Boot 4 project. The BOM handles version alignment:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>2.0.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Package structure: the rule of one level down

The package arrangement Spring Modulith expects is straightforward. Your main class sits in the root package; every direct child package becomes a module:

com.example.shop
├── ShopApplication.java          ← root; main class lives here
├── order                         ← "order" module
│   ├── OrderManagement.java      ← public API (in root of module)
│   └── internal
│       └── OrderRepository.java  ← private; other modules cannot see this
├── inventory                     ← "inventory" module
│   ├── InventoryService.java     ← public API
│   └── internal
│       └── StockItem.java        ← private
└── customer                      ← "customer" module
    └── CustomerApi.java          ← public API

Notice that OrderRepository lives in order.internal. That single directory level is what makes it invisible to the rest of the application. No annotations needed; the package location is the boundary.

Running ApplicationModules.verify()

The most important thing Spring Modulith gives you is a single line of test code that audits your entire architecture. Add it to your test suite and it will run in every CI build:

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

class ModularStructureTests {

    @Test
    void verifiesModularStructure() {
        ApplicationModules.of(ShopApplication.class).verify();
    }
}

If any module accesses an internal type from another module, the test throws an exception with a message along these lines:

org.springframework.modulith.core.Violations:
- Module 'customer' depends on non-exposed type
  com.example.shop.order.internal.OrderRepository
  within module 'order'!
  CustomerService declares field CustomerService(OrderRepository)
  in (CustomerService.java:14)

That is an exact file and line number pointing to the violation. There is no ambiguity, and there is no way to ignore it silently — the build breaks. This is a fundamentally different guarantee from a coding convention or an architecture document that nobody reads.

New in 2.0: Structural verification can now also run on application startup, not just in tests. Enable it with spring.modulith.detection-strategy=direct-sub-packages and optionally spring.modulith.verification-on-startup=enabled in your application.properties.

Declaring explicit allowed dependencies

Sometimes you want to go further and explicitly whitelist which modules a given module is allowed to depend on. You do that via package-info.java:

// src/main/java/com/example/shop/order/package-info.java
@ApplicationModule(allowedDependencies = {"inventory", "customer"})
package com.example.shop.order;

import org.springframework.modulith.ApplicationModule;

With this in place, if the order module later imports something from, say, the payment module, verify() will catch it immediately. The allowed dependencies list becomes living documentation of your architecture, checked automatically on every commit.

Chart: architecture complexity over time

Relative architectural complexity — unstructured monolith vs. modular monolith vs. microservices

Choosing between events and direct calls

Once your module boundaries are defined, you still need to decide how modules communicate. Spring Modulith gives you two mechanisms, and the choice between them matters a lot for maintainability and future extractability.

AspectDirect callEvent publication
CouplingCaller depends on callee’s public APIPublisher depends only on the event class; subscriber is independent
Transaction behaviourParticipates in caller’s transactionRuns after commit by default with @ApplicationModuleListener
ReliabilitySynchronous; failure propagates immediatelyPersisted to Event Publication Registry; survives crashes
Extractability to microservicesRequires API contract changesSwap Spring event for Kafka/AMQP topic with minimal code change
DebuggabilitySimple stack traceRequires understanding async flow; more log entries needed
When to preferQuery results needed synchronously; tight business invariantsSide effects; cross-domain reactions; anything that can be eventual

A practical rule of thumb: if module A needs a result from module B in order to continue its own business logic, a direct call to a public API method is correct. If module A simply needs to notify the rest of the system that something happened — an order was placed, a payment was confirmed — that is a natural event.

Publishing and consuming events

Publishing a domain event requires nothing more than Spring’s built-in ApplicationEventPublisher:

// Inside the order module — com/example/shop/order/OrderManagement.java
@Service
@RequiredArgsConstructor
public class OrderManagement {

    private final ApplicationEventPublisher events;
    private final OrderRepository repository;

    @Transactional
    public void completeOrder(Order order) {
        repository.save(order.complete());
        events.publishEvent(new OrderCompleted(order.getId()));
    }
}
// Inside the inventory module — com/example/shop/inventory/InventoryListener.java
@Component
public class InventoryListener {

    @ApplicationModuleListener
    void on(OrderCompleted event) {
        // runs after the order transaction commits
        // updates stock levels for event.orderId()
    }
}

The @ApplicationModuleListener annotation, which replaced the older @ApplicationEventListener in 2.0, combines @EventListener@Async, and @Transactional(propagation = REQUIRES_NEW) in one. It ensures the handler runs after the publisher’s transaction commits, in its own separate transaction. That means a failure in the inventory listener never rolls back the order — which is almost always what you want.

Event Publication Registry: Spring Modulith 2.0 ships a fully revamped Event Publication Registry that persists unpublished events to the database (JDBC, JPA, MongoDB, or Neo4j). If the application crashes between publishing and handling, the event is redelivered on restart. This gives you the transactional outbox pattern essentially for free.

New in 2.0: what actually changed

The 2.0 release, which went GA in November 2025, is not just a version bump. Several meaningful changes arrived alongside the Spring Boot 4 baseline.

FeatureChange in 2.0
Event Publication RegistryFull lifecycle overhaul with new states (submitted, started, completed, failed) and a configurable staleness monitor for stuck publications
Startup verificationModule structure can now be verified when the Spring context boots, catching violations at runtime not just in CI
Module-specific Flyway migrationsEach module can now declare its own Flyway migration scripts, keeping database schema evolution co-located with domain logic
VerificationOptions APICustom ArchUnit rules can be plugged in alongside built-in checks; hexagonal architecture verification is more lenient out of the box
Jackson 3 supportEvent serialization now works with Jackson 3, required by Spring Boot 4
IDE toolingIntelliJ IDEA and VS Code now surface module violations inline, with quick-fix options to expose types via @NamedInterface or open the module
Deprecated removed@ApplicationEventListener is gone; use @ApplicationModuleListener exclusively

Chart: event publication states in the 2.0 registry

Event publication lifecycle states — time spent in each state under normal operation (illustrative distribution)

@NamedInterface: exposing exactly what you mean to expose

By default, anything in a module’s root package is visible to other modules. However, as modules grow, you often want to keep some types internal even if they sit in the root package. The @NamedInterface annotation lets you define a precisely scoped API surface. You apply it either directly to a type or to a package via package-info.java. Other modules then reference it explicitly:

// package-info.java inside inventory module
@NamedInterface("api")
package com.example.shop.inventory.api;

import org.springframework.modulith.NamedInterface;
// orders module declares it depends only on inventory's "api" named interface
@ApplicationModule(allowedDependencies = "inventory::api")
package com.example.shop.order;

import org.springframework.modulith.ApplicationModule;

The double-colon syntax (inventory::api) means “only the types exposed through the api named interface of the inventory module.” That is a much tighter contract than “all public types in inventory,” and consequently it is much safer to refactor behind.

Testing individual modules in isolation

Another practical advantage of explicit module boundaries is faster, more focused tests. Instead of bootstrapping the entire application context for every integration test, @ApplicationModuleTest loads only the module under test and its declared dependencies:

@ApplicationModuleTest
class OrderModuleTests {

    @Test
    void completingAnOrderPublishesEvent(
            @Autowired OrderManagement orders,
            PublishedEvents events) {

        orders.completeOrder(SomeOrder.sample());

        assertThat(events.ofType(OrderCompleted.class)).hasSize(1);
    }
}

Dependencies on other modules are automatically mocked. The result is a test that boots in a fraction of the time of a full-context test, while still validating the module’s behaviour in a realistic Spring environment. This additionally makes it immediately obvious when a module’s test pulls in too many external collaborators — a sign that the module boundary might need rethinking.

When is a modular monolith the right call?

Spring Modulith is not the right answer for every team. It is, however, the right default for a wider range of situations than most teams assume.

Start with a modular monolith if you are building a new product and your first priority is moving fast without creating irreversible architectural decisions. The boundaries you define with Spring Modulith will make a future microservices extraction considerably less painful because the seams are already there, already tested, and already documented. Equally, if you are refactoring a legacy codebase, ApplicationModules.verify() is one of the most powerful tools available: it lets you define the desired boundaries and then progressively fix violations without having to do a big-bang rewrite.

On the other hand, if different parts of your system genuinely need to scale independently — and by independently we mean scaling a specific component by 100x while others stay flat — or if you have truly autonomous teams that need separate deployment pipelines with no coordination, microservices may be the correct long-term answer. The point is not that microservices are wrong; it is that they are rarely the right starting point.

A practical migration path: Start with a Spring Modulith application. Add spring-modulith-events-api and use events for cross-module communication from day one. When the time comes to extract a module, swap ApplicationEventPublisher for a Kafka producer — the consumer side barely changes because @ApplicationModuleListener already had the right transactional semantics.

Generating living documentation

One underrated feature of Spring Modulith is that the same module model used for verification can also generate architectural documentation. A single test produces PlantUML component diagrams and an Application Module Canvas — essentially a structured table describing each module’s dependencies, events, and public API. The documentation is generated from the actual code, which means it cannot drift out of sync the way a manually maintained architecture diagram always does.

@Test
void writeDocumentationSnippets() {
    var modules = ApplicationModules.of(ShopApplication.class).verify();
    new Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml();
    // output goes to target/modulith-docs/
}

This is particularly useful when onboarding new team members. Instead of sending them a wiki page, you point them at the generated canvas, which reflects exactly what is in production at that moment.

What we have learned

In this article, we explored why the leap to microservices is so often premature and how Spring Modulith 2.0 provides a disciplined alternative. We saw that the package-based module detection model turns a simple directory structure into an enforced architectural contract. The ApplicationModules.verify() method — backed by ArchUnit — catches boundary violations at test time and, new in 2.0, at application startup. We walked through when to choose event publication over direct calls: events are the right choice for side effects and eventual consistency, direct calls for queries and tight transactional invariants. We also covered the major 2.0 additions: the revamped Event Publication Registry with its full lifecycle model and staleness monitor, module-specific Flyway migrations, IDE tooling for IntelliJ and VS Code, and the removal of deprecated APIs. Finally, we saw how @ApplicationModuleTest and the built-in documentation generator help teams maintain a modular codebase that can evolve gracefully — whether it stays a monolith or eventually becomes a set of microservices.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button