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:

  1. 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.

  2. 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.

  3. 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 runtime

Java 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.js

Add 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.

PatternRelationship
Bounded-ContextOne 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-PatternModule boundaries are natural extraction seams for microservices. The Migration Path section above describes the 3-step extraction process.
Anti-Corruption-Layer-PatternThe 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-ArchitectureA 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-ArchitectureA 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-ArchitectureVSA 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-EventsInter-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 forbidden rules reference (rules-reference.md)
  • docs.nestjs.com/modules — NestJS @Module({ exports: [] }) module system