Skip to main content

02 - Modular Monolith: Hybrid Architecture Between Monolith and Microservices

Introduction: The Modular Monolith as Hybrid Architecture In the previous article we analyzed the microservices crisis and the warning signs that...

What do you think about this article?

At a Glance

Introduction: The Modular Monolith as Hybrid Architecture In the previous article we analyzed the microservices crisis and the warning signs that...

Introduction: The Modular Monolith as a Hybrid Architecture

In the previous article we analyzed the microservices crisis and the warning signs indicating when distribution becomes a problem. Now it is time to explore the solution: the Modular Monolith, an architecture combining the operational simplicity of a single deployable process with the modularity and logical independence typical of microservices.

The modular monolith is not a second-rate compromise, but a deliberate architectural choice that delivers the best of both worlds. In this article we will see how to design module boundaries, define internal APIs, and how frameworks like Spring Modulith can help maintain architectural integrity over time.

What You Will Learn in This Article

  • The fundamental principles of modular monolith architecture
  • How to define clear and maintainable module boundaries
  • The role of internal APIs and inter-module contracts
  • Spring Modulith: the framework enforcing boundaries at compile time
  • Structured comparison: monolith, modular monolith, microservices
  • Reference architecture with diagrams and code

Fundamental Principles of the Modular Monolith

A modular monolith differs from a traditional monolith through four architectural principles governing the code structure and interactions between components:

1. Single Deployable, Multiple Logical Modules

The application is compiled and deployed as a single artifact (a JAR, a WAR, a binary), but internally it is organized into independent logical modules. Each module has its own package, its own classes, and a well-defined public API surface. Modules communicate exclusively through public interfaces, never accessing internal classes of other modules.

2. Encapsulation and Information Hiding

Each module hides its internal implementation behind a public interface. Domain classes, repositories, internal services are inaccessible from outside. Only API interfaces, DTOs, and events are exposed. This ensures modules can evolve internally without breaking consumers.

3. Data Ownership

Each module is the exclusive owner of its own data. Even if the physical database is shared, each module accesses only its own tables through its own repository. No module can directly read or write to another module's tables: it must go through the public API.

4. Explicit Communication

Interactions between modules occur through explicit channels: method calls through interfaces, domain events, or commands/queries. There are no hidden dependencies or direct access to other modules' databases. Every dependency is visible and traceable.

The Key Difference

In a traditional monolith, any class can call any other class. In a modular monolith, modules can communicate only through public interfaces. This restriction is what makes eventual extraction of a module as a microservice possible.

Reference Architecture

Let us see how a modular monolith is structured in practice. The following example shows an e-commerce application with four modules: Order, Catalog, User, and Payment.


// Modular monolith project structure
// com.ecommerce/
// ├── order/
// │   ├── api/
// │   │   ├── OrderModuleApi.java        (public interface)
// │   │   ├── OrderDto.java              (public DTO)
// │   │   └── OrderCreatedEvent.java     (public event)
// │   └── internal/
// │       ├── OrderService.java          (private logic)
// │       ├── OrderRepository.java       (private data access)
// │       └── Order.java                 (private entity)
// ├── catalog/
// │   ├── api/
// │   │   ├── CatalogModuleApi.java
// │   │   └── ProductDto.java
// │   └── internal/
// │       ├── CatalogService.java
// │       └── Product.java
// ├── user/
// │   ├── api/
// │   │   └── UserModuleApi.java
// │   └── internal/
// │       └── UserService.java
// └── payment/
//     ├── api/
//     │   └── PaymentModuleApi.java
//     └── internal/
//         └── PaymentService.java
    

Defining Internal APIs

Each module exposes an interface that defines the module's public contract. This interface is the only access point for other modules. The implementation is hidden within the internal package.


// Order module public API
package com.ecommerce.order.api;

public interface OrderModuleApi {

    /**
     * Creates a new order.
     * Publishes OrderCreatedEvent upon completion.
     */
    OrderDto createOrder(CreateOrderCommand command);

    /**
     * Retrieves an order by ID.
     * @throws OrderNotFoundException if the order does not exist
     */
    Optional<OrderDto> findById(UUID orderId);

