Modular Monoliths: How to Enforce Service Boundaries and Prevent Spaghetti Code
· Architecture · TypeScript · Node.js · Domain-Driven Design · Monolith
Learn how to enforce strict architectural boundaries in a modular monolith using automated dependency checks, package isolation, and domain-driven design principles.
更新情報を受け取る
新しい記事を公開したときに短いお知らせを送ります。メールまたはブラウザの購読情報は通知配信のためだけに保存され、いつでも解除できます。アカウントや追跡用プロフィールは不要です。
Introduction to Modular Monoliths
As microservices fall out of favor for many mid-scale systems due to operational overhead, network latency, and distributed transaction complexity, the modular monolith has emerged as a resilient architectural alternative. A modular monolith provides the deployment simplicity of a single process while retaining logical separation between business domains. However, without strict enforcement mechanisms, teams inevitably fall back into old habits, resulting in circular dependencies, tightly coupled database access, and the dreaded distributed-monolith-in-a-box anti-pattern.
Enforcing boundaries requires moving beyond code conventions and README files. It demands automated tooling, compilation-level constraints, and deliberate directory layouts that make illegal imports impossible. This guide covers how to design, implement, and enforce strict boundaries within a TypeScript-based modular monolith.
Establishing Domain Boundaries
To prevent code degradation, you must first define what a module is. In a modular monolith, a module represents a bounded context in Domain-Driven Design (DDD) terminology. It encapsulates its own data schema models, application logic, and domain services. Other modules cannot access internal resources directly; they must interact solely through a well-defined, public API contract.
Consider a typical e-commerce system containing an orders module and an inventory module. The directory structure should reflect these physical boundaries:
src/
orders/
internal/
order-repository.ts
order-processor.ts
public/
orders-api.ts
types.ts
index.ts
inventory/
internal/
inventory-repository.ts
public/
inventory-api.ts
index.tsThe index.ts file at the root of each module acts as the explicit entry point. It exports only the public API classes, interfaces, and DTOs. Anything living inside the internal directory is strictly private to that module.
Enforcing Boundaries with Compilation Rules
Code reviews and linting rules are often insufficient for maintaining architectural invariants because developers can easily bypass them with suppression flags or simple oversight. Instead, use compiler-level boundaries or dedicated static analysis tools to prevent unauthorized cross-module imports.
Using TypeScript Project References
TypeScript project references allow you to split a single codebase into multiple distinct tsconfig projects. By leveraging the composite setting and controlling references, you can configure the compiler to reject cross-module imports that bypass the public API.
Here is an example tsconfig.json setup for the orders module that depends only on shared packages, but remains isolated from direct internal imports of the inventory module:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"strict": true,
"module": "commonjs",
"target": "es2022"
},
"include": ["src/orders/**/*"]
}To further restrict cross-module access without setting up complex multi-package monorepos, use ESLint plugins such as eslint-plugin-import or eslint-plugin-boundaries. These tools allow you to declare element types and enforce dependency rules directly in your ESLint configuration.
module.exports = {
plugins: ["boundaries"],
settings: {
"boundaries/elements": [
{ type: "orders", pattern: "src/orders/*" },
{ type: "inventory", pattern: "src/inventory/*" },
{ type: "shared", pattern: "src/shared/*" }
]
},
rules: {
"boundaries/element-types": [
"error",
{
"default": "disallow",
"rules": [
{ from: "orders", allow: ["shared", "inventory"] },
{ from: "inventory", allow: ["shared"] }
]
}
]
}
};This configuration guarantees that the inventory module can never import code from the orders module, effectively eliminating circular dependencies at build time.
Managing Database Isolation and Transactions
The most common failure mode in modular monoliths is database coupling. When developers share a single database connection pool and write raw SQL joins across tables owned by different modules, logical boundaries collapse instantly.
To maintain physical separation while running in a single database instance, adhere to these rules:
- Schema Isolation: Each module should own its dedicated tables or database schemas (e.g.,
schema_orders,schema_inventory). Applications should connect using distinct database users with permissions restricted strictly to their respective schemas. - No Cross-Module Joins: An
orderrepository must never execute a SQL query that joins theorderstable with theinventory_itemstable. If the orders module needs inventory details, it must call theinventorymodule's public API. - Distributed Transactions vs. Eventual Consistency: When an operation spans multiple modules, avoid wrapping everything in a monolithic database transaction. Instead, utilize transactional outbox patterns and asynchronous domain events to propagate state changes.
Implementing Asynchronous Events
When the orders module successfully creates an order, it should emit a domain event rather than directly calling an inventory service method synchronously. This decouples failure domains and improves latency.
import { EventBus } from '../shared/event-bus';
import { OrderCreatedEvent } from './types';
export class OrderService {
constructor(private readonly eventBus: EventBus) {}
async createOrder(data: CreateOrderDto): Promise<void> {
// Persist order within local transaction
const order = await this.saveOrder(data);
// Publish event for downstream modules
await this.eventBus.publish(new OrderCreatedEvent(order.id, order.items));
}
}The inventory module subscribes to OrderCreatedEvent, processes the stock reduction asynchronously, and handles retries independently if failures occur.
Testing Strategies for Modular Monoliths
Testing a modular monolith requires a tiered approach that respects module boundaries. Avoid writing end-to-end tests for every business rule; instead, rely heavily on isolated unit tests and module-level integration tests.
- Unit Tests: Test business logic within a single module by mocking its external dependencies and public API consumers.
- Integration Tests: Test a single module against its dedicated database schema or containerized test database to verify repository logic and constraints.
- Architectural Tests: Implement automated tests using libraries like
dependency-cruiserto scan the dependency graph during CI builds and fail the pipeline if a forbidden import is detected.
// Example architectural test snippet using dependency-cruiser output
const validator = require('dependency-cruiser');
describe('Architectural Boundaries', () => {
it('should not allow modules to import internals of other modules', async () => {
const result = await validator.cruise(['src'], { ruleSet: require('./.dependency-cruiser.js') });
expect(result.output.summary.violationCount).toBe(0);
});
});Operations, Deployment, and Migration Path
The primary operational advantage of a modular monolith is its trivial deployment footprint. You compile a single artifact, deploy a single container, and scale a single process. CPU and memory utilization remain optimal because inter-module communication happens via direct in-memory function calls instead of network serialization over HTTP or gRPC.
When a specific module outgrows the monolith due to heavy resource consumption or independent scaling requirements, extracting it into a microservice is straightforward. Because internal boundaries are already strictly enforced and cross-module calls traverse clear API boundaries, you only need to replace the in-memory API client with an HTTP or gRPC client implementation.
Conclusion
Modular monoliths offer a pragmatic middle ground between the unmanageable sprawl of legacy monolithic applications and the operational complexity of premature microservices. By enforcing strict compilation rules, maintaining database schema isolation, and communicating via explicit public APIs, engineering teams can build scalable, maintainable systems without distributed system overhead.
