Stop the Rot: Building Enforceable Modular Monoliths with Spring Modulith

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Problem: When Monoliths Turn into a ‘Big Ball of Mud’

We all start new projects with high hopes and a clean architecture. You likely organize your code into standard packages like service, repository, and controller. It feels structured on day one. However, once the codebase hits 50,000 lines or the team grows beyond five developers, those boundaries usually begin to crumble.

Pressure to deliver features often leads to architectural shortcuts. A developer working on ‘Billing’ might quickly import a repository from ‘Inventory’ to save time. Inevitably, your application becomes a web of spaghetti code. Changing a single logic gate in the shipping module might unexpectedly break the payment gateway three layers away.

Many teams try to escape this mess by jumping into Microservices. Unfortunately, they often trade code complexity for operational headaches like network latency, distributed tracing, and data consistency issues. There is a better middle ground. You need a Modular Monolith, and Spring Modulith is the framework that makes it enforceable in the Java ecosystem.

The Modular Monolith Concept

A Modular Monolith is a single deployment unit—one JAR file—where internal code is strictly partitioned. Unlike a traditional monolith, these modules have ‘hard’ walls. A module should only expose a specific public API, keeping its internal helper classes and logic hidden from the rest of the system.

Spring Modulith, now a production-ready project under the Spring umbrella, gives you the tools to verify these boundaries. It uses your Java package structure to define what is a ‘module’ and what is ‘internal.’ Most importantly, it fails your tests if a developer tries to cheat the architecture.

Core Mechanics of Spring Modulith

  • Package-based Boundaries: Every direct sub-package under your main application class is treated as a distinct module.
  • Strict Encapsulation: Code in the top-level module package is public. Anything in sub-packages (like .internal) is invisible to other modules.
  • Architectural Verification: It runs ArchUnit-based tests to detect cyclic dependencies or illegal cross-module access automatically.

Hands-on: Implementing Spring Modulith

Let’s build a store system with two modules: Order and Inventory. Our goal is to let Order call the public InventoryService, while keeping the database helpers in Inventory strictly private.

1. Project Setup

Add the Spring Modulith BOM and the core starter to your pom.xml. Using the BOM ensures all module dependencies stay in sync.

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

2. Defining the Module Structure

Structure determines your architecture. If your base package is com.example.store, arrange your folders like this:

  • com.example.store.order (The Order Module – Public)
  • com.example.store.inventory (The Inventory Module – Public)
  • com.example.store.inventory.internal (Hidden logic)

Spring Modulith treats inventory as the gateway. Any class inside inventory.internal is off-limits to the order package.

3. Creating the Inventory Module

First, we define a public service. This is the ‘front door’ of our module.

package com.example.store.inventory;

import org.springframework.stereotype.Service;

@Service
public class InventoryService {
    public boolean isStockAvailable(String productId) {
        return true; 
    }
}

Next, we create an internal component. This class might handle raw database mapping that we don’t want to expose to other teams.

package com.example.store.inventory.internal;

import org.springframework.stereotype.Component;

@Component
public class InventoryDataHelper {
    public void performSecretTask() {
        // Logic that should never be called from the Order module
    }
}

Turning Architecture into a Unit Test

In a standard Java project, the public keyword allows access from anywhere. This is why architectures rot. Spring Modulith changes the rules by analyzing your bytecode during the test phase.

Create a test class to act as your architectural watchdog:

package com.example.store;

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

class ModularityTests {

    ApplicationModules modules = ApplicationModules.of(StoreApplication.class);

    @Test
    void verifyModularity() {
        // This fails the build if a developer creates a circular dependency 
        // or accesses an .internal package from the outside.
        modules.verify();
    }

    @Test
    void writeDocumentation() {
        // Automatically generates PlantUML diagrams of your modules!
        new Documenter(modules).writeModulesAsPlantUml();
    }
}

If a developer tries to import InventoryDataHelper inside OrderService, the modules.verify() test will fail. It provides a clear error message pinpointing the violation. This moves architectural governance from a PDF document into your CI/CD pipeline.

Conclusion

Choosing a Modular Monolith is a pragmatic decision for most business applications. It keeps your codebase organized and easy to test without the overhead of managing 20 different microservices. By using Spring Modulith, you stop ‘the rot’ before it starts.

This approach gives you the best of both worlds. You get the simplicity of a single deployment unit today, with the clean boundaries needed to split into Microservices tomorrow if your scale truly requires it.

Share: