Published on

The Modular Monolith — All the Benefits of Microservices Without the Distributed Systems Tax

Authors

Introduction

The microservices hype has cooled, and many teams are rediscovering that a well-structured monolith can be superior to a dozen half-baked services. A modular monolith applies microservices principles—bounded contexts, independent teams, clean interfaces—while retaining simplicity.

Bounded Contexts as Modules, Not Services

Domain-Driven Design's bounded context is a natural module boundary. Instead of breaking into separate deployments, keep them as distinct directories within one codebase.

// src/modules/auth/
// src/modules/orders/
// src/modules/payments/

// No direct imports across modules. Only through published interfaces.
import type { AuthTokenPayload } from './modules/auth/interfaces'

// Each module owns its domain logic completely
export namespace OrdersModule {
  export interface CreateOrderRequest {
    userId: string
    items: Array<{ productId: string; quantity: number }>
  }

  export interface OrderCreatedEvent {
    orderId: string
    userId: string
    total: number
    timestamp: Date
  }

  export class Order {
    constructor(
      public id: string,
      public userId: string,
      public status: 'pending' | 'confirmed' | 'shipped'
    ) {}

    confirm(): OrderCreatedEvent {
      this.status = 'confirmed'
      return { orderId: this.id, userId: this.userId, total: 0, timestamp: new Date() }
    }
  }
}

Module Interface Contracts and No Cross-DB Access

Each module defines explicit interfaces. Modules never access another module's database directly.

// src/modules/auth/index.ts - the published interface
export interface AuthService {
  validateToken(token: string): Promise<AuthTokenPayload | null>
  refreshToken(refreshToken: string): Promise<string>
}

export interface UserRepository {
  findById(userId: string): Promise<User | null>
  save(user: User): Promise<void>
}

// src/modules/orders/order-service.ts
import type { AuthService } from '../auth'

export class OrderService {
  constructor(
    private authService: AuthService,
    private repository: OrderRepository
  ) {}

  async createOrder(token: string, request: CreateOrderRequest): Promise<Order> {
    const payload = await this.authService.validateToken(token)
    if (!payload) throw new Error('Invalid token')

    const order = new Order(crypto.randomUUID(), payload.userId, 'pending')
    await this.repository.save(order)
    return order
  }
}

// Bad: Modules NEVER do this
// const user = await db.query('SELECT * FROM auth.users WHERE id = ?')

Module Directory Structure

Organize each module with a standard structure that makes boundaries obvious:

src/modules/orders/
├── index.ts                 (public interface exports)
├── entities/
│   ├── order.ts            (domain entity)
│   └── line-item.ts
├── services/
│   ├── order-service.ts    (use cases)
│   └── shipping-service.ts
├── repositories/
│   ├── order-repository.ts (interface definition)
│   └── order-repository.postgres.ts (implementation)
├── events/
│   └── order-created.ts
├── adapters/
│   └── stripe-payment-adapter.ts (external integrations)
└── __tests__/
    ├── order-service.test.ts
    └── fixtures.ts

Each module's index.ts explicitly exports only what's public:

// src/modules/orders/index.ts
export type { Order, CreateOrderRequest } from './entities/order'
export { OrderService } from './services/order-service'
export type { OrderRepository } from './repositories/order-repository'
export type { OrderCreatedEvent } from './events/order-created'

Shared Kernel for Cross-Cutting Concerns

Some code naturally spans all modules: logging, metrics, error handling. Keep this in a shared folder:

// src/shared/logger.ts
export class Logger {
  info(module: string, message: string, context?: Record<string, any>) {
    console.log(JSON.stringify({
      timestamp: new Date().toISOString(),
      level: 'info',
      module,
      message,
      ...context
    }))
  }

  error(module: string, error: Error, context?: Record<string, any>) {
    console.error(JSON.stringify({
      timestamp: new Date().toISOString(),
      level: 'error',
      module,
      message: error.message,
      stack: error.stack,
      ...context
    }))
  }
}

// src/shared/errors.ts
export class DomainError extends Error {
  constructor(message: string, public code: string) {
    super(message)
    this.name = 'DomainError'
  }
}

export class ValidationError extends DomainError {
  constructor(message: string) {
    super(message, 'VALIDATION_ERROR')
  }
}

Enforcing Module Boundaries With ESLint Rules

Define boundaries in eslintrc to prevent unauthorized imports:

// .eslintrc.json
{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          "src/modules/*/repositories/*.postgres.ts",
          "src/modules/*/adapters/*.ts"
        ],
        "message": "Import adapters and repositories only through module index"
      }
    ]
  },
  "overrides": [
    {
      "files": ["src/modules/orders/**"],
      "rules": {
        "no-restricted-imports": [
          "error",
          {
            "patterns": ["src/modules/!(orders|shared)/**"],
            "message": "Orders module can only import from its own module or shared"
          }
        ]
      }
    }
  ]
}

