In 2018, the engineering blog you were reading told you to decompose everything into microservices. In 2022, the same blog started running post-mortems about distributed system complexity. In 2026, teams that survived both cycles are settling on something that looks a lot like what worked in 2010 — but done properly this time.
The modular monolith.
Not a big ball of mud. Not a distributed system pretending to be one service. A deliberately structured codebase where the modules are as isolated as microservices but everything runs in one process, shares one database transaction when needed, and gets deployed as one artifact.
If your team has fewer than 50 engineers, ships to fewer than 10 teams, and is spending more time debugging cross-service issues than building features — this post is for you. I'll show you the exact package structure, how to enforce module boundaries with ArchUnit, how to do cross-module communication without coupling, and when you'll actually know it's time to extract a service.
All examples use Java 21, Spring Boot 3.x, and a domain roughly based on an order management system — three bounded contexts: Orders, Catalog, and Customers. The patterns apply regardless of your domain.
Why Most "Microservices" Are Actually Distributed Monoliths
Before the architecture, a diagnostic. Here's the most common failure mode I encounter:
OrderService calls CustomerService via REST
→ CustomerService calls AccountService via REST
→ AccountService calls AuthService via REST
PlaceOrder() now has 4 network hops, 4 failure modes,
4 deployment dependencies, and distributed transaction risk.
This is not a microservices architecture. This is a monolith where the method calls were replaced with HTTP calls. The coupling is identical — it's just now expressed as API contracts instead of import statements, and every call can fail with a network error.
The teams I've seen thrive with microservices have two things: genuinely independent business capabilities with separate teams owning them end-to-end, and the organizational maturity to run distributed systems (platform team, service mesh, distributed tracing, on-call rotations per service). That's a real investment. For most teams, it's not the right trade.
The modular monolith gives you the architectural discipline of microservices without the operational overhead.
The Core Principle: Module Boundaries Are Enforced, Not Suggested
A modular monolith is not just "we put things in separate packages." Every Java codebase has packages. The difference is enforcement.
In a modular monolith:
- One module cannot import the internal implementation of another module
- Cross-module communication happens through defined interfaces, not direct class references
- Database access is scoped per module (even if you share one database)
- These rules are tested by the build — not just agreed on in a wiki
Let me show you what this looks like in practice.
Module Structure
src/main/java/com/company/app/
├── orders/
│ ├── api/ ← Public API of this module
│ │ ├── OrderService.java ← Interface (not implementation)
│ │ ├── OrderDto.java ← Data structures safe to share
│ │ └── OrderEvents.java ← Domain events this module publishes
│ ├── domain/ ← Private: domain model
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ ├── OrderStatus.java
│ │ └── OrderRepository.java
│ ├── application/ ← Private: use cases / application services
│ │ └── OrderServiceImpl.java
│ ├── infrastructure/ ← Private: DB, messaging, external APIs
│ │ ├── JpaOrderRepository.java
│ │ └── OrderMapper.java
│ └── OrdersConfig.java ← @Configuration for this module's beans
│
├── catalog/
│ ├── api/
│ │ ├── CatalogService.java
│ │ ├── ProductDto.java
│ │ └── CatalogEvents.java
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── CatalogConfig.java
│
├── customers/
│ ├── api/
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── CustomersConfig.java
│
└── shared/
├── Money.java ← Value objects used by multiple modules
├── CustomerId.java
└── DomainEvent.java ← Base type for all domain events
The rule is simple: everything in api/ is public. Everything else is private.
The orders module can call CatalogService (which is in catalog/api/). It cannot import catalog/domain/Product.java or catalog/infrastructure/JdbcCatalogRepository.java.
Enforcing Boundaries with ArchUnit
The structure above is meaningless without enforcement. Engineers will break it under deadline pressure. The compiler won't stop them — Java doesn't enforce package-level visibility like a module system does (and JPMS is a separate conversation). ArchUnit does.
@AnalyzeClasses(packages = "com.company.app", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
private static final List<String> MODULES = List.of("orders", "catalog", "customers");
@ArchTest
static final ArchRule no_module_accesses_internals_of_another =
noClasses()
.that().resideInAPackage("com.company.app.(*)..")
.should().accessClassesThat()
.resideInAPackage("com.company.app.(*).domain..")
.orShould().accessClassesThat()
.resideInAPackage("com.company.app.(*).application..")
.orShould().accessClassesThat()
.resideInAPackage("com.company.app.(*).infrastructure..")
// unless you're inside that same module
.andShould(beInTheSameModule());
@ArchTest
static final ArchRule infrastructure_does_not_depend_on_other_modules_infrastructure =
noClasses()
.that().resideInAPackage("com.company.app.(*).infrastructure..")
.should().accessClassesThat()
.resideInAPackage("com.company.app.(*).infrastructure..")
.andShould(beInDifferentModules());
@ArchTest
static final ArchRule domain_has_no_spring_annotations =
noClasses()
.that().resideInAPackage("com.company.app.(*).domain..")
.should().beAnnotatedWith(Component.class)
.orShould().beAnnotatedWith(Service.class)
.orShould().beAnnotatedWith(Repository.class);
}
The last rule — no Spring annotations in domain classes — enforces that your domain model is a pure Java object model, not a Spring-managed bean graph. This makes your domain independently testable without starting a Spring context.
This test suite runs in CI. A PR that breaks module boundaries doesn't merge. That's the whole mechanism.
Defining the Module API
Each module exposes its capabilities through a Java interface in api/:
// orders/api/OrderService.java
public interface OrderService {
OrderDto placeOrder(PlaceOrderCommand command);
OrderDto getOrder(UUID orderId);
List<OrderDto> getOrdersForCustomer(UUID customerId);
void cancelOrder(UUID orderId);
}
And DTO types — not domain objects:
// orders/api/OrderDto.java
public record OrderDto(
UUID id,
UUID customerId,
List<OrderLineDto> lines,
Money totalAmount,
String status,
Instant createdAt
) {}
public record OrderLineDto(UUID productId, String productName, int quantity, Money unitPrice) {}
Why DTOs instead of domain objects? Domain objects carry business logic and invariants — they're not safe to expose outside the module. Exposing Order directly creates an implicit coupling: any change to Order (adding a field, changing a type, splitting a class) breaks every caller. DTOs are a stable contract. Changes to domain model don't ripple out.
Cross-Module Communication via Domain Events
The most common question: "Orders needs to know when a product price changes in Catalog. How does that work without Orders depending on Catalog internals?"
Domain events. Catalog publishes an event. Orders subscribes.
// catalog/api/CatalogEvents.java
public sealed interface CatalogEvent extends DomainEvent permits
CatalogEvent.ProductPriceChanged,
CatalogEvent.ProductDiscontinued,
CatalogEvent.ProductRestocked {
record ProductPriceChanged(
UUID productId,
Money oldPrice,
Money newPrice,
Instant occurredAt
) implements CatalogEvent {}
record ProductDiscontinued(
UUID productId,
String reason,
Instant occurredAt
) implements CatalogEvent {}
record ProductRestocked(
UUID productId,
int newQuantity,
Instant occurredAt
) implements CatalogEvent {}
}
Using Java 21 sealed interfaces here is intentional. The permits clause makes the event hierarchy exhaustive — when you add a new event type, every switch that handles CatalogEvent gets a compile error if it doesn't handle the new case. Your IDE finds every subscriber that needs updating.