In today’s world of software development, microservices have revolutionized how applications are built and scaled. However, managing and maintaining a complex ecosystem of independent services often leads to issues such as increased complexity, difficulty in maintaining clear code boundaries, and challenges in integration.
Spring Modulith offers a structured yet simple approach to building a modular monolith, reducing complexity without the overhead of microservices. It helps maintain clear boundaries, improves testability, and enables smooth scaling, allowing you to focus on delivery while keeping future growth in mind.

The Challenge with Microservices and Monoliths
When designing an application, adopting the microservice architecture might seems like the ideal solution. It promotes dividing an application into smaller, independent services that can be deployed and scaled independently. However, the challenges soon become apparent:
- Complexity in Inter-Service Communication: Services need to interact with each other, often leading to intricate dependencies and communication overhead.
- Deployment and Versioning: Each microservice has its own deployment lifecycle, which can lead to issues in keeping everything in sync.
- Inconsistent Design: Microservices often become too granular, leading to repeated logic and code duplication across services.
- Testing and Debugging: Testing microservices in isolation becomes difficult, and end-to-end integration tests across multiple services can be time-consuming.
On the flip side, traditional monolithic architectures offer simplicity, but as the application grows, they suffer from scalability and maintainability issues. It becomes difficult to break down features or services into isolated components, leading to a tightly coupled system that is hard to modify without affecting the entire application, often referred to as the big ball of mud.
On the flip side, traditional monolithic architectures offer simplicity at the start, but as the application grows, they face several challenges:
- Limited Scalability: Scaling the entire application for just one resource-intensive feature becomes inefficient.
- Reduced Maintainability: Changes in one part of the system can have unintended consequences in unrelated areas.
- Tight Coupling: Features and services become interdependent, making it hard to isolate and update individual components.
- Deployment Bottlenecks: A small change anywhere requires redeploying the entire application.
- Architecture Degradation: Over time, the codebase can turn into a “big ball of mud” — a tangled, unstructured system that is hard to evolve.
This naturally leads to the question: Is there a middle ground?
The modular monolith
Yes, this is where the modular monolith comes in. It retains the simplicity of a monolith but enforces modularity, ensuring clear boundaries between different parts of the system. Unlike traditional monoliths, where everything is tightly coupled, a modular monolith structures the application into well-defined modules that communicate through APIs or events, reducing dependencies and improving maintainability.

