There was a time when breaking a monolith into microservices felt like unlocking a new level in engineering maturity. Teams moved faster, deployments became independent, and scaling suddenly felt elegant instead of painful.
Then reality arrived.
What looked like architectural sophistication started behaving like operational gravity. Latency crept in. Debugging turned into distributed archaeology. Observability became a full-time investment. Teams that once shipped features weekly found themselves tuning Kubernetes probes and chasing partial failures across services.
This isn't a rejection of microservices. It's a correction.
Microservices introduce what many engineers now call the Distributed Systems Tax:
For large organizations, this tax is acceptable---even necessary. For small to medium teams, it often becomes the bottleneck.
A simple mental model:
| Operation Type | Latency |
|---|---|
| In-memory method call | ~10--100 ns |
| Same-process function | ~100--500 ns |
| Network call (same AZ) | ~0.5--2 ms |
| Cross-AZ network call | ~2--10 ms |
That's a 1,000,000x difference in worst-case scenarios.
Now multiply that across service chains.
A modular monolith is not a "big ball of mud."
It's a single deployable unit with strict internal boundaries:
The key distinction:
| Legacy Monolith | Modular Monolith |
|---|---|
| Implicit dependencies | Explicit module graph |
| Shared mutable state | Encapsulated domains |
| Hard to refactor | Designed for extraction |
| No boundaries | Enforced boundaries |
One of the most common architectural mistakes is conflating deployment concerns with code structure.
You don't need distributed systems to achieve modularity.
You need discipline---and enforcement.
Java developers have long relied on packages for structure. But packages are advisory, not enforceable at scale.
public#In Java:
public class PaymentService { }
This is effectively visible to the entire codebase.
In a large system, that becomes dangerous:
Package-private (default) access:
You need something stronger.
The Java Platform Module System (JPMS) introduces strong encapsulation at the module level.
exportsDefines what is visible outside the module.
module com.app.orders {
exports com.app.orders.api;
}
requiresDeclares dependencies explicitly.
module com.app.orders {
requires com.app.inventory;
}
Circular dependencies are caught at compile time.
opensUsed for reflection-heavy frameworks:
opens com.app.orders.internal to spring.core, spring.beans;
uses and providesEnable service discovery via the Service Loader pattern:
uses PaymentProcessor;
provides PaymentProcessor with StripePaymentProcessor;
Domain-Driven Design (DDD) fits naturally into modular monoliths.
Each bounded context becomes a JPMS module:
com.app.orderscom.app.inventorycom.app.shippingcom.app.orders
├── api
├── internal
└── infrastructure
apipackage com.app.orders.api;
public interface OrderService {
Order createOrder(CreateOrderCommand command);
}
internalpackage com.app.orders.internal;
class OrderServiceImpl implements OrderService {
}
infrastructurepackage com.app.orders.infrastructure;
@Repository
class OrderRepository {
}
module com.app.orders {
exports com.app.orders.api;
requires com.app.inventory;
requires spring.context;
requires spring.beans;
opens com.app.orders.internal to spring.core;
}
module com.app.inventory {
exports com.app.inventory.api;
requires spring.context;
}
| Call Type | Latency |
|---|---|
| Method call | ~50 ns |
| REST call | ~5 ms |
Order order = orderService.createOrder(cmd);
public interface DomainEvent {}
public class OrderCreatedEvent implements DomainEvent {
private final String orderId;
}
eventBus.publish(new OrderCreatedEvent(orderId));
@EventListener
public void handle(OrderCreatedEvent event) {
inventoryService.reserve(event.getOrderId());
}
sequenceDiagram participant Orders participant EventBus participant Inventory Orders->>EventBus: Publish OrderCreatedEvent EventBus->>Inventory: Notify Inventory->>Inventory: Reserve Stock
SELECT * FROM orders JOIN inventory ON ...
@Transactional
public void placeOrder() {
orderService.create();
inventoryService.reserve();
}
subprojects {
apply plugin: 'java'
}
Timer.builder("module.call")
.tag("from", "orders")
.tag("to", "inventory")
.register(meterRegistry);
graph TD Orders --> Inventory Orders --> Payments
| Dimension | Traditional Monolith | JPMS Modular Monolith | Microservices |
|---|---|---|---|
| Deployment | Single | Single | Multiple |
| Latency | Low | Very Low | High |
| Scalability | Limited | Moderate | High |
| Complexity | Low | Moderate | Very High |
| Consistency | Strong | Strong | Eventual |
| Team Size Fit | Small | Small--Medium | Medium--Large |
| Dev Speed | High | High | Low |
| Operational Cost | Low | Low--Moderate | High |
| Debugging | Easy | Moderate | Difficult |
| Failure Modes | Local | Local | Distributed |
A well-structured modular monolith isn't a dead end.
It's a staging ground.
graph LR Orders --> InventoryModule InventoryModule --> InventoryService
The industry isn't abandoning microservices. It's becoming more selective.
Architecture is no longer about proving scalability.
It's about preserving velocity under real-world constraints.