Integration Between Modules: Sync vs Event

Modules communicate via two patterns:

Synchronous: Direct service calls for immediate consistency:

// OrderService needs real-time payment status
export class OrderService {
  constructor(
    private paymentService: PaymentService,
    private repository: OrderRepository
  ) {}

  async createOrder(request: CreateOrderRequest): Promise<Order> {
    const order = new Order(crypto.randomUUID(), request.userId, 'pending')
    await this.repository.save(order)

    try {
      const payment = await this.paymentService.charge(order.id, request.total)
      order.confirmPayment(payment.id)
      await this.repository.save(order)
      return order
    } catch (error) {
      order.markFailed()
      await this.repository.save(order)
      throw error
    }
  }
}

Asynchronous: Events for loose coupling:

// src/shared/event-bus.ts
export interface DomainEvent {
  eventType: string
  aggregateId: string
  timestamp: Date
}

export class InProcessEventBus {
  private handlers: Map<string, Array<(event: DomainEvent) => Promise<void>>> = new Map()

  subscribe(eventType: string, handler: (event: DomainEvent) => Promise<void>) {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, [])
    }
    this.handlers.get(eventType)!.push(handler)
  }

  async publish(event: DomainEvent) {
    const handlers = this.handlers.get(event.eventType) || []
    await Promise.allSettled(handlers.map(h => h(event)))
  }
}

// Order module publishes events
export class OrderService {
  constructor(private eventBus: InProcessEventBus, private repository: OrderRepository) {}

  async createOrder(request: CreateOrderRequest): Promise<Order> {
    const order = new Order(crypto.randomUUID(), request.userId, 'pending')
    await this.repository.save(order)

    await this.eventBus.publish({
      eventType: 'OrderCreated',
      aggregateId: order.id,
      timestamp: new Date()
    })

    return order
  }
}

// Notifications module listens
export class NotificationListener {
  constructor(private eventBus: InProcessEventBus, private emailSender: EmailSender) {
    this.eventBus.subscribe('OrderCreated', this.onOrderCreated.bind(this))
  }

  private async onOrderCreated(event: DomainEvent) {
    await this.emailSender.send({
      to: 'customer@example.com',
      subject: 'Order Confirmed',
      body: `Your order ${event.aggregateId} is confirmed`
    })
  }
}

Extracting a Module to a Microservice

When a module is large enough to warrant its own service, the transition is straightforward because boundaries already exist:

// Before: All in one process
const authService = new AuthService(authRepository)
const orderService = new OrderService(orderRepository, authService)

// After: Orders is now a separate service
// orderService.ts still imports the same interface
import type { AuthService } from '@mycompany/auth-service-client'

const authServiceClient: AuthService = new AuthServiceClient('http://auth-service:3000')
const orderService = new OrderService(orderRepository, authServiceClient)

The domain logic doesn't change. Only the transport layer changes.

Testing Modules in Isolation

Each module is tested independently with in-memory implementations:

// src/modules/orders/__tests__/order-service.test.ts
class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map()

  async save(order: Order): Promise<void> {
    this.orders.set(order.id, order)
  }

  async findById(id: string): Promise<Order | null> {
    return this.orders.get(id) || null
  }
}

class FakeAuthService implements AuthService {
  async validateToken(token: string): Promise<AuthTokenPayload | null> {
    return token === 'valid' ? { userId: 'user123', exp: Date.now() + 3600000 } : null
  }
}

describe('OrderService', () => {
  let service: OrderService
  let repository: InMemoryOrderRepository
  let authService: FakeAuthService

  beforeEach(() => {
    repository = new InMemoryOrderRepository()
    authService = new FakeAuthService()
    service = new OrderService(repository, authService)
  })

  it('should create order for valid token', async () => {
    const order = await service.createOrder('valid', {
      userId: 'user123',
      items: [{ productId: 'p1', quantity: 2 }]
    })

    expect(order.status).toBe('pending')
    expect(order.userId).toBe('user123')
  })

  it('should reject invalid token', async () => {
    expect(() =>
      service.createOrder('invalid', { userId: 'user123', items: [] })
    ).rejects.toThrow('Invalid token')
  })
})

Checklist

  • Each module has a distinct src/modules/moduleName directory
  • Module boundaries enforced via ESLint no-restricted-imports
  • All cross-module communication goes through published interfaces in index.ts
  • Modules never access other modules' databases directly
  • Cross-cutting concerns isolated in src/shared
  • Synchronous calls used only when immediate consistency needed
  • Asynchronous events used for notifications and non-critical updates
  • Each module tested independently with fake/in-memory implementations
  • Clear migration path to microservices when needed

Conclusion

A modular monolith gives you the flexibility of microservices—team ownership, independent testing, isolated deployment considerations—without the operational burden. Start with modules. Graduate to services only when you have evidence (traffic, team size, independent scaling needs) that the cost is justified. Many systems never need to cross that line.