The software industry has a microservices fixation. Over the last decade, “monolith” became almost a pejorative, and teams moved to distributed architectures before they had the scale, team size, or operational maturity to justify them.
The result has been widespread over-engineering: systems that are harder to understand, harder to debug, harder to change, and more expensive to operate — not because distributed systems are inherently bad, but because the problems they solve weren’t actually present.
The modular monolith is not a compromise or a stepping stone. For many teams and products, it is the correct architecture. It solves the real problem with monoliths — accidental coupling and boundary erosion — without introducing distributed systems complexity.
What Makes a Monolith Bad Is Not That It’s a Monolith
The actual problems with the “big ball of mud” monolith aren’t:
- Shared deployment unit
- Single process
- Single database
They are:
- No enforced module boundaries: any code can call any other code
- Implicit dependencies: understanding any one piece requires understanding everything
- Boundary erosion over time: what started as separate concerns gradually bleeds together
- Tangled domain logic: business rules scattered across layers
These problems can exist in microservices too — they’re just harder to see because the network makes them explicit rather than the code.
A modular monolith addresses the actual problem: it enforces module boundaries strictly, so the codebase can grow without becoming unmaintainable.
What a Modular Monolith Is
A modular monolith is a single deployable unit where the internal structure is divided into modules with explicit, enforced boundaries. Each module:
- Owns its own domain logic
- Owns its own persistence
- Exposes a defined public API
- Has no direct access to other modules’ internals
The key word is enforced. Module boundaries that are only conventions decay. Enforcement mechanisms — build-time checks, package-private visibility, separate Gradle/Maven modules — are what make the architecture durable.
Structuring Modules in Java
Java gives you several enforcement mechanisms. The most practical for teams not using JPMS is Maven/Gradle multi-module projects with strict dependency rules.
A typical structure:
myapp/
├── app/ # Bootstrapping, Spring Boot entry point
├── modules/
│ ├── orders/ # Orders domain module
│ │ ├── api/ # Public API: interfaces, DTOs, events
│ │ └── internal/ # Domain logic, persistence, private
│ ├── inventory/
│ │ ├── api/
│ │ └── internal/
│ ├── payments/
│ │ ├── api/
│ │ └── internal/
│ └── users/
│ ├── api/
│ └── internal/
└── shared/ # Truly shared utilities (logging, pagination, etc.)
The discipline: modules may only depend on other modules’ api submodule, never their internal.
In Gradle, this is expressible:
// orders/internal/build.gradle.kts
dependencies {
implementation(project(":modules:orders:api"))
implementation(project(":modules:inventory:api")) // Can call inventory's public API
// NOT: implementation(project(":modules:inventory:internal"))
}
With ArchUnit you can encode this as a test:
@AnalyzeClasses(packages = "com.myapp")
class ModuleBoundaryTest {
@ArchTest
static ArchRule noInternalCrossModuleDependencies = noClasses()
.that().resideInAPackage("..modules..internal..")
.should().dependOnClassesThat()
.resideInAPackage("..modules..internal..")
.andShould().haveFullyQualifiedName(Predicates.not(
name -> name.contains(currentModuleName()) // except own internal
));
}
Module Public APIs
Each module’s public API defines the contract the rest of the system depends on. It should contain:
- Interfaces for services the module provides
- DTOs/records for input and output data
- Domain events the module publishes
- Nothing about persistence — no JPA entities, no repository interfaces
// orders/api — public contract
// What other modules can do with orders
public interface OrderService {
OrderId placeOrder(PlaceOrderCommand command);
Optional<OrderSummary> findOrder(OrderId id);
void cancelOrder(OrderId id, CancellationReason reason);
}
// Commands — input data
public record PlaceOrderCommand(
UserId customerId,
List<OrderLineItem> items,
ShippingAddress shippingAddress
) {}
// Read models — output data
public record OrderSummary(
OrderId id,
UserId customerId,
OrderStatus status,
Money total,
Instant placedAt
) {}
// Events — things that happened
public record OrderPlaced(
OrderId orderId,
UserId customerId,
List<OrderLineItem> items,
Instant occurredAt
) {}
The internal package contains:
- JPA entities (if using JPA)
- Repository implementations
- Domain services
- The actual business logic
None of this leaks out.
Cross-Module Communication
Modules communicate in two ways:
Synchronous calls
Direct method calls through the public API interface. The calling module holds a reference to the interface, not the implementation:
// In payments/internal — calling inventory via its public API
@Component
public class PaymentProcessor {
private final InventoryService inventoryService; // From inventory/api
public PaymentResult processPayment(PaymentRequest request) {
var reservation = inventoryService.reserveItems(request.items());
// ... payment processing ...
}
}
This is just dependency injection. In Spring, the implementation from inventory/internal gets wired in. The payments module never has a compile dependency on inventory/internal.
Asynchronous events
For decoupled communication, modules publish domain events via a simple in-process event bus:
// orders/internal publishes
eventPublisher.publish(new OrderPlaced(orderId, customerId, items, Instant.now()));
// inventory/internal subscribes
@EventListener
public void onOrderPlaced(OrderPlaced event) {
inventoryService.reserveForOrder(event.orderId(), event.items());
}
In Spring, ApplicationEventPublisher provides this out of the box. Events are in-process and synchronous by default, which is fine for most use cases. If you need async, Spring’s @Async or a background executor works without infrastructure.
Database Separation
Each module owns its persistence. This is the hardest discipline to maintain but arguably the most important.
Practical approaches:
Schema-per-module in shared database: Each module has its own schema. Cross-module JOINs are explicitly banned.
-- orders module uses orders schema
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_lines (...);
-- inventory module uses inventory schema
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);
Separate databases: More isolation, more operational overhead. Worth it when modules have meaningfully different scaling or availability requirements.
If a read view needs data from multiple modules, options are:
- Query each module’s service and assemble in the application
- Use a read model (CQRS) that subscribes to events from multiple modules
- Accept denormalization — the read module maintains its own copy of relevant data
What you don’t do: JOIN across module boundaries. That coupling is as bad as calling internal code.
Testing Modules Independently
One of the real advantages of strict module boundaries: you can test each module in complete isolation.
@SpringBootTest(classes = OrdersModuleConfiguration.class)
// Only loads the orders module's Spring context
class OrdersModuleIntegrationTest {
@Autowired
OrderService orderService;
@MockBean
InventoryService inventoryService; // Mock the inventory boundary
@Test
void placingOrder_reservesInventory() {
when(inventoryService.reserveItems(any())).thenReturn(ReservationId.generate());
var orderId = orderService.placeOrder(new PlaceOrderCommand(...));
assertThat(orderId).isNotNull();
verify(inventoryService).reserveItems(any());
}
}
The module under test loads only its own context. Dependencies on other modules are mocked at the boundary. This is fast, reliable, and tests the right thing.
When to Extract a Service
A modular monolith gives you a natural migration path to microservices if you ever genuinely need them. The signals that a module is ready for extraction:
- Independent scaling requirements: the inventory module gets 50× the read traffic of other modules
- Independent deployment requirements: a team needs to deploy payments independently of the rest
- Technology isolation: the data science team wants to own the recommendation engine in Python
- Organizational independence: a team is large enough to own a service end-to-end
Notice what is not on this list: “we’re doing microservices because that’s what modern companies do.”
The extraction is mechanical when the module boundary is clean. The api package becomes a network contract. The event bus becomes a message queue. The in-process call becomes an HTTP or gRPC call. Nothing else changes.
If your module boundaries are not clean when you try to extract, you’ll spend months untangling accidental coupling that should never have existed.
Comparison With Microservices
| Concern | Modular Monolith | Microservices |
|---|---|---|
| Deployment | Single artifact | Independent per service |
| Module isolation | Enforced by build | Enforced by network |
| Operational complexity | Low | High |
| Distributed transactions | Not needed | Hard problem |
| Debugging across modules | Easy — single process | Hard — requires distributed tracing |
| Scaling | Vertical + horizontal | Independent per service |
| Team independence | Limited | Strong |
| Technology diversity | Low | High |
The modular monolith wins on everything except deployment independence and team independence. For many teams, those two things don’t justify the operational overhead.
The Honest Assessment
Most startups, scale-ups, and SMEs should start with a modular monolith. The team is small enough that independent deployment is not a real constraint. The operational overhead of microservices is a tax on velocity that small teams cannot afford.
Build the module boundaries correctly. Keep them enforced. When the system grows to the point where microservices genuinely add value, extract the modules that need it. You’ll be glad you started with clean boundaries.
The modular monolith is not a lesser architecture. It is frequently the more appropriate one.