Modular Monolith
Modular Monolith
"A Modular Monolith is a single deployment unit whose internals are rigorously divided into well-defined modules. Done well, it is a legitimate target architecture — not a compromise waiting to be fixed." — Sam Newman, "Monolith to Microservices" (O'Reilly, 2020)
Intent
A Modular Monolith is a monolith whose internal structure is decomposed into well-defined modules with enforced boundaries — and for many teams, this is where the architecture should stay.
The prevailing "monolith to microservices" narrative treats the monolith as a temporary phase. Teams that extract microservices prematurely pay coordination costs without the deployment independence they expected: distributed transactions, inter-service latency, independent deployment complexity, and cross-team API versioning — all before any meaningful scaling need has been demonstrated. A Modular Monolith provides the structural benefits of bounded contexts without the operational overhead of distributed systems.
Each module is a self-contained Bounded-Context expressed in code: it owns its domain model, its data, and its public API surface. Modules communicate via in-process calls through explicit public interfaces, not through shared database access or HTTP calls. This structure makes future microservices extraction straightforward — but the architectural goal is correctness and maintainability, not extraction.
Each module's internal structure is a separate choice: a module can follow Clean-Architecture (four-layer Dependency Rule), Hexagonal-Architecture (ports and adapters), or Vertical-Slice-Architecture (feature-oriented decomposition). These patterns operate at different granularities — module boundary vs internal structure — and are not in conflict.
When NOT to Use
- Small applications with a single bounded context — one module equals one monolith, so enforced boundaries add overhead without benefit
- Prototypes or MVPs where architectural overhead exceeds exploration value
- Teams already successfully running microservices with independent deployment cadences — the benefit is already realised
- Systems where individual components have genuinely different scaling requirements that cannot be addressed by selective instance scaling of the monolith
- Projects with no more than 2–3 engineers where the discipline overhead of explicit module exports outweighs the coupling risk
When to Use
- Multiple bounded contexts within a single deployable unit
- Teams that want domain separation without taking on distributed systems complexity
- Systems where all modules share the same deployment cadence — extracting them adds operational cost with no benefit
- Organisations migrating from a tangled ("big ball of mud") monolith toward better internal structure
- As prerequisite structure before potential microservices extraction — well-defined module boundaries are the extraction seams
How It Works
Module Structure
One module = one Bounded-Context. If two modules need to share a concept, they communicate through module-public APIs, not shared database tables.
Each module owns its own schema or set of tables. In a PostgreSQL database this means separate schemas: orders.* tables belong exclusively to the Orders module; inventory.* tables belong exclusively to the Inventory module. Cross-module data access goes through the module's public API — never through a cross-schema SQL join. This rule makes the data ownership boundary legible in both code and schema.
Module communication is synchronous in-process calls via public interfaces. Inter-module HTTP calls add network overhead without providing deployment independence. Event-based decoupling using Domain-Events is optional — use it for eventual consistency scenarios where a module needs to react to another module's state change without tight coupling, not as the default communication mechanism.
Migration Path: Strangler Fig
A well-structured Modular Monolith has natural extraction seams for microservices: each module is already an independently cohesive unit with a defined public API and its own data. When a module genuinely needs independent deployment or scaling, extraction follows three steps:
-
Identify the module to extract — the module with a genuinely different deployment cadence or scaling profile. A module that deploys on every release alongside all others has no extraction justification. Validate against the Bounded-Context boundary: if the context is stable and independently deployable, proceed.
-
Replace in-process calls with an Anti-Corruption Layer — introduce an ACL that translates between the module's internal model and the emerging external contract. Other modules now call the ACL interface, not the module's in-process implementation. This step decouples the callers before the physical separation happens.
-
Deploy the module as an independent service behind the ACL — the Strangler Fig migration is complete when the original module code is deleted from the monolith. The ACL remains as the contract boundary.
"The best part of a well-structured Modular Monolith is that you may never need this migration. If your deployment unit works, keep it."
The Distributed Monolith Trap
The Distributed Monolith Trap
A Modular Monolith with unenforced boundaries is just a monolith with folders. Without automated boundary checks, teams inevitably reach across modules — creating the coupling of a monolith with none of the simplicity. Enforce boundaries from day one, or accept that you have a traditional monolith.
Architecture Diagram
Modular-Monolith-diagram.excalidraw
TypeScript Example
// Modular structure (NestJS):
//
// src/
// modules/
// orders/
// internal/
// order.entity.ts
// orders.service.ts // private — business logic
// order.repository.ts // private — data access
// public/
// orders.facade.ts // public — thin wrapper, stable API
// orders.module.ts // NestJS module declaration
// dto/
// create-order.dto.ts
// inventory/
// internal/
// inventory.service.ts
// public/
// inventory.module.ts
//
// Traditional layered (for contrast):
// src/
// controllers/
// OrderController.ts
// services/
// OrdersService.ts
// repositories/
// OrderRepository.ts
// modules/orders/public/orders.facade.ts
@Injectable()
export class OrdersFacade {
constructor(private readonly ordersService: OrdersService) {}
createOrder(dto: CreateOrderDto): Promise<Order> {
return this.ordersService.create(dto);
}
getOrder(id: string): Promise<Order> {
return this.ordersService.findById(id);
}
}
// modules/orders/public/orders.module.ts
@Module({
providers: [OrdersService, OrderRepository, OrdersFacade],
exports: [OrdersFacade], // only facade crosses the module boundary
})
export class OrdersModule {}
// modules/inventory/public/inventory.module.ts
@Module({
imports: [OrdersModule], // gets only OrdersFacade — cannot inject OrdersService or OrderRepository
providers: [InventoryService],
})
export class InventoryModule {}
// InventoryService can inject OrdersFacade; direct injection of OrdersService throws at runtimeJava Example
// Modular structure (Spring Modulith):
//
// com.app/
// orders/
// package-info.java // @ApplicationModule declaration
// OrderService.java // public API — root package = public by default
// internal/
// OrderRepository.java // private — sub-package = hidden from other modules
// OrderEntity.java
// inventory/
// package-info.java
// InventoryService.java // public API
// internal/
// InventoryRepository.java
//
// Traditional layered (for contrast):
// com.app/
// controllers/
// OrderController.java
// services/
// OrderService.java
// repositories/
// OrderRepository.java
// com/app/inventory/package-info.java
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "orders" // inventory may ONLY depend on orders module
)
package com.app.inventory;
// com/app/orders/OrderService.java — in root package: public API
public class OrderService {
private final OrderRepository repository; // internal — injected via DI, not visible outside module
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public OrderSummary placeOrder(PlaceOrderCommand cmd) {
return new OrderSummary(repository.save(new OrderEntity(cmd)));
}
}
// com/app/orders/internal/OrderRepository.java
package com.app.orders.internal;
// Classes in internal sub-packages are inaccessible from com.app.inventory.* per Spring Modulith rules
public interface OrderRepository {
OrderEntity save(OrderEntity entity);
Optional<OrderEntity> findById(String id);
}Enforcement Tooling
TypeScript
NestJS @Module({ exports: [] }) provides runtime enforcement: providers not in exports are not registered in the consuming module's DI container. An attempt to inject OrdersService from InventoryModule throws a NestJS: Nest can't resolve dependencies error at startup.
dependency-cruiser provides static analysis and CI enforcement — it catches forbidden imports before the application runs:
// .dependency-cruiser.js
// Source: github.com/sverweij/dependency-cruiser (rules-reference.md)
module.exports = {
forbidden: [
{
name: "no-cross-module-internal-access",
comment: "Module internals are private — import only from /public/ or module root",
severity: "error",
from: { path: "^src/modules/orders" },
to: { path: "^src/modules/inventory/internal" }
},
{
name: "no-orders-internal-from-inventory",
severity: "error",
from: { path: "^src/modules/inventory" },
to: { path: "^src/modules/orders/internal" }
}
]
};
// CI command:
// npx depcruise src --config .dependency-cruiser.jsAdd npx depcruise src --config .dependency-cruiser.js to CI pipeline. Violations exit non-zero and fail the build.
Java
Spring Modulith provides test-time enforcement via ApplicationModules.of(Application.class).verify(). Run this as a standard Spring Boot test — it throws if any module accesses another module's internal sub-package:
// Source: docs.spring.io/spring-modulith/reference/fundamentals.html (v2.0.4)
@SpringBootTest
class ModuleStructureTest {
@Test
void verifyModularStructure() {
ApplicationModules modules = ApplicationModules.of(Application.class);
modules.verify(); // Fails if any module accesses another module's internal packages
}
}ArchUnit provides supplementary enforcement for non-Spring projects or as an additional test layer:
// Source: archunit.org — layered architecture tests (adapted to module boundaries)
@AnalyzeClasses(packages = "com.app")
class ModuleBoundaryTest {
@ArchTest
static final ArchRule ordersInternalIsPrivate =
noClasses().that().resideOutsideOfPackage("com.app.orders..")
.should().accessClassesThat()
.resideInAPackage("com.app.orders.internal..");
@ArchTest
static final ArchRule inventoryInternalIsPrivate =
noClasses().that().resideOutsideOfPackage("com.app.inventory..")
.should().accessClassesThat()
.resideInAPackage("com.app.inventory.internal..");
}Both Spring Modulith verify() and ArchUnit rules should be in the standard test suite — they express the same constraint at different levels of granularity. Run both in CI.
Related Concepts
| Pattern | Relationship |
|---|---|
| Bounded-Context | One module = one Bounded Context. Module boundaries are Bounded Context boundaries expressed in code. The @ApplicationModule annotation and dependency-cruiser rules make the DDD concept a compile/test-time constraint. |
| Strangler-Fig-Pattern | Module boundaries are natural extraction seams for microservices. The Migration Path section above describes the 3-step extraction process. |
| Anti-Corruption-Layer-Pattern | The ACL is the mechanism that enables module extraction: it translates between the module's internal model and the external contract during migration. The ACL persists after extraction as the public contract boundary. |
| Clean-Architecture | A module's internal structure can follow Clean Architecture's four layers. The Dependency Rule applies within each module independently — the module boundary is a separate concern from the internal layer dependency direction. |
| Hexagonal-Architecture | A module's public API can be modelled as a driving port (the OrdersFacade is a driving port entry point). Internal services are the application core; database access is a driven adapter. The port/adapter vocabulary applies within each module. |
| Vertical-Slice-Architecture | VSA can be used within each module — they operate at different granularities (module boundary vs feature boundary). A module containing three features (place-order, cancel-order, query-order) can organise those features as vertical slices internally. Consistent with VSA note. |
| Domain-Events | Inter-module communication for eventual consistency scenarios. Modules publish domain events; subscribing modules react without direct coupling. Use for cases where tight synchronous coupling is inappropriate — not as default module communication. |
Sources
- Sam Newman, "Monolith to Microservices" (O'Reilly, 2020) — Modular Monolith as valid end-state framing, extraction criteria
- Shopify Engineering, "Deconstructing the Monolith" (2019) — Real-world Modular Monolith case study at scale
- docs.spring.io/spring-modulith/reference/fundamentals.html — Spring Modulith v2.0.4:
@ApplicationModule,allowedDependencies,verify()API - github.com/sverweij/dependency-cruiser — dependency-cruiser
forbiddenrules reference (rules-reference.md) - docs.nestjs.com/modules — NestJS
@Module({ exports: [] })module system