TechLead
Lesson 21 of 27
5 min read
Software Architecture

Modular Monolith

Learn how to build a well-structured modular monolith that combines the simplicity of a monolith with the boundaries of microservices

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

Continue Learning