← Back to skills
extension
Category: Development & EngineeringAPI key requirement unconfirmed

modular-monolith

Modular monolith with bounded contexts. Use for scalable monoliths.

personAuthor: jakexiaohubgithub

Modular Monolith

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

References