Home/Architecture/Modular monolith over microservices
System design

Modular monolith over microservices

Service boundaries drawn too early are a bet on requirements you don't have yet.

Context

Microservices come up in almost every architecture discussion, but they bring real costs: network overhead, distributed tracing, deployment complexity, eventual consistency. Those costs only pay off when you actually understand your domain boundaries and have the team size to own separate services.

The decision

Build a modular monolith. Domain boundaries are enforced at the module level with explicit dependency wiring and no cross-module imports. Each module owns its domain, application, and infrastructure layers. The event bus handles cross-module communication without tight coupling. Splitting into services later is a deployment decision, not an architectural rewrite.

Rationale

  • 1

    Domain boundaries are hard to get right on the first pass. A monolith lets you move them cheaply. Once a boundary becomes a network contract between services, refactoring gets expensive fast.

  • 2

    Cross-module communication already goes through an IEventBus interface, and the EventBus persists events to Postgres before dispatch. If this ever needs multi-instance fan-out, swapping to SQS or Redis Streams is an infrastructure change. Domain code stays the same.

  • 3

    Dependencies are wired manually. Concrete infrastructure classes get imported and constructed in each command and query index file, so the full dependency graph is visible at compile time. TypeScript catches missing dependencies before the app starts. No IoC container magic.

  • 4

    At this scale, one deployment unit keeps things simple: one database connection, one process, one set of logs, one deploy pipeline.

In the codebase

Module structure. Each module owns its full slice

src/core/modules/identity/
  domain/          # pure business rules, no infra dependencies
  application/     # commands, queries, handlers
    commands/
      login-user/  # command + handler
      register-user/
  infrastructure/  # adapters. Repository, services
  api/             # composition root + public surface
    identity.service.ts  # dispatches via bus, maps to DTOs
    identity.dto.ts
    mappers/
    index.ts       # wires deps, registers handlers, exports service
  index.ts         # thin re-export from ./api

Cross-module communication via IEventBus

// Domain layer defines the interface, no infra dependency
interface IEventBus {
  register<T extends DomainEvent>(
    eventType: string,
    handler: EventHandler<T>,
  ): void;
  dispatch(events: DomainEvent[]): Promise<void>;
}

// Today: EventBus. Postgres persistence + QStash async dispatch
const eventBus = new EventBus(prisma, qstash, appUrl);

// Tomorrow: swap one line, zero domain changes
const eventBus = new SqsEventBus(sqsClient, queueUrl);

Tradeoffs

One deployment unit. No distributed tracing, no network partitions, no inter-service auth.

A runaway query or memory leak in one module can affect all of them. Real isolation eventually means separate processes.

Domain boundaries can be refined cheaply before they become network contracts.

Module boundary discipline is enforced by convention, not by the compiler. A careless import can couple modules silently.

The EventBus persists every event to Postgres before handler execution, so events survive crashes and can be replayed or audited without an external broker.

Still single-process dispatch. Multi-instance fan-out would need an external broker (SQS, Redis Streams). The IEventBus interface keeps that swap path open.