What is a Modular Monolith?
A Modular Monolith is a single deployable application organized into well-defined modules with explicit boundaries. Each module encapsulates a specific business domain and communicates with other modules through well-defined interfaces. It provides the organizational benefits of microservices while maintaining the operational simplicity of a monolith.
Why Modular Monolith?
- Simplicity of deployment: One artifact to build, test, and deploy
- Clear boundaries: Modules enforce separation of concerns like microservices
- Easy refactoring: Move code between modules with IDE support, no API changes
- ACID transactions: Cross-module transactions are simple since everything is in-process
- Migration path: Modules can be extracted to microservices when the need arises
Module Structure
// Project structure
// src/
// ├── modules/
// │ ├── orders/
// │ │ ├── index.ts (Public API — the ONLY export)
// │ │ ├── domain/
// │ │ │ ├── Order.ts
// │ │ │ └── OrderItem.ts
// │ │ ├── services/
// │ │ │ └── OrderService.ts
// │ │ ├── repositories/
// │ │ │ └── OrderRepository.ts
// │ │ └── events/
// │ │ └── OrderEvents.ts
// │ ├── inventory/
// │ │ ├── index.ts
// │ │ ├── domain/
// │ │ ├── services/
// │ │ └── repositories/
// │ ├── payments/
// │ │ ├── index.ts
// │ │ └── ...
// │ └── shipping/
// │ ├── index.ts
// │ └── ...
// ├── shared/
// │ ├── events/
// │ │ └── EventBus.ts
// │ └── types/
// │ └── common.ts
// └── main.ts
// Module public API (orders/index.ts)
// This is the ONLY file other modules can import from
export interface OrderModule {
placeOrder(command: PlaceOrderCommand): Promise;
getOrder(orderId: OrderId): Promise;
getOrdersByCustomer(customerId: CustomerId): Promise;
cancelOrder(orderId: OrderId, reason: string): Promise;
}
export interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
}
export interface OrderDTO {
id: string;
customerId: string;
status: string;
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
createdAt: Date;
}
// Events this module publishes (part of public API)
export interface OrderPlacedEvent {
type: "OrderPlaced";
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number }>;
total: number;
}
export interface OrderCancelledEvent {
type: "OrderCancelled";
orderId: string;
reason: string;
}
Module Implementation
// Internal implementation — NOT exported
// orders/services/OrderServiceImpl.ts
import { OrderModule, PlaceOrderCommand, OrderDTO } from "../index";
import { Order } from "../domain/Order";
import { OrderRepository } from "../repositories/OrderRepository";
import { EventBus } from "../../shared/events/EventBus";
export class OrderServiceImpl implements OrderModule {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus
) {}
async placeOrder(command: PlaceOrderCommand): Promise {
const order = Order.create(command.customerId, command.shippingAddress);
for (const item of command.items) {
order.addItem(item.productId, item.quantity);
}
order.place();
await this.orderRepo.save(order);
// Publish event for other modules
await this.eventBus.publish({
type: "OrderPlaced",
orderId: order.id,
customerId: command.customerId,
items: command.items,
total: order.total,
});
return order.id;
}
async getOrder(orderId: OrderId): Promise {
const order = await this.orderRepo.findById(orderId);
return order ? this.toDTO(order) : null;
}
async cancelOrder(orderId: OrderId, reason: string): Promise {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Order not found");
order.cancel(reason);
await this.orderRepo.save(order);
await this.eventBus.publish({
type: "OrderCancelled",
orderId: order.id,
reason,
});
}
private toDTO(order: Order): OrderDTO {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items.map(i => ({
productId: i.productId,
quantity: i.quantity,
price: i.price,
})),
total: order.total,
createdAt: order.createdAt,
};
}
}
Inter-Module Communication
// Shared event bus for module communication
class InProcessEventBus implements EventBus {
private handlers = new Map Promise>>();
subscribe(eventType: string, handler: (event: T) => Promise): void {
const existing = this.handlers.get(eventType) || [];
existing.push(handler as (event: unknown) => Promise);
this.handlers.set(eventType, existing);
}
async publish(event: { type: string; [key: string]: unknown }): Promise {
const handlers = this.handlers.get(event.type) || [];
// Execute handlers — in a real system, consider parallel vs sequential
for (const handler of handlers) {
await handler(event);
}
}
}
// Inventory module reacts to order events
class InventoryEventHandler {
constructor(
private readonly inventoryService: InventoryService,
eventBus: EventBus
) {
eventBus.subscribe("OrderPlaced", (event) =>
this.onOrderPlaced(event)
);
eventBus.subscribe("OrderCancelled", (event) =>
this.onOrderCancelled(event)
);
}
private async onOrderPlaced(event: OrderPlacedEvent): Promise {
for (const item of event.items) {
await this.inventoryService.reserveStock(item.productId, item.quantity);
}
}
private async onOrderCancelled(event: OrderCancelledEvent): Promise {
await this.inventoryService.releaseReservation(event.orderId);
}
}
Enforcing Module Boundaries
// Use TypeScript path aliases to enforce imports
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@orders": ["src/modules/orders/index.ts"],
"@inventory": ["src/modules/inventory/index.ts"],
"@payments": ["src/modules/payments/index.ts"],
"@shared/*": ["src/shared/*"]
}
}
}
// ESLint rule to prevent deep imports
// .eslintrc.js
module.exports = {
rules: {
"no-restricted-imports": ["error", {
patterns: [
{ group: ["**/modules/*/domain/*"], message: "Import from module index only" },
{ group: ["**/modules/*/services/*"], message: "Import from module index only" },
{ group: ["**/modules/*/repositories/*"], message: "Import from module index only" },
],
}],
},
};
// Correct: import from module public API
import { OrderModule } from "@orders";
// Wrong: reaching into module internals
// import { Order } from "@orders/domain/Order"; // ESLint error!
Modular Monolith Rules
- Public API only: Modules only interact through their public interfaces (index.ts) — never import internal files
- Own your data: Each module owns its database tables — no cross-module table access
- Events for decoupling: Use an in-process event bus for cross-module communication instead of direct calls
- No circular dependencies: If modules A and B depend on each other, extract shared concepts into a new module or use events
- Test in isolation: Each module should be testable without the others