Building a Modular Monolith with Spring Modulith

Microservices solve organizational and scalability problems — but they add operational complexity. Most applications don’t need that complexity. A modular monolith gives you clean boundaries and loose coupling without the distributed systems overhead. Spring Modulith enforces those boundaries.

The Problem with Unstructured Monoliths

Without explicit boundaries, every part of the codebase can talk to every other part:

// OrderService calling PaymentRepository directly — skipping the Payment module
@Service
public class OrderService {
    @Autowired PaymentRepository paymentRepository;  // ← wrong
    @Autowired NotificationService notificationService;  // ← wrong
    @Autowired AnalyticsService analyticsService;    // ← wrong
}

This creates hidden coupling. You can’t reason about what changing Payment will break. You can’t extract it to a separate service later without massive refactoring.

Spring Modulith Package Structure

Spring Modulith recognizes top-level packages under the application’s root package as modules:

com.devopsmonk.orderservice/
├── OrderServiceApplication.java    ← root
├── order/                          ← Order module
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   ├── Order.java
│   └── internal/                   ← package-private internals
│       └── OrderValidator.java
├── payment/                        ← Payment module
│   ├── PaymentService.java         ← public API
│   └── internal/
│       ├── PaymentGatewayClient.java
│       └── PaymentRepository.java
├── notification/                   ← Notification module
│   └── NotificationService.java
└── shared/                         ← Shared module (common types)
    └── Money.java

Rules Spring Modulith enforces:

  • Public types in a module = that module’s API
  • internal sub-package = private to the module (not accessible from other modules)
  • Modules may not form dependency cycles

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>

Verifying Module Structure

// src/test/java
class ModuleStructureTest {

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

    @Test
    void verifiesModuleStructure() {
        modules.verify();   // fails if any module boundary is violated
    }

    @Test
    void printsModuleDocumentation() {
        modules.forEach(System.out::println);
        // Prints: module name, dependencies, violations
    }
}

If any module accesses another module’s internal package, modules.verify() throws:

org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type 'payment.internal.PaymentRepository'

Cross-Module Communication with Events

Modules communicate through Spring’s ApplicationEventPublisher — not direct method calls:

// Order module publishes an event (doesn't know who listens)
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

    public Order createOrder(CreateOrderRequest request) {
        Order order = orderRepository.save(buildOrder(request));

        // Publish domain event — no direct dependency on Payment or Notification
        events.publishEvent(new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount()
        ));

        return order;
    }
}
// Payment module listens — no direct dependency on Order internals
@Service
@Slf4j
public class PaymentService {

    @ApplicationModuleListener   // Spring Modulith annotation — async by default
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("Processing payment for order {}", event.orderId());
        initiatePayment(event.orderId(), event.totalAmount());
    }
}

// Notification module listens independently
@Service
public class NotificationService {

    @ApplicationModuleListener
    public void onOrderCreated(OrderCreatedEvent event) {
        sendOrderConfirmation(event.customerId(), event.orderId());
    }
}

@ApplicationModuleListener is Spring Modulith’s version of @EventListener — it:

  • Runs the listener asynchronously in a separate transaction
  • Retries on failure
  • Records event publication state (which listeners have processed which events)

Event Externalization to Kafka

Spring Modulith can automatically publish internal events to Kafka:

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-events-kafka</artifactId>
</dependency>
@Externalized("order-events::#{#this.orderId()}")   // topic::key
public record OrderCreatedEvent(
    UUID orderId,
    UUID customerId,
    BigDecimal totalAmount
) implements ApplicationEvent {}

Spring Modulith automatically publishes this event to the order-events Kafka topic. Other services can consume it from Kafka. Internal modules still receive it via @ApplicationModuleListener.

Event Publication Registry

Spring Modulith tracks which listeners have processed which events — if the application crashes mid-processing, incomplete listeners are retried on restart:

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
spring:
  modulith:
    events:
      jdbc:
        schema-initialization:
          enabled: true   # creates event_publication table

On restart, Spring Modulith finds incomplete event publications and re-triggers those listeners. This is the outbox pattern built into the framework.

Module Documentation

Generate architecture documentation automatically:

@Test
void generateModuleDocumentation() {
    ApplicationModules modules = ApplicationModules.of(OrderServiceApplication.class);

    // Generates docs in target/modulith-docs/
    new Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml();
}

Output: PlantUML diagrams showing module dependencies, event flows, and API surfaces. Auto-generated, always current.

Integration Testing Modules in Isolation

// Test only the Order module — mock everything else
@ApplicationModuleTest
class OrderModuleTest {

    @Autowired OrderService orderService;

    @MockBean PaymentService paymentService;     // mock the payment module
    @MockBean NotificationService notificationService;

    @Test
    void createsOrderAndPublishesEvent(PublishedEvents events) {
        CreateOrderRequest request = new CreateOrderRequest(/* ... */);

        Order order = orderService.createOrder(request);

        // Assert the domain event was published
        assertThat(events.ofType(OrderCreatedEvent.class))
            .hasSize(1)
            .extracting(OrderCreatedEvent::orderId)
            .containsExactly(order.getId());
    }
}

@ApplicationModuleTest boots only the specified module’s beans — much faster than @SpringBootTest.

PublishedEvents captures events published during the test — verifies event contracts without checking Kafka.

Module API Design

Define explicit API types to control what other modules can see:

// order/OrderFacade.java — the only public API of the Order module
@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final OrderService orderService;  // internal

    // Public API — other modules call this
    public OrderSummary createOrder(CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return OrderSummary.from(order);
    }

    public OrderSummary findById(UUID orderId) {
        return orderService.findById(orderId).toSummary();
    }
}

// order/internal/OrderService.java — internal, not accessible from other modules
class OrderService {
    // package-private: accessible within order module, not from outside
}

When to Choose Modulith vs Microservices

ConcernModular MonolithMicroservices
Deployment complexitySingle unitPer-service pipelines
Operational overheadLowHigh (k8s, service mesh, observability)
Development speedFast (in-process calls)Slow (API versioning, network latency)
Team autonomyShared codebaseIndependent codebases
Scale requirementsVertical or horizontal (single unit)Independent per-service scaling
Data consistencyEasy (single DB)Hard (distributed transactions, sagas)
LatencyMicrosecondsMilliseconds

Start with a modular monolith. If you outgrow it — specific modules need independent scaling, teams need independent deployment — extract those modules to services. Spring Modulith’s event-based boundaries make extraction straightforward.

What You’ve Learned

  • Spring Modulith recognizes top-level packages as modules and enforces access boundaries
  • The internal sub-package is private to a module — modules.verify() fails if violated
  • Modules communicate via ApplicationEventPublisher, not direct method calls — loose coupling
  • @ApplicationModuleListener runs listeners asynchronously in a separate transaction with retry
  • @Externalized automatically publishes internal events to Kafka for external consumers
  • Spring Modulith’s event publication registry provides built-in outbox behavior
  • @ApplicationModuleTest boots only one module — fast, isolated integration tests

This completes Part 8: Messaging and Event-Driven Architecture. You now know how to build event-driven systems — from basic Kafka producers/consumers through reliable delivery patterns to modular architecture.


Next: Part 9 — Microservices starts with Article 46: Microservices Architecture — When to Split and When Not to.