Spring Modulith: Build a Modular Monolith Before You Commit to Microservices

Microservices solve real problems: independent deployability, team autonomy, technology flexibility. They also create real problems: distributed transactions, network latency, operational complexity. Many teams split into microservices too early, before they understand their domain well enough to draw stable boundaries.

Spring Modulith gives you module boundaries, enforced isolation, and event-driven decoupling inside a single deployable JAR. It’s the pragmatic middle ground.


The Modular Monolith Problem It Solves

A typical Spring Boot monolith looks like this after a year:

com.example.app
├── controllers/
│   ├── OrderController.java
│   ├── CustomerController.java
│   └── PaymentController.java
├── services/
│   ├── OrderService.java
│   ├── CustomerService.java
│   └── PaymentService.java
└── repositories/
    ├── OrderRepository.java
    └── CustomerRepository.java

Every service can call every other service. There are no enforced boundaries. OrderService directly calls PaymentService which calls CustomerService. The codebase becomes a ball of mud — impossible to reason about, impossible to extract into separate services later.

Spring Modulith enforces that modules communicate only through their public API, and detects when a module depends on the internals of another.


Setup

<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>

<!-- Optional: for JDBC-backed event publication log -->
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-events-jdbc</artifactId>
</dependency>

Package Structure

Spring Modulith uses the package structure to define modules:

com.example.app
├── Application.java          # @SpringBootApplication
├── order/                    # "order" module
│   ├── OrderManagement.java  # public API (the only thing other modules can use)
│   ├── Order.java            # public type
│   └── internal/             # internal implementation — off-limits to other modules
│       ├── OrderService.java
│       ├── OrderRepository.java
│       └── OrderValidator.java
├── payment/                  # "payment" module
│   ├── PaymentGateway.java   # public API
│   └── internal/
│       ├── PaymentProcessor.java
│       └── StripeClient.java
└── customer/                 # "customer" module
    ├── Customers.java        # public API
    └── internal/
        └── CustomerRepository.java

Rules enforced by Spring Modulith:

  • Other modules can only access types in the module’s root package (not internal/)
  • internal/ sub-packages are private to the module
  • Cycles between modules are detected and fail tests

Enforcing Module Boundaries

Write the verification test (run it in CI)

class ModularityTests {

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

    @Test
    void verifyModularity() {
        modules.verify();
        // Fails if:
        // - Module B accesses internal types of Module A
        // - There's a cycle between modules
        // - Module A depends on Module B and vice versa
    }
}

Visualize module dependencies

@Test
void generateDiagram() throws Exception {
    new Documenter(modules)
        .writeModulesAsPlantUml()           // UML diagram
        .writeIndividualModulesAsPlantUml() // per-module diagram
        .writeDocumentation();              // AsciiDoc
}

Generates PlantUML and AsciiDoc documentation showing module relationships — useful for onboarding.


Module Public API

// order/OrderManagement.java — the public API of the order module
@Service
public class OrderManagement {

    private final OrderRepository orderRepository;  // in internal/
    private final ApplicationEventPublisher events;

    // Public methods — callable by other modules
    public Order createOrder(CreateOrderCommand command) {
        Order order = Order.create(command.customerId(), command.items());
        Order saved = orderRepository.save(order);

        // Publish event instead of directly calling payment module
        events.publishEvent(new OrderCreated(saved.getId(), saved.getTotal()));

        return saved;
    }

    public Optional<Order> findById(OrderId id) {
        return orderRepository.findById(id);
    }
}
// order/OrderCreated.java — public event type
public record OrderCreated(OrderId orderId, Money total) {}

The OrderService inside internal/ is invisible to other modules. Only OrderManagement is accessible.


Application Events for Module Decoupling

Instead of OrderManagement directly calling PaymentGateway, it publishes an event. The payment module listens and processes asynchronously. Neither module knows about the other.

// payment module listens for OrderCreated
@ApplicationModuleListener  // Spring Modulith annotation — async, transactional
public class PaymentListener {

    private final PaymentGateway paymentGateway;

    @ApplicationModuleListener
    public void onOrderCreated(OrderCreated event) {
        paymentGateway.initiatePayment(event.orderId(), event.total());
    }
}

@ApplicationModuleListener is equivalent to @TransactionalEventListener(phase = AFTER_COMMIT) — the listener fires after the order transaction commits (never on rollback).

Transactional event publication log

For reliability: if the listener fails after the order is committed, the event should be retried. Spring Modulith’s event publication log stores events in the database within the same transaction and marks them complete only after successful processing:

