Skip to content
All posts

Spring Modulith: Build a Modular Monolith in Spring Boot

By 9 min read

Most Spring Boot monoliths start tidy. A year later the order code reads the product tables directly, the product code imports an order DTO, and nobody can change one area without breaking another. Splitting into microservices fixes the coupling, but you pay for it with network calls, separate deployments and data that is only eventually consistent.

A modular monolith is the middle path: one deployable application, with boundaries inside it that are enforced rather than hoped for. Spring Modulith gives a Spring Boot app those boundaries, a test that fails when one is crossed, and documentation generated from the code. The examples come from my modulith repository, a small shop with catalog, orders and inventory modules on Spring Boot 3.4 and Spring Modulith 1.3. Spring Modulith 2.x, for Spring Boot 4, keeps every API shown here.

Modules are packages

Spring Modulith needs no new build structure. Every direct sub-package of your application’s main package is a module:

src/main/java
com.incognito.modulith
├── ModulithApplication.java
├── catalog
│   ├── Product.java            API
│   ├── ProductService.java     API
│   ├── domain/                 internal
│   └── web/                    internal
├── orders
│   ├── domain/                 internal
│   │   └── events/             named interface "order-events"
│   └── web/                    internal
├── inventory
│   └── InventoryService.java
└── common                      open module

Types in a module’s top-level package are its API. Everything in a sub-package is internal, even when it is declared public. That last part is the point: Java’s public means “visible to everyone”, and there is no keyword for “visible to my module only”. Spring Modulith adds that rule and checks it.

Spring Modulith module map: orders uses catalog's ProductService API, which is allowed; orders reaching into catalog's internal ProductRepository is rejected; inventory listens to the OrderCreatedEvent named interface
Solid arrows are allowed dependencies. The dashed one reaches into another module’s internals, and the verification test fails on it.

Add Spring Modulith to the build

build.gradle
dependencyManagement {
    imports {
        mavenBom "org.springframework.modulith:spring-modulith-bom:1.3.3"
    }
}

dependencies {
    implementation 'org.springframework.modulith:spring-modulith-starter-core'
    testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
}

Verify the boundaries in a test

One test turns the package conventions into rules the build enforces:

ModularityTests.java
class ModularityTests {

    static ApplicationModules modules = ApplicationModules.of(ModulithApplication.class);

    @Test
    void verifiesModularStructure() {
        modules.verify();
    }
}

verify() fails the test with a list of violations if:

  • two modules depend on each other in a cycle,
  • a module uses a type from another module’s internal packages, such as orders injecting catalog’s ProductRepository, or
  • a module depends on one it has not declared as allowed (see below).

Because it is a plain unit test, it runs on every build. A boundary violation shows up in a pull request instead of in an architecture review six months later.

Expose only what other modules need

The catalog module publishes an interface and a record at its top level. The implementation, the JPA entity and the repository all live in catalog.domain, where no other module can reach them:

catalog/ProductService.java
package com.incognito.modulith.catalog;

public interface ProductService {
    PagedResult<Product> getProducts(int pageNo);
    Optional<Product> getByCode(String code);
}
catalog/Product.java
package com.incognito.modulith.catalog;

public record Product(String code, String name, String description,
                      String imageUrl, BigDecimal price) {}

The orders module validates a new order against that API and nothing else. It never sees the ProductEntity or its table:

orders/domain/OrderService.java
private void validate(CreateOrderRequest request) {
    String code = request.item().code();
    Product product = productService.getByCode(code)
            .orElseThrow(() -> new InvalidOrderException("Product not found with code: " + code));
    if (product.price().compareTo(request.item().price()) != 0) {
        throw new InvalidOrderException("Product price mismatch");
    }
}

Returning a record instead of the entity matters. If catalog later splits its table or moves to another database, the Product record stays the same and orders never notices.

Named interfaces for types that live deeper

Sometimes a module needs to share a type that does not belong in its top-level package, such as events. A package-info.java with @NamedInterface exposes one sub-package under a name:

orders/domain/events/package-info.java
@org.springframework.modulith.NamedInterface("order-events")
package com.incognito.modulith.orders.domain.events;

Other modules may now use OrderCreatedEvent, but the rest of orders.domain stays internal.

Declare which modules a module may depend on

By default a module may use any other module’s API. To make the dependency graph explicit, list the allowed ones on the module’s base package. Anything not listed fails verify():

