In today’s software development landscape, building maintainable and scalable applications is more important than ever. While microservices are popular for their flexibility and scalability, they introduce operational complexity that is unnecessary in the early stages of a project when the system is still simple.
Starting a new project with a monolithic architecture allows teams to focus on delivering business value quickly without the overhead of managing distributed systems. However, traditional monoliths can suffer from issues like tight coupling, unclear boundaries, and scaling difficulties.
Spring Modulith addresses these challenges by enforcing clear module boundaries and providing tools for structured modularization. It ensures that components remain well-isolated, enhancing both maintainability and scalability within a monolithic architecture.
Verifying Application Module Structure
Let’s consider an example involving an e-commerce application with an Order module for order processing, an Inventory module for tracking product stock, and a Notification module for sending notifications to users. Our primary focus is the use case where the inventory must be updated and a notification needs to be sent to the user once the order is completed.

The project structure follows the standard skeleton for a Modular Monolith design, with each sub-package representing a sub-domain, such as inventory, order, notification, and so on. With this arrangement, unfortunately, the Java compiler cannot prevent illegal access to InventoryRepository, which is expected to be accessed only within the Inventory module.
Spring Modulith steps in to verify the application’s structure and ensure that our code adheres to the defined structures. We can create a test case for this:
public class ModularityTests {
@Test
void verifiesModularStructure() {
ApplicationModules.of(ModulithApplication.class).verify();
}
}
Assuming that OrderService has introduced a dependency on InventoryRepository, the above test would fail with the following error message, resulting in a broken build:
org.springframework.modulith.core.Violations: - Module 'order' depends on non-exposed type com.dev.modulith.inventory.repository.InventoryRepository within module 'inventory'!
OrderService declares constructor OrderService(OrderRepository, InventoryRepository) in (OrderService.java:0)
The test also fails when there are cyclic dependencies between application modules, access to internal types (according to the project structure definition), and when dependencies that are not on the explicitly allowed list of the module are accessed. For more information on defining application module boundaries and permitted dependencies, see this reference documentation.
Testing Modules in Isolation
Spring Modulith enables integration testing of individual application modules either in isolation or together. The @ApplicationModuleTest annotation will run your integration tests similarly to @SpringBootTest, but the bootstrapping gets its auto-configuration limited to the packages the modules to be bootstrapped reside in.
@ApplicationModuleTest
class OrderIntegrationTests {
// Individual test cases go here
}
There are several bootstrap modes supported:
STANDALONE(default) — Runs the current module only.DIRECT_DEPENDENCIES— Runs the current module as well as all modules the current one directly depends on.ALL_DEPENDENCIES— Runs the current module and the entire tree of modules depended on.
Leveraging Events for Inter-Module Interaction
Typically, our module communication will be something like the following:
@Service
public class OrderService {
...
@Transactional
public void processOrder(Order order) {
// State transition go here
orderRepository.save(order);
inventoryService.decreaseStock(order);
notificationService.sendConfirmation(order);
}
However, there are a few problems with this code.
- Firstly, the order module is tightly coupled with other modules. The
processOrder()function is a point of functional gravity that pulls in additional tasks like updating stock, sending confirmations, updating rewards, etc. It increases the difficulties of maintenance and testing.
- The process is synchronous and within a single commit boundary, increasing the risk of failure in the main business function due to issues with an attached function. For example, if the notification module fails, it will break the whole order processing, which is unreasonable.
- The Order parameter in
inventoryService.decreaseStock()creates a cycle between the order and inventory modules.
It’s better to do it through an event. Instead of depending on other modules’s Spring bean, the order module use ApplicationEventPublisher to publish an event when the order is completed.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void processOrder(Order order) {
// State transition go here
orderRepository.save(order);
eventPublisher.publishEvent(new OrderCompleted(order.getId()));
}
And other modules can consume this event on its own.
@Service
public class InventoryService {
...
@EventListener
void on(OrderCompleted event) {...}
}
By this way, we decoupled the modules. Note that, by using @EventListener, the event publication and consumption happen synchronously. This means we can keep the same consistency as the previous code, but it’s also not good in case the entire transaction fails due to an error in a functionality that is not crucial -sending notifications, for example.
A different way is moving the event consumption to asynchronous handling at the after transaction commit phase by using @ApplicationModuleListener.
@Service
public class InventoryService {
...
@ApplicationModuleListener
void on(OrderCompleted event) {...}
}
This separates the original transaction from running the listener (which also runs in a transaction). While this prevents growth of the primary business transaction, it creates a risk: if the listener fails, the event publication is lost, leaving the process in an inconsistent state.
Spring Modulith includes an event publication registry that connects to the main event system of the Spring Framework. When an event is published, it identifies the transactional event listeners that will receive the event and logs each of them as part of the primary business transaction.

The event is marked as completed if the listener works successfully. If the listener fails, the log entry remains unchanged so retry mechanisms can be used based on the application’s needs. You can enable automatic re-publication of events through the spring.modulith.republish-outstanding-events-on-restart property.
Integrating Modules with External Systems
Some events shared between application modules may interest external systems. This can be more easily done through annotation-based or programmatic configuration.
@Externalized
public record OrderCompleted(long orderId) {}
Spring Modulith lets you publish events to different message brokers. Visit this reference page for information on externalization methods and a list of supported infrastructures.
Documenting Application Modules
The ApplicationModules model can be used to create documentation snippets for developer guides in Asciidoc.
ApplicationModules modules = ApplicationModules.of(ModulithApplication.class)
@Test
void createModuleDocumentation() {
new Documenter(modules).writeDocumentation(); // default options
}
In the “./target/spring-modulith-docs” folder, you can find the generated documents, including C4 and UML diagrams that show the relationships between application modules, as well as an aggregated “all-docs.adoc” file that contains all modules.


A canvas document for each app module:

Observability at the Module Level
The interaction between application modules can be intercepted to create Micrometer spans that can be visualized in tools like Zipkin, resulting in all Spring components of the module’s API being decorated with an aspect to intercept invocations and generate these spans.

In this case, the order processing finished in 119 ms, but the notification took 1 second. This happened because the order triggered an event that the notification module picked up asynchronously. The Spring Modulith can still correlate it based on the trace ID, allowing you to see the complete request flow.
Conclusion
Spring Modulith enables you to build well-structured, maintainable applications while staying within the simplicity of a monolithic architecture. Whether you’re building a new project or refactoring an existing one, Spring Modulith can be the ideal pair-programming partner to guide you toward cleaner, more scalable code.
Make sure you check the official document page for the latest updates and other features.