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.