    /**
     * Lists a user's orders with pagination.
     */
    Page<OrderDto> findByUserId(UUID userId, Pageable pageable);

    /**
     * Cancels an existing order.
     * Publishes OrderCancelledEvent upon completion.
     */
    void cancelOrder(UUID orderId);
}

// Public DTO - data only, no domain logic
public record OrderDto(
    UUID id,
    UUID userId,
    List<OrderItemDto> items,
    BigDecimal total,
    OrderStatus status,
    Instant createdAt
) {}
    

Internal Module Implementation

The module implementation is completely hidden. Classes in the internal package are not accessible from outside. This allows freely refactoring the implementation without impacting consumers.


// Private implementation of the Order module
package com.ecommerce.order.internal;

@Service
class OrderServiceImpl implements OrderModuleApi {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    private final CatalogModuleApi catalogModule;

    OrderServiceImpl(OrderRepository orderRepository,
                     ApplicationEventPublisher eventPublisher,
                     CatalogModuleApi catalogModule) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
        this.catalogModule = catalogModule;
    }

    @Override
    @Transactional
    public OrderDto createOrder(CreateOrderCommand command) {
        // Verify products through Catalog module public API
        List<ProductDto> products = command.getItemIds().stream()
            .map(catalogModule::findById)
            .map(opt -> opt.orElseThrow(ProductNotFoundException::new))
            .toList();

        // Create domain entity (private class)
        Order order = Order.create(command.getUserId(), products);
        orderRepository.save(order);

        // Publish event for other interested modules
        eventPublisher.publishEvent(
            new OrderCreatedEvent(order.getId(), order.getUserId())
        );

        return order.toDto();
    }
}
    

Spring Modulith: Compile-Time Boundaries

Spring Modulith is an official Spring framework providing tools to structure, verify, and document modular monoliths in Spring Boot. Its key feature is the ability to verify boundaries between modules at compile time, preventing unauthorized access between packages.

Spring Modulith Configuration


<!-- pom.xml - Spring Modulith 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>
    

Boundary Verification Test

Spring Modulith provides an automated test verifying that access rules between modules are respected. If a module accesses internal classes of another module, the test fails.


// Test verifying boundary integrity
@Test
void verifyModularStructure() {
    ApplicationModules modules = ApplicationModules.of(
        EcommerceApplication.class
    );

    // Verify no module accesses internal classes
    // of another module
    modules.verify();

    // Generate automatic module documentation
    new Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml();
}

// Test output on violation:
// Module 'payment' depends on non-exposed type
// com.ecommerce.order.internal.Order
// from module 'order'
    

Event-based Communication with Spring Modulith

Spring Modulith natively supports event-based communication between modules. Events are published through ApplicationEventPublisher and consumed with @ApplicationModuleListener, ensuring decoupling between producer and consumer.


// Order module: publishes an event
@Service
class OrderServiceImpl implements OrderModuleApi {

    private final ApplicationEventPublisher events;

    @Transactional
    public OrderDto createOrder(CreateOrderCommand cmd) {
        Order order = Order.create(cmd);
        orderRepository.save(order);
        events.publishEvent(new OrderCreatedEvent(
            order.getId(), order.getUserId(), order.getTotal()
        ));
        return order.toDto();
    }
}

// Payment module: reacts to the event
@Service
class PaymentEventHandler {

    @ApplicationModuleListener
    void onOrderCreated(OrderCreatedEvent event) {
        // Process payment when an order is created
        paymentService.processPayment(
            event.userId(), event.total()
        );
    }
}

// Notification module: reacts to the same event
@Service
class NotificationEventHandler {

    @ApplicationModuleListener
    void onOrderCreated(OrderCreatedEvent event) {
        notificationService.sendOrderConfirmation(event.userId());
    }
}
    

Comparison: Monolith vs Modular Monolith vs Microservices

To understand where the modular monolith sits in the architectural landscape, let us compare the three architectures across key dimensions:

Operational Complexity

  • Traditional Monolith: Low. One deploy, one process, centralized logs. But internal code can become tangled
  • Modular Monolith: Low. Same operational advantages as monolith, with structured and maintainable internal code
  • Microservices: High. N pipelines, N deploys, distributed tracing, service mesh, network policies

