Monoliths get painful as internal coupling grows, but splitting into microservices is often overkill. Many teams find themselves stuck between these two extremes. The “modular monolith” has emerged as a middle-ground solution.

In this article, we’ll use Spring Modulith 1.x to walk through the whole package — declaring package boundaries, verifying them, event-driven communication between modules, and persistence — end to end.

Positioning Modular Monoliths and Spring Modulith

In a traditional monolith, internal classes can call each other freely, and dependencies inevitably turn into spaghetti if left unchecked. Microservices, on the other hand, give you independent deployments but bring along operational cost and the complexity of network boundaries.

The modular monolith sits in between. It’s a design where the deployment unit stays as one, but the internal code is divided by strong boundaries.

Spring Modulith is an official Spring team project that provides the following four capabilities out of the box:

  • Package-level boundary declarations
  • Boundary violation verification
  • Event-driven communication between modules
  • Automatic generation of architecture documentation

If you want to understand ApplicationEvent basics first, reading Event-Driven Design with ApplicationEvent in Spring Boot beforehand will speed things up.

Prerequisites and Dependencies

Spring Modulith 1.x requires Spring Boot 3.x and Java 17 or later. Using the BOM to align versions is the safest approach.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>1.2.0</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-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

starter-core handles module detection and boundary verification, starter-jpa handles event persistence, and starter-test provides testing support.

Declaring Package Boundaries with @ApplicationModule

Spring Modulith automatically recognizes subpackages directly beneath the main application class as individual modules. For example, everything under com.example.app.order becomes the order module as a whole.

Meta information goes in package-info.java.

@org.springframework.modulith.ApplicationModule(
    displayName = "Order Module",
    allowedDependencies = {"shared"}
)
package com.example.app.order;

By narrowing allowedDependencies, you can explicitly restrict references to other modules. By default, subpackages named internal cannot be referenced from outside, while everything else is treated as that module’s public API.

If you want finer control, you can specify the scope of what’s exposed with @NamedInterface.

Detecting Boundary Violations with ApplicationModules.verify()

Declaring boundaries doesn’t enforce them on its own. Verify them continuously through tests.

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

class ModularityTests {

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

It’s just three lines, but it throws an exception when there’s a direct dependency on an internal package or a reference to a module not listed in allowedDependencies. The exception message tells you specifically which class violated which module, so you can pinpoint the fix without guessing.

If you wire this test into CI, boundary violations that slip past code review will be automatically stopped.

Loose Coupling Between Modules with @ApplicationModuleListener

Replace inter-module communication from “direct calls” to “events.” The publishing side is simple.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher events;

    @Transactional
    public void placeOrder(OrderRequest req) {
        // ... persist the order
        events.publishEvent(new OrderCreated(req.orderId()));
    }
}

The receiving side uses @ApplicationModuleListener.

@Component
class InventoryEventHandler {

    @org.springframework.modulith.ApplicationModuleListener
    void on(OrderCreated event) {
        // allocate inventory
    }
}

This annotation is a shortcut combining @TransactionalEventListener(AFTER_COMMIT), @Async, and @Transactional. After the publishing side’s transaction commits, the handler runs asynchronously in a separate transaction.

As a result, the Order module doesn’t need to import any classes from the Inventory module.

Persisting Events with the Event Publication Registry

Asynchronous processing always carries the risk of event loss. Spring Modulith addresses this with a mechanism called the Event Publication Registry.

Adding spring-modulith-starter-jpa automatically generates an event_publication table, where published events are recorded as rows per listener. When a listener completes successfully, completion_date is filled in; if it fails, the row remains incomplete.

Incomplete events are automatically redelivered when the application restarts. If you want to retry at arbitrary intervals, you can write it like this:

@Component
@RequiredArgsConstructor
class EventRetryJob {
    private final IncompleteEventPublications incomplete;

    @Scheduled(fixedDelay = 60_000)
    void retry() {
        incomplete.resubmitIncompletePublications(p -> true);
    }
}

This is similar in spirit to Transactional Messaging with the Outbox Pattern, but the big difference is that no external message broker is required — everything stays self-contained within the monolith.

Migration Steps from an Existing ApplicationEvent Implementation

Projects already built with ApplicationEventPublisher and @EventListener can migrate incrementally in the following order:

  1. Reorganize the package structure into feature-based units and place package-info.java in each root
  2. Run ApplicationModules.verify() to surface boundary violations, then clean up the internals first
  3. Replace @EventListener with @ApplicationModuleListener
  4. Add spring-modulith-starter-jpa to enable event persistence

One thing to watch out for is circular dependencies. Module A subscribing to B’s events while B subscribes to A’s events is allowed, but if compile-time dependencies become bidirectional, verify will fail. Placing event types in a shared module (e.g., shared) is the safer approach.

Generating Module Diagrams with Documenter

Design diagrams tend to rot alongside the code, but Modulith generates them automatically.

@Test
void writeDocumentation() {
    var modules = ApplicationModules.of(Application.class);
    new org.springframework.modulith.docs.Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml()
        .writeDocumentation();
}

The output is in PlantUML and AsciiDoc, including module dependency diagrams and event flow diagrams. If you store the generated artifacts in CI, your documentation will always stay current.

When to Adopt It and When to Avoid It

It suits teams of around 5 to 30 people, working on growing services where domain boundaries are starting to emerge. It also pairs well with internal partitioning of multi-tenant SaaS — combining it with the Multi-Tenancy Implementation Guide makes things even easier to organize.

Conversely, it’s overinvestment for teams that are geographically or temporally distributed and require deployment independence, or for short-lived PoCs built by a handful of people.

Even if you want to keep the option of splitting into microservices later, the boundaries you’ve organized with Modulith can be reused directly as the split lines, so the effort rarely goes to waste. That’s another welcome point.

Summary

Spring Modulith provides the five-piece set of “declare boundaries, verify them, connect with events, persist, and document” as a standard part of Spring. Since you can adopt it incrementally while leveraging your existing ApplicationEvent assets, it’s worth trying as a realistic step before jumping straight to microservices.