A Modular Monolith structures a single deployment artifact into discrete, loosely-coupled domain modules with strict encapsulation boundaries, balancing microservice-level separation with monolithic operational simplicity.
When to Use
- Medium-to-Large Engineering Teams: Scaling a codebase across multiple teams without the operational overhead of microservices.
- Single-Deployment Velocity: Retaining simple CI/CD pipelines, single-transaction database migrations, and local Docker setups.
- Clear Domain Boundaries: Preventing spaghetti code by enforcing strict module encapsulation and public API boundaries at compile-time.
- Pre-Microservice Preparation: Architecting systems that can be cleanly sliced into separate microservices later if scaling demands it.
Quick Start
/src
/Modules
/Catalog <-- Public Interface defined here
/Core <-- Internals (Classes, Logic) hidden
/API <-- Public Contracts (DTOs)
/Ordering
/Core
/Shipping
/Core
/Shared <-- Infrastructure, Event Bus
// Communication via In-Process Interfaces or Events
public class OrderService {
private readonly ICatalogModule _catalog; // In-memory reference, but strict contract
public async Task Checkout(string productId) {
var product = await _catalog.GetProduct(productId); // Fast 0ms call
// ...
}
}
Core Concepts
Strict Module Encapsulation & Public API Facades
Modules interact exclusively through explicit public facades; internal repositories, models, and helpers are unexported:
src/modules/
├── billing/
│ ├── internal/ # Private tables, services, entities
│ └── index.ts # Public Interface & Facade ONLY
└── shipping/
├── internal/
└── index.ts
In-Process Domain Events
Modules communicate asynchronously across boundaries using in-memory event buses:
// modules/common/event-bus.ts
import EventEmitter from "events";
export const inProcessBus = new EventEmitter();
// modules/orders/internal/order.service.ts
inProcessBus.emit("order.created", { orderId: "123", customerId: "456" });
// modules/notifications/internal/notification.listener.ts
inProcessBus.on("order.created", (payload) => {
// Executes in same process, decoupled from order module code
});
Architecture Linter Boundary Enforcement
Enforces boundary rules in CI using tools like eslint-plugin-boundaries or ArchUnit:
// .eslintrc.js
rules: {
"boundaries/element-types": [2, {
default: "disallow",
rules: [
{ from: "module:billing", allow: ["module:billing", "module:common"] },
{ from: "module:shipping", allow: ["module:shipping", "module:common"] },
]
}]
}
Architectural Advantages over Microservices
- Zero-Latency In-Memory Invocation: Module boundaries communicate through typed facades without network serialization or RPC latency.
- Local Transactional Consistency: ACID transactions remain possible across co-located tables without distributed 2PC or complex Saga orchestration.
- Unified Toolchain Refactoring: IDE refactorings, cross-cutting interface changes, and atomic commits span the full system without multi-repo drift.
Common Patterns
Internal Module Public API Facade
Problem: Modules access each other's private tables, destroying encapsulation and preventing future service extraction.
Solution: Expose an explicit Public API interface per module.
// modules/billing/index.ts (Billing Module Public Boundary)
export interface BillingModuleApi {
chargeCustomer(
customerId: string,
amountCents: number,
): Promise<PaymentReceipt>;
getSubscriptionStatus(customerId: string): Promise<SubscriptionStatus>;
}
// Internal billing tables, services, and repositories are NOT exported.
export class BillingFacade implements BillingModuleApi {
constructor(private readonly paymentService: InternalPaymentService) {}
async chargeCustomer(id: string, amount: number) {
return this.paymentService.processCharge(id, amount);
}
async getSubscriptionStatus(id: string) {
return this.paymentService.checkSubscription(id);
}
}
Best Practices
Do:
- Enforce Boundaries with Build Tools: Use ESLint rules, TypeScript project references, or ArchUnit to prevent illegal module cross-imports.
- Isolate Module Schemas: Use separate database schemas (
billing.*,users.*) within the same database to prevent illicit SQL joins. - Communicate Across Modules via Facades or Events: Disallow direct access to another module's internal classes or database repositories.
- Keep Deployment Pipeline Unified: Enjoy the speed of atomic single-binary deployments and coordinated database migrations.
Don't:
- Execute direct cross-module foreign key joins: Reference entities from foreign modules by ID only; do not write
LEFT JOIN shipping.parcels. - Share mutable memory state across modules: Pass immutable DTOs or primitive values through public facade methods.
- Prematurely split into microservices: Extract a module into a microservice only when deployment frequency or scaling demands it.
Troubleshooting
| Error | Cause | Solution |
|---|---|---|
Cyclic module dependency |
Modules directly importing internal classes across boundaries. | Expose strictly typed public APIs/facades and enforce boundary linting. |
Accidental shared database table |
Cross-module queries joining tables across domains. | Isolate schemas per module or access data exclusively through module interfaces. |
Leaky module abstractions |
Direct access to internal database entities instead of DTOs. | Return immutable Data Transfer Objects (DTOs) from module public services. |