spring:
  modulith:
    events:
      jdbc:
        schema-initialization:
          enabled: true
// Event publication is automatic — events stored in DB in same transaction
events.publishEvent(new OrderCreated(saved.getId(), saved.getTotal()));
// If the listener fails, the event stays in the log and is retried

This gives you at-least-once delivery without Kafka or RabbitMQ infrastructure.


Testing Individual Modules

Spring Modulith supports isolated module integration tests — boots only the module under test and its dependencies:

@ApplicationModuleTest  // only loads the "order" module's beans
class OrderManagementTests {

    @Autowired
    OrderManagement orderManagement;

    @MockitoBean
    ApplicationEventPublisher events;

    @Test
    void shouldCreateOrder() {
        var command = new CreateOrderCommand(CustomerId.of(1L), List.of(item("PROD-1", 2)));
        Order order = orderManagement.createOrder(command);

        assertThat(order.getId()).isNotNull();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    }

    @Test
    void shouldPublishOrderCreatedEvent() {
        orderManagement.createOrder(new CreateOrderCommand(CustomerId.of(1L), List.of(...)));

        verify(events).publishEvent(any(OrderCreated.class));
    }
}

Loading only one module is much faster than loading the full @SpringBootTest context — tests run in 2–3 seconds instead of 15–20 seconds.


Extracting a Module to a Microservice

When a module needs to scale independently or be owned by a separate team, Spring Modulith makes extraction straightforward because the boundaries are already enforced.

Before extraction: module communicates via in-process events.

After extraction: replace in-process events with Kafka/RabbitMQ.

// Before: in-process event
events.publishEvent(new OrderCreated(orderId, total));

// After: Kafka event (same event type, different transport)
// Spring Modulith supports externalizing events via @Externalized
@Externalized("orders.created")  // publishes to Kafka topic "orders.created"
public record OrderCreated(OrderId orderId, Money total) {}

The payment module doesn’t change — it still listens for OrderCreated. Spring Modulith routes it through Kafka instead of in-process.

This is the key advantage: you can extract modules incrementally without rewriting the business logic.


When to Use Modulith vs Microservices

SituationUse ModulithUse Microservices
Team size< 20 engineersMultiple teams per domain
Domain understandingStill exploring boundariesStable, well-understood domain
Deployment frequencySame for all modulesModules need independent deploy schedules
Operational maturityLimited DevOps capacityStrong K8s / service mesh expertise
Performance needsShared DB is fineModules need independent scaling

The honest take: most applications don’t need microservices. They need good module boundaries. Start with a modulith, enforce boundaries from day one, and extract services only when there’s a concrete operational need (independent scaling, team autonomy, different tech stacks).

Amazon, Netflix, and Uber built microservices after years as monoliths — because they hit the scaling and team size limits. Most apps never reach those limits.


Actuator Integration

Spring Modulith adds module information to Actuator:

curl http://localhost:8080/actuator/modulith | jq
{
  "modules": [
    {
      "name": "order",
      "basePackage": "com.example.order",
      "publishedEvents": ["OrderCreated", "OrderCancelled"],
      "subscribedEvents": ["PaymentCompleted"]
    },
    {
      "name": "payment",
      "basePackage": "com.example.payment",
      "publishedEvents": ["PaymentCompleted"],
      "subscribedEvents": ["OrderCreated"]
    }
  ]
}

Quick Reference

// Package structure
com.example.app/{module}/              // public API lives here
com.example.app/{module}/internal/    // private implementation

// Verify boundaries (in CI)
ApplicationModules.of(Application.class).verify();

// Publish inter-module event
events.publishEvent(new OrderCreated(orderId, total));

// Listen (async, after transaction commits)
@ApplicationModuleListener
public void on(OrderCreated event) { ... }

// Isolated module test
@ApplicationModuleTest
class OrderManagementTests { ... }

// Externalize to Kafka when extracting to microservice
@Externalized("orders.created")
public record OrderCreated(...) {}

Summary

Spring Modulith enforces module boundaries via package structure and CI tests, replacing implicit coupling with explicit public APIs and application events. Modules can be tested in isolation (faster tests, clearer failures) and extracted to microservices incrementally when operational need justifies it. The transactional event publication log provides at-least-once delivery without requiring Kafka or RabbitMQ for intra-application communication. Start with Modulith, extract to microservices when you hit a concrete scaling or team boundary problem.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.