Software Design & Architecture6 min read·By Liyabona Saki·

Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith

Why modern teams are returning to modular monoliths — module boundaries, package-by-feature, internal events and a clean migration path to microservices in Spring Boot.

The Fallacy of the Distributed Monolith

Most teams pivoting to microservices do so because their Spring Boot monolith has become a "Big Ball of Mud." They hope that physical process separation will force cleanliness. Instead, they usually end up with a distributed monolith: the same spaghetti code, but now with the added overhead of network latency, RestTemplate timeouts, and the nightmare of distributed transactions.

A true Modular Monolith (Modulith) enforces boundaries within a single deployment unit. In Spring Boot, this isn't just about making folders; it’s about using the application context and visibility modifiers to prevent OrderService from directly mutating InventoryTable. If you cannot defend a boundary inside a single JVM, you will never defend it across a network.

The following failure modes represent the most common ways "Modular" architectures collapse back into chaos during high-load production scenarios.

Failure Mode: The Autowired Entanglement

Symptom: A change in the shipping-module requires recompiling the billing-module, or worse, a circular dependency prevents the ApplicationContext from starting entirely. You see the dreaded BeanCurrentlyInCreationException.

Root Cause: Engineers treat every Spring @Service as a public API. By default, every @Service in a Spring Boot application is public. If OrderService autowires InventoryService, and InventoryService autowires OrderService for a status check, the modules are physically coupled. This defeats the purpose of modularity; you cannot deploy or test them in isolation.

The Fix: Use Java’s package-private visibility. Only the "API" layer of your module should be public. All implementation details (repositories, internal services) should be package-private.

```java
// src/main/java/com/platform/orders/internal/OrderProcessor.java
@Service
class OrderProcessor { // Package-private: cannot be seen by 'shipping' module
    void process(Order order) { ... }
}

// src/main/java/com/platform/orders/OrderService.java @Service public class OrderService { // Public: the entry point for other modules private final OrderProcessor processor;

public OrderService(OrderProcessor processor) { this.processor = processor; }

public void createOrder(OrderRequest req) { processor.process(req.toDomain()); } } ```

By placing the implementation in an internal package and keeping the class non-public, you leverage the compiler to enforce architectural boundaries. If an engineer in the billing module tries to autowire OrderProcessor, the code won't even compile.

Failure Mode: Transactional Leakage and Ghost States

Symptom: An exception in the notification-module causes a rollback in the payment-module. A user pays for a subscription, the email server times out, and the database rolls back the payment record—leaving the user charged but the system claiming they haven't paid.

Root Cause: Sharing the same database transaction across module boundaries. When PaymentService calls EmailService.send() inside a @Transactional block, they share a single unit of work. If send() fails, the entire transaction is marked for rollback.

The Fix: Adopt an asynchronous, event-driven approach for cross-module communication using Spring’s ApplicationEventPublisher. Use @TransactionalEventListener to ensure the event is only processed *after* the primary transaction commits.

text
[ Order Module ]                [ Transaction Log ]           [ Shipping Module ]
       |                                |                             |
1. Create Order ----------------------> DB                            |
2. Publish OrderCreatedEvent             |                            |
3. Commit Transaction <----------------- DB                            |
       |                                                              |
4. Trigger Listener -------------------------------------------> 5. Prep Shipment

This decouples the availability of the modules. If the Shipping module is slow or down, the Order module still completes its work.

Failure Mode: The Shared Database Schema Trap

Symptom: A simple migration to rename the user_id column to account_id in the auth module breaks the reports module and the analytics module. Deployment requires a "Stop the World" lock on the database.

Root Cause: Modules are sharing tables. Modularity is a lie if your data layer is a monolith. If Module A queries a table owned by Module B, you have tight coupling at the storage level, which is the hardest type of coupling to decouple later.

The Fix: Logical Schema Isolation. At a minimum, use different table prefixes (e.g., ord_orders, inv_stock). Ideally, use separate database schemas within the same PostgreSQL or MySQL instance. Configure Spring Data JPA to use specific entity managers for different base packages.

java
@Configuration
@EnableJpaRepositories(
    basePackages = "com.platform.inventory",
    entityManagerFactoryRef = "inventoryEntityManager"
)
public class InventoryConfig { ... }

In a high-traffic environment (p99 of 450ms reduced to 110ms in one project), we found that separating the schemas allowed us to optimize the connection pools independently. The heavy-reporting module could saturate its 10 connections without starving the core-transactional module's pool.