orders/package-info.java
@org.springframework.modulith.ApplicationModule(
    allowedDependencies = { "catalog" }
)
package com.incognito.modulith.orders;

To allow only a named interface, use the module :: interface form, for example "orders :: order-events". A small shared kernel, such as a PagedResult type every module returns, can be declared an open module, which exposes all of its packages:

common/package-info.java
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package com.incognito.modulith.common;

import org.springframework.modulith.ApplicationModule;

Talk through events instead of calls

Orders validates against catalog with a direct call, because it needs the answer before it can continue. Inventory is different: orders does not need to wait for it, and should not even know it exists. So orders publishes an event after saving:

orders/domain/OrderService.java
OrderEntity savedOrder = orderRepository.save(newOrder);
eventPublisher.publishEvent(new OrderCreatedEvent(
        savedOrder.getOrderNumber(),
        savedOrder.getOrderItem().code(),
        savedOrder.getOrderItem().quantity()));

and inventory listens:

inventory/InventoryService.java
@Service
class InventoryService {

    @EventListener
    void on(OrderCreatedEvent event) {
        log.info("Update inventory for product code: {}", event.productCode());
    }
}

The dependency now points from inventory to orders, not the other way round. Adding shipping or notifications later means adding a listener, not editing OrderService.

A plain @EventListener runs synchronously, inside the publisher’s transaction. If the inventory update throws, the order rolls back with it. Spring Modulith’s @ApplicationModuleListener and its event publication registry fix that. They are covered in Transactional Outbox with Spring Modulith Events.

Test one module at a time

@ApplicationModuleTest starts only the module the test class lives in, rather than the whole application. Beans from other modules are not created, so dependencies on them are mocked. The Scenario parameter lets a test trigger something and wait for the event it should publish:

orders/OrdersModuleTests.java
@ApplicationModuleTest
class OrdersModuleTests {

    @MockitoBean
    ProductService productService;       // catalog is not started

    @Autowired
    OrderService orderService;

    @Test
    void publishesOrderCreatedEvent(Scenario scenario) {
        when(productService.getByCode("P100")).thenReturn(Optional.of(
                new Product("P100", "The Hunger Games", "", "", new BigDecimal("34.00"))));

        var request = new CreateOrderRequest(
                new OrderItem("P100", "The Hunger Games", new BigDecimal("34.00"), 1),
                new Customer("Ayesha", "ayesha@example.com", "+8801700000000"));

        scenario.stimulate(() -> orderService.createOrder(request))
                .andWaitForEventOfType(OrderCreatedEvent.class)
                .matchingMappedValue(OrderCreatedEvent::productCode, "P100")
                .toArrive();
    }
}

@MockitoBean is the Spring Framework 6.2 replacement for Boot’s deprecated @MockBean. The stimulus runs in its own transaction, which commits, so clean up what it created (Scenario has an andCleanup(…) hook for this). The orders module still needs its database, so run this against a real PostgreSQL. The testing guide shows how with Testcontainers.

Generate documentation from the code

The same ApplicationModules model can write diagrams of the modules and their dependencies, so the architecture documentation is regenerated on every build instead of drifting out of date:

ModularityTests.java
@Test
void writeDocumentation() {
    new Documenter(modules).writeDocumentation();
}

This writes PlantUML component diagrams for the whole application and for each module, plus a “module canvas” per module listing its API, Spring beans, events published and events listened to. They go into a spring-modulith-docs folder under the build output directory.

Modular monolith or microservices?

Modular monolithMicroservices
DeployablesOneOne per service
Calls between partsIn-process method calls and eventsNetwork calls and messaging
ConsistencyLocal transactionsEventual, with sagas or outboxes
Boundaries enforced byTests like verify()Separate codebases and the network
ScalingThe whole applicationEach service on its own
Operational costLowHigh

For most teams, a modular monolith is the better place to start. If a module later needs to scale or deploy on its own, clean boundaries and event-based communication make extracting it a refactoring, not a rewrite.

Checklist

  • One top-level package per business capability.
  • Interfaces and records in the module’s base package; everything else below it.
  • ApplicationModules.of(App.class).verify() in the test suite.
  • allowedDependencies to make the dependency graph explicit, and named interfaces for shared events.
  • Events for anything the caller does not need to wait for.
  • @ApplicationModuleTest for module-level integration tests.
  • Generated documentation, regenerated on every build.