What is Spring Modulith?
Spring Modulith is an opinionated toolkit to build domain-driven, modular applications with Spring Boot. It helps structure an application into clearly defined modules, each encapsulating a specific business domain or feature.
Advantages
- Code organization and maintainability: Codebase is easier to understand, develop and maintain.
- Simplified refactoring: Refactoring can be isolated on a module level. Refactoring one module should not affect the other modules’ code.
- Relatively independent deployments: modules can be upgraded without affecting other modules
- Take advantage of the benefits of both microservices and monoliths: we are on the middle ground.
Trade-Offs
Although the benefits are obvious, Modulith comes with its own set of trade-offs:
- Potential for tight coupling: We are still using a monolith architecture, and even though the main focus is modularization and loose-coupling between modules, the risk is still there that some components might be tightly coupled
- Scaling complexity: Unlike microservices, where each service can independently be scaled, we need to scale the entire application just because a part of the application is under heavy load.
- Tech stack limitation: The entire application shares the same technology stack, which might be a limiting factor. Maybe parts of the application are more suitable for implementation with a different programming language using a different tech stack.
- Single point of failure: We effectively have a single application. An issue in one module can potentially impact the entire application, whereas in a microservice environment the failures are isolated.
- Readiness of the team to embrace the Modulith architecture: Adopting it may involve a learning curve and adjustments in the development practices, which could impact the team productivity.
Spring Modulith in Action
Installation
You can start a new Spring Boot project by using Spring initializr and specifying Spring Modulith as a dependency.
Spring Modulith Project structure
Spring Modulith is opinionated and expects the code to follow a specific structure. The top level packages, which are on the same level as the @SpringBootApplication are considered modules.
Let’s create a sample project with 3 modules: Product, Order and Inventory.
com.n47.example.modulith
├── inventory
│ └── InventoryService.java
├── order
| ├── controller
| | └── OrderController.java
| ├── events
| | └── OrderCreated.java
│ |── OrderService.java
├── product
│ ├── ProductDto.java
│ └── ProductService.java
└── ModulithApplication.java
The module encapsulation is achieved by restricting the visibility of types within a module. Only the types on the top level of a module are considered part of the module’s public API and are visible to other modules. This means that any sub-packages are considered private to the module and are not visible to the other modules unless they are explicitly specified as public.
Project structure validation
The structure is not enforced during compile time. Instead, it is verified by a test. Failing to follow the rules that Modulith imposes would result in test failure.
@SpringBootTest
class ModulithApplicationTests {
ApplicationModules modules = ApplicationModules.of(ModulithApplication.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
}
Additionally, Spring Modulith offers a built-in functionality that can generate component diagrams. Those could be quite useful for visualizing the services and the interactions between them. We can add the following test, which would generate the component diagrams.
@Test
void createModuleDocumentation() {
new Documenter(modules).writeDocumentation();
}
The generated diagrams can be found under target/spring-modulith-docs/components.puml
. The file can be opened with PlantUML diagram viewer.
Module interaction
There are two main approaches to enabling module interaction:
- Direct interaction: Achieved through dependency injection
- Event-based communication: Modules can publish and subscribe to events
Direct interaction
Let’s consider the following scenario where the user wants to order a product. The user calls the OrderController
, which invokes the OrderService
. The OrderService
needs some product information, so it requests it from the ProductService
. This is possible as the ProductService
is a top-level public service within the product module. The getProduct
method returns a ProductDto
. Again, this is possible as the ProductDto
is defined as a public type on a top-level of the ProductsService
.
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductService productService;
public void createOrder(UUID productId) {
ProductDto productDto = productService.getProduct(productId);
log.info("Creating order with product: {}", productDto);
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {
public ProductDto getProduct(UUID productId) {
return new ProductDto(productId, "Product");
}
}
Let’s run the tests and generate the component diagram.

We can observe the module interaction: The Order
module uses the Product
module to fetch some product details.
Event-Based communication
Let’s add the InventoryService
to the mix. We want the OrderService
to notify the InventoryService
as soon as the order is created.
Up until now, the communication between our modules was synchronous. However, we can implement asynchronous communication by making use of the Spring application events. Spring offers the option of outputting events via an in-process event bus using the ApplicationEventPublisher
interface.
Let’s modify the OrderService
:
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductService productService;
private final ApplicationEventPublisher applicationEventPublisher;
public void createOrder(UUID productId) {
ProductDto productDto = productService.getProduct(productId);
log.info("Creating order with product: {}", productDto);
applicationEventPublisher.publishEvent(new OrderCreated(productId));
}
}
Spring Modulith hooks onto this mechanism and extends it with transactional behavior as well as asynchrony, using the ApplicationModuleListener
. This allows for events to be consumed and processed by the InventoryService
.
@Slf4j
@Service
@RequiredArgsConstructor
public class InventoryService {
private final ProductService productService;
@ApplicationModuleListener
void onOrderCreated(OrderCreated event) {
ProductDto productDto = productService.getProduct(event.productId());
log.info("Order created for product: {}", productDto);
}
}
The InventoryService
also needs to fetch some product info, so it additionally injects the ProductService
.
Explicit module access
Let’s try to run the tests now. Oops… we got a failure.
org.springframework.modulith.core.Violations: - Module 'inventory' depends on non-exposed type com.n47.example.modulith.order.events.OrderCreated within module 'order'!
OrderCreated declares parameter OrderCreated.onOrderCreated(OrderCreated) in (InventoryService.java:0)
- Module 'inventory' depends on non-exposed type com.n47.example.modulith.order.events.OrderCreated within module 'order'!
Method <com.n47.example.modulith.inventory.InventoryService.onOrderCreated(com.n47.example.modulith.order.events.OrderCreated)> calls method <com.n47.example.modulith.order.events.OrderCreated.productId()> in (InventoryService.java:20)
The key message here is: Module 'inventory' depends on non-exposed type com.n47.example.modulith.order.events.OrderCreated within module 'order'!
.
Indeed, we are trying to use OrderCreated
type, but it’s located in a nested package, thus it’s considered private to the Order
module. To explicitly expose this package, we need to add a package-info.java
file in the events
package:
@org.springframework.modulith.NamedInterface("order-events")
package com.n47.example.modulith.order.events;
If we run the test again, it now passes.
Let’s generate the component diagram again.

We can observe the newly added listens to
and uses
relationships between the Inventory
and the other two modules.
The dependencies between modules form a directed acyclic graph, which ensures a clear hierarchy, prevents cyclic dependencies, and allows for predictable module interactions.
Testing
The Modulith test dependency offers the @ApplicationModuleTest
annotation, which ensures that only the relevant modules and its dependencies are loaded. This helps enforce the modular boundaries by preventing unneeded dependencies from being used in the test. Overall, it works similarly to the @SpringBootTest
but with a focus on a specific module rather than the entire application.
Additionally, the Modulith test support provides the Scenario
helper, which allows event simulation and simplifies testing that events were published.
@ApplicationModuleTest
@RequiredArgsConstructor
class OrderServiceTest {
private final OrderService orderService;
@MockBean
ProductService productService;
@Test
void publishesOrderCreated(Scenario scenario) {
scenario.stimulate(() -> orderService.createOrder(UUID.randomUUID()))
.andWaitForEventOfType(OrderCreated.class)
.toArrive();
}
}
Should I go with Spring Modulith or Microservices on my next project?
As usual in engineering, it’s all about trade-offs, and the decision depends on your specific needs.
Spring Modulith is particularly well-suited for:
- Small to Medium-Sized Applications: It would be more efficient to focus on building well-structured applications and avoid the unnecessary overhead that comes with microservices.
- Projects with Limited Scaling Needs: Where scaling the entire application as a whole is sufficient.
- Teams with Limited Resources: There is an operational burden of managing microservices. managing a single application would be simpler and cheaper.
- Applications with Strong Consistency Requirements: Distributed transaction management would be overly complex if implemented with microservices.
As the application grows and evolves, you may identify the modules that would scale better independently and extract them as separate microservices. The modular approach, allows you to maintain the benefits of a monolithic architecture while also preparing for a smooth transition to microservices if such a need emerges.