A monolith can have beautifully organized packages and still be a nightmare to maintain.
You open the project and see folders called orders, billing, and inventory. Everything looks neatly separated. But the moment you investigate the dependencies, the real picture emerges.
Orders imports Inventory's internal repository. Billing relies on implementation details from Orders. A small change in one feature unexpectedly affects several others.
The problem isn't necessarily that the application is a monolith.
The problem is that its module boundaries exist only in the folder structure, not in the architecture.
Moving to microservices will not automatically solve those dependencies. It may simply turn tightly coupled Java classes into tightly coupled network services.
Before introducing distributed infrastructure, there's a simpler architectural improvement worth considering: enforcing module boundaries inside the existing application.
In this tutorial, we'll explore how to do that using Java, Spring Boot, and Spring Modulith.
The examples use a simplified commerce application. They demonstrate structural principles rather than a complete production inventory system.
1. Start by Defining What Each Module Owns
Imagine an application with four business capabilities:
- Orders: Creates and manages customer purchases.
- Inventory: Maintains stock information and availability.
- Billing: Handles invoices and payment-related operations.
- Notifications: Delivers customer updates.
A starting package structure might look like this:
com.example.commerce
├── CommerceApplication.java
├── orders
│ ├── OrderService.java
│ └── internal
│ └── OrderRepository.java
├── inventory
│ ├── InventoryOperations.java
│ └── internal
│ ├── InventoryService.java
│ ├── StockRepository.java
│ └── InMemoryStockRepository.java
├── billing
│ └── BillingOperations.java
└── notifications
└── NotificationOperations.java
Under Spring Modulith's default module detection arrangement, the direct subpackages beneath the application root can represent application modules.
The root package of a module forms its default exposed interface, while internal subpackages contain implementation details.
This distinction matters.
Other modules may need to ask Inventory whether enough stock is available. They shouldn't automatically gain access to Inventory's persistence implementation.
Spring Modulith's official module fundamentals describe how provided interfaces and internal implementation components are represented.
The folder structure is the starting point. Enforcing which dependencies are allowed is the next step.
2. The Problem: Accessing Another Module's Internals
Consider an Orders service that imports an Inventory repository directly.
Before: A Boundary Violation
package com.example.commerce.orders;
import com.example.commerce.inventory.internal.StockRepository;
import org.springframework.stereotype.Service;
// Deliberately incorrect architecture example.
// Assumes StockRepository is declared public.
@Service
class OrderService {
private final StockRepository stockRepository;
OrderService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
}
This example deliberately violates the intended boundary.
It assumes StockRepository is public, meaning Java can permit the import. But because the repository belongs to inventory.internal, Orders has become dependent on Inventory's internal implementation.
Spring Modulith's default module verification rejects this kind of cross-module access when the modules use the standard arrangement.
If the repository were package-private instead, the Java compiler would reject the cross-package reference before architecture verification could run.
The important lesson is that Java visibility and architectural module boundaries are related, but they are not identical.
A class can be publicly accessible in Java while still being something other modules should not depend on.
3. Expose a Business Interface Instead
The better approach is to expose a narrow operation through Inventory's public module interface.
For this example, we'll implement an availability check.
File: inventory/InventoryOperations.java
package com.example.commerce.inventory;
public interface InventoryOperations {
boolean canReserve(String sku, int quantity);
}
This interface tells other modules what Inventory can do without exposing how Inventory stores its records.
Next, define the internal repository abstraction.
File: inventory/internal/StockRepository.java
package com.example.commerce.inventory.internal;
public interface StockRepository {
int availableQuantity(String sku);
}
The repository is declared public here because it is an interface available to components within its own package and potentially other Java packages. Its location within inventory.internal still signals that it is not part of Inventory's default Spring Modulith API.
For production applications, teams may further restrict Java visibility according to their implementation needs.
File: inventory/internal/InMemoryStockRepository.java
package com.example.commerce.inventory.internal;
import java.util.Map;
import org.springframework.stereotype.Repository;
@Repository
class InMemoryStockRepository implements StockRepository {
private final Map<String, Integer> stock =
Map.of("SKU-001", 25);
@Override
public int availableQuantity(String sku) {
return stock.getOrDefault(sku, 0);
}
}
This provides a simple Spring-managed implementation of the repository contract.
It intentionally uses fixed sample data and does not provide real persistence or stock reservation functionality.
Now add the business-facing implementation.
File: inventory/internal/InventoryService.java
package com.example.commerce.inventory.internal;
import com.example.commerce.inventory.InventoryOperations;
import org.springframework.stereotype.Service;
@Service
class InventoryService implements InventoryOperations {
private final StockRepository repository;
InventoryService(StockRepository repository) {
this.repository = repository;
}
@Override
public boolean canReserve(String sku, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
return repository.availableQuantity(sku) >= quantity;
}
}
Notice what changed.
The repository remains an internal dependency of Inventory. Other business modules do not need to know how the availability calculation obtains its data.
The method is deliberately simplified: canReserve() only checks reported availability. It does not actually reserve stock.
A production reservation workflow needs concurrency control, transaction handling, and atomic updates where appropriate.
File: orders/OrderService.java
package com.example.commerce.orders;
import com.example.commerce.inventory.InventoryOperations;
import org.springframework.stereotype.Service;
@Service
class OrderService {
private final InventoryOperations inventory;
OrderService(InventoryOperations inventory) {
this.inventory = inventory;
}
boolean canCreate(String sku, int quantity) {
return inventory.canReserve(sku, quantity);
}
}
Orders now depends on Inventory's published interface rather than its repository implementation.
This is the main architectural improvement: the dependency follows a business capability instead of exposing internal persistence details.
The anti-pattern and corrected implementation are alternatives. Do not keep both versions of OrderService in the same project.
4. Verify Boundaries with Spring Modulith
Code review is useful, but relying on developers to remember every architectural rule becomes difficult as applications evolve.
Automated verification can help prevent accidental violations.
First, the application needs a root configuration class.
File: CommerceApplication.java
package com.example.commerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CommerceApplication {
public static void main(String[] args) {
SpringApplication.run(
CommerceApplication.class, args
);
}
}
Spring Modulith provides an ApplicationModules API for inspecting the module structure.
The verification test can be written as follows.
File: ArchitectureTests.java
package com.example.commerce;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ArchitectureTests {
@Test
void verifiesModuleBoundaries() {
ApplicationModules
.of(CommerceApplication.class)
.verify();
}
}
The official Spring Modulith verification documentation describes this exact API pattern.
By default, the verification checks include:
- Circular dependencies between application modules.
- Access to internal types in another application module.
- Violations of explicitly configured allowed dependencies.
For a suitable standard-module arrangement, accessing the Inventory public interface is allowed, while accessing an internal Inventory repository from Orders should be rejected.
| Scenario | Expected verification behavior |
|---|---|
Orders depends on InventoryOperations
|
Allowed, assuming no other violations |
Orders accesses inventory.internal.StockRepository
|
Rejected under default closed-module rules |
| Orders and Inventory depend cyclically on one another | Rejected |
| A module violates configured allowed dependencies | Rejected |
These results describe the documented rules, not the output of a test suite executed for this article.
To run the example, the project also needs compatible Spring Boot and Spring Modulith dependencies, plus its normal build and test configuration.
Verification status: The Java examples are designed to be consistent, and the ApplicationModules API has been checked against official documentation. A full Spring Boot application startup and architecture test run have not been demonstrated here.
5. Why a Passing Architecture Test Isn't Enough
An important limitation deserves attention.
Even if every architecture test passes, the application can still have poorly chosen business boundaries.
For example, imagine a Billing module that owns several unrelated operations simply because they involve money.
Technically, the module may not violate a dependency rule. But it could still be too broad to maintain effectively.
Automated checks cannot replace domain understanding.
Teams must still ask:
- Does each module represent a coherent business responsibility?
- Are its published operations understandable?
- Are implementation details hidden?
- Do other modules rely on the exposed contract rather than internal behavior?
- Can the responsibility evolve without repeatedly forcing unrelated modules to change?
Architecture tests help enforce the rules the team defines.
They cannot determine whether every rule is appropriate.
6. What About Circular Dependencies?
Suppose Orders depends on Billing, Billing depends on Notifications, and Notifications directly depends on Orders.
Now the three modules form a dependency cycle.
This can make changes harder to isolate and can blur responsibility between business capabilities.
Spring Modulith's verification identifies module-level dependency cycles.
When a cycle appears, the solution should not simply be to disable the rule.
Investigate why the modules depend on one another.
Sometimes responsibilities need to be redistributed. Sometimes one interaction can be expressed through a stable interface or business event.
The right solution depends on the domain.
Avoid adding another abstraction merely to hide the dependency without resolving the underlying coupling.
7. When Should Modules Communicate Through Events?
A modular monolith can use asynchronous communication without becoming microservices.
That is useful to understand because developers sometimes equate events with distributed architecture.
The two are not the same.
For example, after an order is created, the application may need to send a confirmation notification.
The Orders module could publish an event, and Notifications could respond without Orders directly depending on notification-delivery internals.
Spring Modulith's event publication documentation explains its event-oriented module interaction capabilities.
However, events create design responsibilities of their own.
A team needs to consider delivery failures, duplicate processing, transaction boundaries, and whether work must be durable.
A synchronous call may remain the better choice when one module requires an immediate response to complete a business operation.
Choose event-driven communication because the interaction needs it, not because asynchronous architecture sounds more modern.
8. Test at the Module Level
A maintainable module should be testable without requiring every unrelated capability to participate in each test.
Spring Modulith offers module-focused integration testing through @ApplicationModuleTest.
That can help developers test a business capability while preserving awareness of its module boundary.
A sensible testing strategy includes:
- Unit tests for individual business rules.
- Integration tests for module interfaces and persistence behavior.
- Architecture verification tests for structural dependencies.
- End-to-end tests for workflows spanning multiple modules.
None of these replaces the others.
An application may pass its structural verification while still having a broken checkout workflow.
Likewise, an end-to-end test may pass while the code gradually develops unnecessary internal dependencies.
Different tests protect different properties.
9. Five Common Mistakes to Avoid
Mistake 1: Treating packages as boundaries without enforcing dependencies
Directories provide organization. Architecture rules establish which relationships are allowed.
Mistake 2: Exposing repositories to unrelated modules
When modules depend directly on another module's persistence implementation, changes can spread across business boundaries.
Mistake 3: Creating an oversized shared module
A general shared package can become an attractive place for unrelated business rules, slowly introducing coupling throughout the application.
Mistake 4: Turning every interaction into an event
Events are valuable when asynchronous or loosely coupled processing serves a real requirement. They are not inherently better for every operation.
Mistake 5: Assuming a modular monolith is only a temporary architecture
A modular monolith can remain a deliberate long-term choice.
The question of whether it should eventually become distributed depends on actual scaling, deployment, reliability, and operational requirements.
A broader comparison of modular monoliths and microservices explores those architectural trade-offs.
10. Module Boundary Checklist for Code Reviews
Before merging a change, ask:
- Does this module expose a stable, meaningful public interface?
- Is another module accessing its internal types?
- Have new dependencies created a cycle?
- Does the receiving module own the business operation it performs?
- Can the change be tested at an appropriate module level?
- Does the architecture verification test still pass?
- Would a future engineer understand why these modules depend on one another?
The objective isn't to maximize the number of modules.
It's to make dependencies understandable and prevent unrelated capabilities from becoming unnecessarily coupled.
These concerns also matter when planning custom software development, particularly for applications expected to evolve over several years.
Frequently Asked Questions
What is a module boundary in a modular monolith?
A module boundary defines which business responsibilities a module owns and which operations it exposes to other modules. Good boundaries prevent unrelated code from accessing internal implementation details. They make dependencies easier to understand and support focused testing and maintenance.
How can Spring Modulith enforce module boundaries?
Spring Modulith analyzes an application's module structure and can verify architectural rules. Its checks include circular dependencies and access to other modules' internal packages. Teams can also configure permitted module dependencies. These checks support architectural discipline but do not replace domain-design reviews.
Can a modular monolith use event-driven communication?
Yes. Modules can publish and consume application events without being independently deployed services. Events may help reduce direct dependencies or support asynchronous work. Teams still need to design for transaction behavior, handler failures, durability requirements, and duplicate processing where applicable.
Is Spring Modulith necessary for modular monolith architecture?
No. A modular monolith is an architectural design choice, not a framework requirement. Spring Modulith offers tools for Spring Boot projects, but other technology stacks can enforce boundaries through visibility rules, architecture tests, clear interfaces, and dependency restrictions.
When should a modular monolith become microservices?
Consider microservices when specific capabilities require independent scaling, deployment, ownership, or failure isolation and when those benefits justify distributed-system complexity. A growing codebase alone is not sufficient reason to migrate. Measure the relevant constraints before changing deployment boundaries.
Conclusion
If your monolith is becoming difficult to maintain, splitting it into microservices should not be the automatic first response.
Investigate the dependencies.
Define ownership, expose narrow interfaces, protect internals, and introduce structural verification.
Those practices improve the architecture whether the application remains a modular monolith or eventually extracts selected capabilities into separate services.
Sometimes the most valuable architectural improvement isn't creating another service.
It's removing a dependency that should never have existed.
Top comments (0)