Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 097 / 100

Modular monoliths with Spring Modulith, explained simply

Microservice boundaries without microservice costs.

Expona Admin6 min read

The problem with monoliths and microservices

We've all lived through the monolith-to-microservices migration story. A Rails app or Spring Boot application starts clean, then gradually becomes a tangled ball of dependencies where a change to the user module breaks something three layers deep in the billing system. So we split into microservices. Now we're debugging network calls across three time zones, managing eventual consistency, and spending half our time on deployment infrastructure instead of features.

Modular monoliths with Spring Modulith, explained simply offers a third path: structure your monolith so clearly that it's almost a microservices architecture on the inside, without paying the operational cost of actual microservices. You get strong module boundaries, clear ownership, and the ability to extract a module into its own service later if you really need to. Until then, you have a single deployable, straightforward testing, and no network latency between your modules.

Modules are packages — Your first line of defense

In Spring Modulith, a module starts as a top-level package. That's it. Each module declares a public API (typically through an internal package or marker interfaces), and everything else is off-limits to the rest of your application.

Let's say we're building an e-commerce platform. We might structure it like this:

java
src/main/java/com/example/shop/
├── orders/
│   ├── internal/
│   │   ├── OrderRepository.java
│   │   ├── OrderFactory.java
│   │   └── OrderFulfillmentService.java
│   ├── api/
│   │   ├── OrderService.java
│   │   └── OrderDTO.java
│   └── OrderModule.java
├── inventory/
│   ├── internal/
│   │   ├── StockService.java
│   │   └── StockRepository.java
│   ├── api/
│   │   └── InventoryService.java
│   └── InventoryModule.java
├── payments/
│   ├── internal/
│   │   └── PaymentProcessor.java
│   ├── api/
│   │   └── PaymentService.java
│   └── PaymentModule.java
└── ShopApplication.java

The rule is simple: other modules can only call classes in the api package. Everything in internal belongs to that module alone. If the orders module needs inventory information, it asks the inventory API, not the inventory internals.

This forces you to think about contracts. What does the orders module actually expose? A small, deliberate API. Not a dumping ground of fifty public classes. The discipline of a package boundary is modest compared to a network boundary, but the psychological effect is powerful—your team stops treating the codebase like a global namespace.

Verify boundaries — Failing the build when rules break

Structure alone doesn't enforce itself. A junior developer pushes a commit where the orders module directly queries the inventory repository. Weeks later, you're debugging mysterious behavior because that private class changed. Spring Modulith solves this with automated verification.

Add the Spring Modulith dependency to your project:

java
// pom.xml
<dependency>
    <groupId>org.springframework.modulith</groupId>
lt;artifactId>spring-modulith-starter-core</artifactId>
<version>1.1.0</version>
</dependency>

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <version>1.1.0</version>
    <scope>test</scope>
</dependency>

Then write a simple test:

java
@ModuleTest
class ModuleStructureTests {

    @Test
    void shouldVerifyModularStructure(ApplicationModules modules) {
        modules.verify();
    }
}

That's it. The @ModuleTest annotation discovers your modules (top-level packages), scans for cross-module dependencies, and fails the test if any module imports from another module's internal package. Run this in your CI pipeline, and boundary violations get caught before they merge.

What does a failure look like? Clear and actionable:

java
Module 'orders' does not depend on module 'inventory'
  - orders.internal.OrderFulfillmentService imports
    inventory.internal.StockRepository (VIOLATES)

No guessing, no code review back-and-forth about "is this okay?" The build tells you: no, restructure. This keeps modules honest. Over months and years, as your codebase grows from three developers to twenty, that verification is invaluable.

Events between modules — Async boundaries without the network

Two modules need to talk, but you don't want hard dependencies. Orders module publishes an event when an order is placed. Inventory listens and decrements stock. Payments listens and captures a payment. In a tightly coupled monolith, you'd have method calls everywhere. In a microservices world, you'd use Kafka or RabbitMQ. With Spring Modulith, you use application events.