Scalability

  • Traditional Monolith: Horizontal (more instances). Sufficient for 95% of cases
  • Modular Monolith: Horizontal + ability to extract critical modules as independent services
  • Microservices: Independent per service. Necessary only for highly asymmetric loads

Team Autonomy

  • Traditional Monolith: Limited. Everyone works on the same codebase without clear boundaries
  • Modular Monolith: Moderate. Teams per module with clear interfaces, shared deployment
  • Microservices: High. Completely independent teams, independent deployments

Costs

  • Traditional Monolith: Minimal. Simple infrastructure
  • Modular Monolith: Minimal to moderate. Same infrastructure cost as monolith
  • Microservices: High. 6x compared to monolith according to industry estimates

The Modular Monolith Sweet Spot

The modular monolith sits at the optimal point for most organizations: it keeps the monolith's low operational costs, offers the modularity needed for medium-sized teams, and preserves the ability to extract microservices in the future when data justifies it.

When to Extract a Module as a Microservice

The modular monolith is not necessarily the final destination. Rather, it is a solid starting point from which to extract microservices when necessary. But how do you know when the time is right?

Here are the signals justifying module extraction:

  • Asymmetric scaling: the module requires 10x more resources than others
  • Dedicated team: a team of 5+ people works exclusively on that module and needs independent deployment
  • Different technology stack: the module would benefit from a different language or runtime
  • Different release frequency: the module needs daily releases while the rest of the system is weekly
  • Failure isolation: a bug in the module can crash the entire system

Practical Implementation: First Step

If you have an existing monolith and want to begin the transition to a modular monolith, the first step is to organize code into feature-based packages rather than technical layers. Instead of separating by controller, service, repository, separate by order, catalog, user.


// BEFORE: organization by technical layer (wrong)
// com.app/
// ├── controller/
// │   ├── OrderController.java
// │   ├── UserController.java
// │   └── CatalogController.java
// ├── service/
// │   ├── OrderService.java
// │   ├── UserService.java
// │   └── CatalogService.java
// └── repository/
//     ├── OrderRepository.java
//     ├── UserRepository.java
//     └── CatalogRepository.java

// AFTER: organization by feature/module (correct)
// com.app/
// ├── order/
// │   ├── api/OrderModuleApi.java
// │   └── internal/
// │       ├── OrderController.java
// │       ├── OrderService.java
// │       └── OrderRepository.java
// ├── user/
// │   ├── api/UserModuleApi.java
// │   └── internal/
// │       ├── UserController.java
// │       ├── UserService.java
// │       └── UserRepository.java
// └── catalog/
//     ├── api/CatalogModuleApi.java
//     └── internal/
//         ├── CatalogController.java
//         ├── CatalogService.java
//         └── CatalogRepository.java
    

Next Steps

In this article we defined the fundamental principles of the modular monolith and saw how to structure code with clear boundaries. In the upcoming articles we will dive deeper into each aspect:

  • Article 3: How to use Domain Driven Design to identify bounded contexts and define module boundaries
  • Article 4: How to design the database for a modular monolith, with patterns for shared and separated schemas
  • Article 5: Communication patterns between modules: synchronous, asynchronous, and event-driven

Next Article

In the next article we will explore Domain Driven Design and how to use it to identify the bounded contexts of your application. We will cover concepts like ubiquitous language, context mapping, aggregate design, and how to translate them into concrete module boundaries in code.

Frequently asked questions

What is the main difference between a traditional monolith and a modular monolith?

A modular monolith differs from a traditional monolith through four architectural principles governing the code structure and interactions between components.

How do modules in a modular monolith communicate with each other?

Modules in a modular monolith communicate exclusively through public interfaces, never accessing internal classes of other modules.

What is Spring Modulith and what does it provide for modular monoliths?

Spring Modulith is an official Spring framework that provides tools to structure, verify, and document modular monoliths in Spring Boot, including the ability to verify boundaries between modules at compile time.

When should a module be extracted as a microservice from a modular monolith?

A module should be extracted as a microservice when there are signals justifying it, such as data growth or complexity that cannot be managed within the existing module.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects