REF / WRITING · SOFTWARE

Spring Boot Modular Monolith: Better Than Microservices for Most Teams

Spring Modulith in production - package boundaries, events, testing, and migrating a tangled monolith into clean modules.

DomainSoftware
Formattutorial
Published19 Nov 2024
Tagsspring-boot · modular-monolith · spring-modulith

The pendulum has swung back. After a decade of teams over-correcting from monoliths to microservices and discovering the operational tax (distributed tracing, network failures, eventual consistency, deploy-time coupling pretending to be runtime decoupling), the modular monolith has re-emerged as the right default for most teams under 100 engineers.

Spring Modulith. promoted to a top-level Spring project. gives you the architectural enforcement to do this well. Here's the production playbook.

What a Modular Monolith Actually Is

A modular monolith is a single deployable artifact (one JAR, one container, one process) composed of independently understandable, loosely coupled modules. Each module owns its data, its domain logic, and its public API. Modules communicate through well-defined contracts. application events, internal APIs, or shared types. but never reach into each other's internals.

The contrast with the mess most "monoliths" become:

  • A traditional monolith: every package can call every other package. Cross-cutting changes ripple unpredictably. A schema change in orders breaks reporting because a join lives in a place no one remembers.
  • A modular monolith: orders exposes a public API. reporting consumes it. The compiler enforces the boundary. A schema change in orders either updates the public API (intentional) or stays internal (no ripple).

The benefit you keep from microservices: clear ownership and bounded contexts. The cost you avoid: a network between every module call.

Package Structure

Spring Modulith uses package-level boundaries. The convention:

com.acme.shop/
├── orders/                    ← module root (public API surface)
│   ├── Order.java             ← public types
│   ├── OrderService.java
│   ├── internal/              ← anything in here is private to the module
│   │   ├── OrderEntity.java
│   │   ├── OrderRepository.java
│   │   └── PricingCalculator.java
│   └── package-info.java
├── inventory/
│   ├── ...
└── notifications/
    └── ...

The internal sub-package is private by convention. Spring Modulith enforces it at test time. if inventory imports com.acme.shop.orders.internal.OrderEntity, the architecture test fails.

Enforcing the Boundaries in Tests

Add Spring Modulith to your build:

dependencies {
    implementation("org.springframework.modulith:spring-modulith-starter-core")
    implementation("org.springframework.modulith:spring-modulith-starter-jpa")
    testImplementation("org.springframework.modulith:spring-modulith-starter-test")
}

Then write the architecture test once:

@SpringBootTest
class ModularityTests {
    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void verifiesModuleStructure() {
        modules.verify();  // fails if a module touches another's internals
    }

    @Test
    void writesDocumentation() {
        new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml();
    }
}

The test fails on every PR that violates a boundary. The documentation generation produces a live PlantUML diagram of your architecture. no more hand-maintained Confluence diagrams that diverge from reality.

Cross-Module Communication: Events

The cleanest way for modules to talk is events. Spring's built-in ApplicationEventPublisher works for synchronous events; Spring Modulith adds an outbox pattern for asynchronous, transactionally-safe events.

// orders module
public record OrderPlaced(UUID orderId, UUID customerId, BigDecimal total) {}

@Service
public class OrderService {
    private final ApplicationEventPublisher events;

    @Transactional
    public Order place(NewOrderCommand cmd) {
        var order = orderRepo.save(Order.from(cmd));
        events.publishEvent(new OrderPlaced(order.id(), cmd.customerId(), order.total()));
        return order;
    }
}
// notifications module - listens, doesn't import from orders.internal
@Component
public class OrderNotificationListener {
    @ApplicationModuleListener
    public void on(OrderPlaced event) {
        emailService.sendOrderConfirmation(event.customerId(), event.orderId());
    }
}

@ApplicationModuleListener runs the listener asynchronously, in a separate transaction, with at-least-once delivery via the event publication registry (a database table Spring Modulith manages). If the listener crashes, the event stays in the registry and is retried.

This is the right eventual consistency: bounded to a single deployment, observable in a single database, no message broker required.

Migrating From a Tangled Monolith

If you're starting from an existing Spring Boot codebase that's grown organically, the migration path:

  1. Map your modules first. Domain-driven design's "bounded context" is the right unit. Sketch them on a whiteboard before touching code.
  2. Move packages, don't refactor logic. Rename com.acme.shop.service.OrderService to com.acme.shop.orders.OrderService. Don't change behaviour.
  3. Add the architecture test in failing mode. Run it. Fix the boundary violations one at a time, in their own PRs.
  4. Replace cross-module direct calls with events. The OrderService no longer calls inventoryService.reserve(...) directly. it publishes OrderPlaced and the inventory module reacts.
  5. Promote internal packages. Anything that's not part of the public contract goes into <module>.internal.

Plan for this to take 2-3 quarters for a 100K-line codebase. Most of the work is testing. you're refactoring while the business ships features.

Module-Level Testing

The win you didn't see coming: module-scoped integration tests run in seconds, not minutes.

@ApplicationModuleTest
class OrdersModuleTest {
    @Autowired OrderService orderService;

    @Test
    void placesOrder(Scenario scenario) {
        scenario.stimulate(() -> orderService.place(new NewOrderCommand(...)))
            .andWaitForEventOfType(OrderPlaced.class)
            .toArriveAndVerify(event -> {
                assertThat(event.total()).isEqualByComparingTo("99.95");
            });
    }
}

@ApplicationModuleTest boots only the orders module. not the entire application context. Tests that previously took 12 seconds to bootstrap take 0.4 seconds. Multiply across hundreds of tests; CI feedback loops collapse.

When to Break Out a Real Service

The modular monolith is the right shape for ~80% of business systems. The 20% where you genuinely want a separate deployable:

  • Different scaling profile. A search service that needs 10× the CPU of the rest of the app, or a video transcoder that needs GPUs.
  • Different release cadence with hard guarantees. A billing service that legal requires to be deployed under change control, while the rest of the app ships continuously.
  • Different language. The ML team writes in Python; you're not turning your Java app into a polyglot codebase.

Outside these cases, a module is the right answer. You can always extract a service later. the public API of a Spring Modulith module is the same shape as a service contract. The reverse migration (services back to monolith) is a 6-month project no one ever budgets for.

Build the modular monolith. Extract services only when measured pain forces you to.