Failure Mode: The Eventual Consistency "Race to the Bottom"

Symptom: UI displays "Order Successful," but when the user clicks "View Shipments," the page is empty because the shipping-module hasn't processed the event yet. The help desk gets flooded with "where is my data?" tickets.

Root Cause: Blindly following asynchronous patterns without a UI strategy. While internal events are great for scale, they introduce a lag. If the UI doesn't know the system is eventually consistent, the user experience suffers.

The Fix: Implement "Read-Your-Writes" consistency or UI Hiding. When the OrderCreatedEvent is published, the API response should include the generated ID and a status (e.g., "PENDING"). The frontend uses this to show a "Processing..." state.

Alternatively, use a synchronous "Internal API" for queries that require immediate consistency, but keep those interfaces strictly read-only. Avoid the temptation to make cross-module writes synchronous.

Failure Mode: The "Fat" Domain Object Leak

Symptom: You pass a User JPA entity from the auth module to the audit module. The audit module tries to access user.getPermissions(), triggering a LazyInitializationException because the Hibernate session was closed three layers ago.

Root Cause: Passing managed entities across module boundaries. This exposes the internal persistence logic of one module to the logic of another. It also creates a "God Object" that everyone depends on, making it impossible to change the database structure of the User table without checking every single module.

The Fix: Use Data Transfer Objects (DTOs) or Value Objects for cross-module communication.

```java
// Instead of this:
public void handle(UserEntity user) { ... }

// Do this: public record UserAuthenticatedInfo(UUID userId, String email, Set<String> roles) {}

public void handle(UserAuthenticatedInfo info) { ... } ```

When we switched to strictly using Records for cross-module events in a 1.2M lines-of-code system, our memory footprint dropped by 15% because we were no longer carrying heavy Hibernate proxy objects and their associated metadata across the entire execution stack.

Failure Mode: Lack of Observability Between Modules

Symptom: A request takes 2 seconds to complete. You look at the logs, but you can only see the entry point and the exit point. You have no idea which module is the bottleneck.

Root Cause: Treating the monolith as a black box. In microservices, people use Sleuth/Micrometer Tracing. In a monolith, people often forget that trace context is just as vital.

The Fix: Use Spring Boot 3 + Micrometer Observation. Instrument the boundaries. Every time a call crosses from OrderService to InventoryService, or an internal event is published, ensure the traceId and spanId are propagated.

Include the module name in your MDC (Mapped Diagnostic Context) logs. This allows you to filter logs by module=inventory or module=shipping within a single log stream.

log
2023-10-27 14:02:01 [traceId=abc123] INFO [orders] - Creating order #99
2023-10-27 14:02:01 [traceId=abc123] INFO [inventory] - Reserving stock for item SKU-01
2023-10-27 14:02:02 [traceId=abc123] ERROR [shipping] - Provider API Timeout

The Strategic Pivot to Microservices

The greatest benefit of a Modular Monolith isn't staying a monolith; it’s the ability to split. If the shipping-module suddenly needs to scale to 100x the load of the rest of the system, you shouldn't have to scale the whole app.

To test if your modularity is "real," try to move a module to a completely different JAR and run it in a separate Spring context. If you've respected package-private visibility, used events for writes, and DTOs for data exchange, this migration is a weekend task of changing an @EventListener to a @KafkaListener. If you haven't, you're looking at a multi-month refactoring nightmare.

The sharp reality of modern backend engineering is that physical boundaries (servers) are expensive and difficult. Logical boundaries (code structure) are cheap and effective, but only if you have the discipline to enforce them with the compiler rather than just a "developer agreement." Your code should be broken by design, but connected by intention.

Migration signals: when a modular monolith should become services

A well-designed modular monolith can carry a team for years. Three signals tell you it is time to extract a module into its own service — and three tell you not to. Extract when: deploy cadence of one module needs to differ from the rest (regulatory or compliance modules often do); a module has genuinely different scaling characteristics (a search or ML module hungry for RAM); or a separate team owns a module and coordination has become the bottleneck. Do not extract when: the extraction is motivated by architectural fashion rather than a measured pain; the module shares transactional writes with the rest of the app (a distributed transaction is not the same trade-off as a local one); or your CI pipeline is slow — that is a pipeline problem, not a monolith problem, and splitting will make it worse before it makes it better.

Go deeper

Further reading

#Spring Boot#Modular Monolith#Architecture#DDD#Microservices

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Software Design & Architecture

Essential Software Design Principles Every Developer Should Know

A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.

Related tutorials