First, define the event:

java
// orders/api/OrderPlacedEvent.java
package com.example.shop.orders.api;

public record OrderPlacedEvent(
    String orderId,
    String customerId,
    List<LineItem> items,
    BigDecimal totalAmount
) {
    public static OrderPlacedEvent of(Order order) {
        return new OrderPlacedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getLineItems(),
            order.getTotalAmount()
        );
    }
}

The orders module publishes when an order is saved:

java
// orders/api/OrderService.java
package com.example.shop.orders.api;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;
    private final OrderRepository repository;

    public OrderService(ApplicationEventPublisher publisher, 
                        OrderRepository repository) {
        this.publisher = publisher;
        this.repository = repository;
    }

    public Order createOrder(CreateOrderRequest request) {
        Order order = Order.create(request);
        repository.save(order);
        publisher.publishEvent(OrderPlacedEvent.of(order));
        return order;
    }
}

Other modules listen:

java
// inventory/internal/InventoryEventListener.java
package com.example.shop.inventory.internal;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import com.example.shop.orders.api.OrderPlacedEvent;

@Component
public class InventoryEventListener {

    private final StockService stockService;

    public InventoryEventListener(StockService stockService) {
        this.stockService = stockService;
    }

    @EventListener
    public void onOrderPlaced(OrderPlacedEvent event) {
        event.items().forEach(item ->
            stockService.decrementStock(item.productId(), item.quantity())
        );
    }
}

This feels like microservices—orders don't know about inventory—but it's still in-process. No network latency. If inventory fails, the transaction can be coordinated with orders. You're decoupled logically but coupled operationally, which is often what you want in a monolith.

Later, when you extract inventory to its own service, you swap the event bus implementation. Your event listener pattern stays the same; only the publishing mechanism changes to Kafka. The module structure made that refactoring possible.

Extract later — Clean modules make migration straightforward

Here's where the payoff lives. Suppose your inventory module becomes a bottleneck. You've profiled the code, optimized the database, and you've genuinely hit the limits of a shared database. Now you need to extract it into its own service.

If your modules are clean, this is arithmetic:

  1. Inventory already has a small, defined API. That becomes your service contract.
  2. Inventory's internal classes stay private; only the API classes matter for the service.
  3. The OrderPlacedEvent and listener pattern already exist. You replace the in-process event with a message broker.
  4. The rest of the application barely changes. Orders still publishes the event. The listener now listens from a queue instead of the application event bus.

If your modules are tangled—orders directly importing inventory internals, circular dependencies, shared utility classes living nowhere—extraction is painful. You rewrite half the codebase. With Spring Modulith, you've already done the hard thinking. The code mirrors the architecture you need.

Common mistakes

  • Confusing package structure with module structure. A module is defined by a top-level package. Don't nest too deeply or split a module across branches. Keep the topology clear.
  • Allowing shared internal packages. A common.internal package across modules is a foothold for coupling. Move shared code to its own module or accept duplication.
  • Publishing events without a consistent naming convention. OrderPlacedEvent versus OrderCreated versus NewOrder is chaos. Establish a vocabulary and stick to it.
  • Forgetting to test the module structure in CI. The verification test is useless if it only runs locally. Add it to your pipeline, or the boundaries erode silently.
  • Over-fragmenting into modules too early. Ten modules for a two-developer app is overhead. Wait until natural ownership and communication patterns emerge, then codify them as modules.

Key takeaways

  • Spring Modulith enforces module boundaries through package structure and automated verification, catching violations before they merge.
  • Each module exposes a small public API; everything else is internal and off-limits to other modules.
  • Application events decouple modules logically while keeping them operationally coupled in a single process.
  • Clean module boundaries make future extraction into microservices straightforward; you've already done the architectural thinking.
  • Verification must run in your CI pipeline, not just locally, to prevent boundary drift as the team grows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles