Modular Monolith Architecture: The Best of Both Worlds
The industry swung hard toward microservices, and now teams are paying the complexity tax — distributed transactions, network latency, operational overhead for dozens of services, and debugging distributed traces. Modular monolith architecture offers a middle ground: the modularity and domain isolation of microservices with the simplicity of a single deployment unit. Therefore, this guide explains how to design module boundaries, enforce isolation, and create a clear migration path to microservices if you ever need it.
The core idea is deceptively simple. You keep one deployable artifact and one process, but inside that process you carve the codebase into modules that behave like miniature services with hard walls between them. Each module is a citizen with a passport — it can talk to its neighbors only through a documented border crossing, never by climbing the fence. In practice teams that adopt this model report dramatically faster local feedback loops because there is no network hop, no service mesh, and no eventual-consistency puzzle to reason about during day-to-day feature work.
Why Not Just Microservices?
Microservices solve real problems — team autonomy, independent scaling, technology diversity. But they also create real problems. A simple feature that touches three microservices requires coordinating three deployments, handling distributed transactions, and debugging across three different log streams. Moreover, for teams smaller than 50 engineers, the operational overhead of microservices often exceeds the organizational benefits.
This middle-ground design gives you strong module boundaries (like service boundaries) without the distributed systems complexity. Each module owns its domain, has its own database schema (or database), and communicates with other modules through well-defined APIs. However, it all deploys as a single application, shares one process, and uses local function calls instead of network requests. Consequently, a refactor that would span a dozen repositories in a microservice world becomes a single atomic commit with a single compiler pass to catch mistakes.
Microservices Trade-offs:
✅ Independent deployment ❌ Network latency between services
✅ Independent scaling ❌ Distributed transactions
✅ Technology diversity ❌ Operational complexity (50+ services)
✅ Team autonomy ❌ Debugging distributed traces
Modular Monolith Trade-offs:
✅ Module isolation ❌ Single deployment unit
✅ Simple operations ❌ Shared scaling
✅ Local transactions ❌ Same technology stack
✅ Easy debugging ❌ Requires discipline to maintain boundaries
Sweet spot: 5-30 engineers, <20 bounded contexts, moderate scale
Industry voices like Martin Fowler and the DDD community have increasingly argued for this "monolith first" stance. The reasoning is that you rarely know your true service boundaries on day one; extracting a service prematurely freezes a boundary that you will probably want to move six months later. A modular codebase lets boundaries flex cheaply while the domain is still being discovered.
Designing Module Boundaries
Module boundaries should follow Domain-Driven Design bounded contexts. Each module owns a business capability end-to-end: its domain model, its database tables, its business rules, and its public API. No module reaches into another module's database tables or internal classes. Furthermore, the public API of each module should be a narrow interface — only expose what other modules genuinely need.
// Project structure — each module is a separate package/project
// com.company.app/
// ├── order/ -- Order module
// │ ├── api/ -- Public interface (other modules use ONLY this)
// │ │ ├── OrderService.java
// │ │ └── OrderDTO.java
// │ ├── internal/ -- Internal implementation (not accessible outside)
// │ │ ├── OrderEntity.java
// │ │ ├── OrderRepository.java
// │ │ └── OrderValidator.java
// │ └── events/ -- Events this module publishes
// │ └── OrderPlacedEvent.java
// ├── inventory/ -- Inventory module
// │ ├── api/
// │ │ ├── InventoryService.java
// │ │ └── StockDTO.java
// │ ├── internal/
// │ └── events/
// └── payment/ -- Payment module
// Order module's public API — the ONLY thing other modules can use
public interface OrderService {
OrderDTO placeOrder(CreateOrderRequest request);
OrderDTO getOrder(UUID orderId);
List<OrderDTO> getOrdersByCustomer(UUID customerId);
void cancelOrder(UUID orderId);
}
// Internal implementation — NOT accessible outside the module
class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepo;
private final InventoryService inventoryService; // Uses inventory's public API
private final EventPublisher eventPublisher;
@Override
@Transactional
public OrderDTO placeOrder(CreateOrderRequest request) {
// Check stock through inventory module's public API
StockDTO stock = inventoryService.checkStock(request.getProductId());
if (stock.getAvailable() < request.getQuantity()) {
throw new InsufficientStockException(request.getProductId());
}
// Create order in this module's tables
OrderEntity order = OrderEntity.create(request);
orderRepo.save(order);
// Publish event for other modules to react
eventPublisher.publish(new OrderPlacedEvent(
order.getId(), request.getProductId(), request.getQuantity()
));
return OrderMapper.toDTO(order);
}
}
Notice the deliberate package split between api and internal. The internal package is package-private by intent — classes there carry no public modifier where possible, so the compiler itself becomes your first line of defense. DTOs returned across boundaries are immutable snapshots, never live JPA entities; this prevents a foreign module from accidentally mutating another module's managed state inside a shared persistence context.
Schema Ownership and the Shared Database Question
A frequent stumbling block is the database. Many teams start with one physical database and assume that means modules can freely join across each other's tables. That shortcut is exactly what erodes boundaries over time. A healthier pattern is one schema per module within a single PostgreSQL instance — for example order.orders, inventory.stock, and payment.transactions — with a database role per module that lacks SELECT privileges on its neighbors' schemas.
-- One database, isolated schemas, enforced at the DB layer
CREATE SCHEMA order_mod;
CREATE SCHEMA inventory_mod;
CREATE ROLE order_app LOGIN;
CREATE ROLE inventory_app LOGIN;
-- Each module's role only touches its own schema
GRANT USAGE, CREATE ON SCHEMA order_mod TO order_app;
GRANT USAGE, CREATE ON SCHEMA inventory_mod TO inventory_app;
-- Explicitly REVOKE cross-schema access so a stray join fails loudly
REVOKE ALL ON SCHEMA inventory_mod FROM order_app;
With schemas separated, a developer who tries to write SELECT * FROM inventory_mod.stock from the order module gets a permission error at runtime rather than a silent coupling that nobody notices until extraction day. This also makes the eventual migration to a separate database almost mechanical, since the data is already physically partitioned by ownership.
Enforcing Module Boundaries
The biggest risk with a modular monolith is boundary erosion — developers taking shortcuts by accessing another module's internal classes or database tables directly. Without enforcement, your codebase degrades into a traditional big ball of mud. You need automated tools to enforce boundaries in continuous integration, not a wiki page that everyone forgets.
// ArchUnit tests — run in CI to enforce module boundaries
@AnalyzeClasses(packages = "com.company.app")
class ModuleBoundaryTests {
@ArchTest
static final ArchRule orderModuleInternalsArePrivate =
classes()
.that().resideInAPackage("..order.internal..")
.should().onlyBeAccessed()
.byClassesThat().resideInAnyPackage("..order..");
@ArchTest
static final ArchRule modulesOnlyUsePublicAPIs =
classes()
.that().resideInAPackage("..inventory.internal..")
.should().notBeAccessed()
.byClassesThat().resideInAPackage("..order..");
@ArchTest
static final ArchRule noDirectDatabaseAccess =
noClasses()
.that().resideInAPackage("..order..")
.should().accessClassesThat()
.resideInAPackage("..inventory.internal..");
}
// Spring Modulith — provides module isolation with dependency verification
// In pom.xml or build.gradle, use Spring Modulith's ApplicationModules
@SpringBootTest
class ModularityTests {
@Test
void verifyModularity() {
ApplicationModules.of(Application.class).verify();
// Fails if modules have undeclared dependencies
}
}
Spring Modulith is particularly powerful for Java applications. It automatically detects modules, verifies dependency rules, generates module documentation, and supports event-driven communication between modules. Additionally, it provides @ApplicationModuleTest for testing modules in isolation. A common pattern is to make ApplicationModules.verify() a hard gate in the build so that any pull request introducing an illegal dependency fails before review. For deeper event mechanics, our companion post on event-driven architecture with Kafka shows how the same events later travel over the wire.
Inter-Module Communication
Modules should communicate through two mechanisms: synchronous API calls (for queries) and asynchronous events (for commands and reactions). Direct API calls work for simple queries — "What's the current stock for product X?" Events work for reactions — "An order was placed, so reduce stock by 5." Keeping these two paths distinct prevents the tangle of synchronous call chains that quietly recreate distributed-system fragility inside a single process.
Using in-process events (Spring ApplicationEvents, Guava EventBus) gives you the decoupling benefits of message queues without the operational overhead. If you later extract a module into a microservice, you swap the in-process event publisher for Kafka or RabbitMQ — the consuming code doesn't change. One important edge case is transactional consistency: by default Spring publishes events synchronously inside the caller's transaction, so a listener that fails will roll back the order. When you want the listener to run only after a successful commit, @TransactionalEventListener(phase = AFTER_COMMIT) combined with Spring Modulith's persistent event publication registry gives you at-least-once delivery without a broker.
Migration Path to Microservices
A well-designed modular codebase is pre-factored for microservice extraction. When a module needs independent scaling, a different deployment cadence, or a different technology stack, you extract it. The process is incremental: extract the module's database tables to a separate database, replace in-process API calls with REST or gRPC calls, replace in-process events with Kafka or RabbitMQ, and deploy the module as a separate service. Because the public API was already the only allowed entry point, callers barely notice the swap.
Critically, you only extract modules that have a clear operational reason to be separate. Most organizations find that 3-5 modules need extraction while the rest are perfectly happy together. Consequently, you get the benefits of microservices where needed without paying the complexity tax everywhere. Teams that compare this approach to a service mesh — see our API gateway comparison — often conclude that they only need that machinery for the two or three modules that truly carry asymmetric load.
When NOT to Use This Approach — The Trade-offs
This pattern is not free, and pretending otherwise sets teams up for disappointment. The single deployment unit means every module ships together, so a risky change in the reporting module forces a redeploy of the payment module. If you have one team that genuinely needs to deploy fifty times a day independently of everyone else, the shared release train will frustrate them. Likewise, the whole application scales as one block; you cannot give the search module ten replicas and the admin module one without splitting it out.
There is also a discipline cost. The boundaries only hold if the team keeps the enforcement tooling green and resists the temptation to "just add a quick join." On a team without that culture, or with high churn and weak code review, boundaries erode and you end up with the worst of both worlds — a tangled monolith that is also hard to extract. Finally, if your organization mandates polyglot technology stacks per team, a single-runtime model simply cannot accommodate that. In those cases genuine microservices, despite their cost, are the honest answer.
Related Reading:
Resources:
In conclusion, the modular monolith architecture is not a stepping stone to microservices — it's a legitimate architecture for most applications. Strong module boundaries, enforced through tooling, give you domain isolation without distributed systems complexity. Start modular, extract only when you have a proven operational need, and enjoy the simplicity of deploying one application instead of fifty.