The Modular Monolith: A Practical Middle Ground

Why jumping straight to microservices might be unnecessary — and how modular monoliths give us structure without the operational overhead.

#Architecture#JavaScript#Node.js

I've watched teams break a perfectly functional application into a dozen services, then spend months debugging issues that did not exist before. Most applications do not need microservices. What they need is structure. A modular monolith provides that without the operational overhead of distributed systems.

What a Modular Monolith Is

A modular monolith is a single deployable application where the code is organized into well-defined, loosely coupled modules — each with clear boundaries, its own internal logic, and explicit interfaces for communication with other modules.

Key properties:

  • Single deployment unit — one build, one deploy, one runtime
  • Strong module boundaries — modules cannot reach into each other's internals
  • Explicit contracts — modules communicate through defined interfaces
  • Independent data ownership — each module owns its data, even if they share a database
Why Not Microservices

Distributed systems trade one set of problems for a larger, more expensive set:

  • Network latency between every service call
  • Distributed transactions
  • Service discovery and load balancing
  • Independent deployment pipelines for each service
  • Monitoring and tracing across service boundaries
  • Data consistency issues

A modular monolith gives us the organizational benefits — clear ownership, separated concerns, independent development — without the operational complexity.

And here is the part that is easy to miss: a well-structured modular monolith is far easier to split into microservices later than a tangled monolith. We are not closing any doors — we are buying ourselves time to learn where the real boundaries are.

Project Structure

For an e-commerce platform, instead of one flat directory of controllers, services, and models, organize by domain:

src/
├── modules/
│   ├── catalog/
│   │   ├── catalog.module.ts
│   │   ├── catalog.service.ts
│   │   ├── catalog.repository.ts
│   │   └── catalog.types.ts
│   ├── orders/
│   │   ├── orders.module.ts
│   │   ├── orders.service.ts
│   │   ├── orders.repository.ts
│   │   └── orders.types.ts
│   ├── payments/
│   │   ├── payments.module.ts
│   │   ├── payments.service.ts
│   │   ├── payments.repository.ts
│   │   └── payments.types.ts
│   └── users/
│       ├── users.module.ts
│       ├── users.service.ts
│       ├── users.repository.ts
│       └── users.types.ts
├── shared/
│   ├── events.ts
│   └── types.ts
└── app.ts

Each module exposes a public API and nothing else leaks out.

Enforcing Module Boundaries

Boundaries that are not enforced do not exist. Each module should export only what it wants to expose:

// modules/catalog/catalog.module.ts

import { CatalogService } from './catalog.service';
import { CatalogRepository } from './catalog.repository';

const repository = new CatalogRepository();
const service = new CatalogService(repository);

// This is the public API. Nothing else leaves this module.
export const catalogModule = {
  getProduct: (id: string) => service.getProductById(id),
  listProducts: (filters: ProductFilters) => service.listProducts(filters),
  onProductUpdated: service.productUpdatedEvent,
};

export type { Product, ProductFilters } from './catalog.types';

The orders module can use catalogModule.getProduct() but cannot import CatalogRepository directly.

We can enforce this with ESLint rules to prevent cross-module internal imports:

// eslint.config.js
export default [
  {
    rules: {
      'no-restricted-imports': ['error', {
        patterns: [
          {
            group: ['*/modules/*/!(*.module|*.types)'],
            message: 'Import from the module file, not internal files.',
          },
        ],
      }],
    },
  },
];
Communication Between Modules

Modules need to communicate. Use direct calls for synchronous queries and events for notifications.

Direct calls for when we need a response:

// modules/orders/orders.service.ts

import { catalogModule } from '../catalog/catalog.module';

class OrdersService {
  async createOrder(userId: string, productId: string, quantity: number) {
    const product = await catalogModule.getProduct(productId);

    if (!product) {
      throw new Error(`Product ${productId} not found`);
    }

    return this.repository.create({
      userId,
      productId,
      quantity,
      totalPrice: product.price * quantity,
    });
  }
}

Events for when a module needs to notify others without knowing who is listening:

// shared/events.ts

type EventHandler<T> = (payload: T) => void | Promise<void>;

export class EventBus {
  private handlers = new Map<string, EventHandler<any>[]>();

  on<T>(event: string, handler: EventHandler<T>) {
    const existing = this.handlers.get(event) || [];
    this.handlers.set(event, [...existing, handler]);
  }

  async emit<T>(event: string, payload: T) {
    const handlers = this.handlers.get(event) || [];
    await Promise.all(handlers.map((h) => h(payload)));
  }
}

export const eventBus = new EventBus();
// modules/orders/orders.service.ts
import { eventBus } from '../../shared/events';

// After creating an order:
await eventBus.emit('order.created', { orderId, userId, productId, quantity });
// modules/payments/payments.module.ts
import { eventBus } from '../../shared/events';
import { PaymentsService } from './payments.service';

const service = new PaymentsService();

eventBus.on('order.created', async (order) => {
  await service.initiatePayment(order);
});

The orders module does not know that payments is listening. When we eventually need to extract payments into its own service, we swap the in-process event bus for a message broker — the module's internal code does not change.

Data Ownership Without Separate Databases

Use schema separation or table-level ownership rules:

// modules/catalog/catalog.repository.ts

class CatalogRepository {
  // This module owns these tables. No other module touches them.
  private readonly TABLES = {
    products: 'catalog_products',
    categories: 'catalog_categories',
  } as const;

  async getById(id: string): Promise<Product | null> {
    const row = await db.query(
      `SELECT * FROM ${this.TABLES.products} WHERE id = $1`,
      [id]
    );
    return row ? this.toProduct(row) : null;
  }
}

The convention: prefix tables with the module name. The catalog module owns catalog_* tables, orders owns orders_* tables. If one module needs data from another, it goes through the module's public API — never through a direct database query.

When to Move to Microservices

Legitimate reasons to extract a module into a separate service:

  • Independent scaling — one module genuinely needs significantly more compute than the rest
  • Different runtime requirements — a module needs a GPU, a different language, or a different deployment cadence
  • Team autonomy at scale — there are many engineers and coordination overhead is the bottleneck
  • Fault isolation — a module's failure is cascading and crashing unrelated functionality

If we have done the modular monolith work, extraction is straightforward. The module already has a defined interface. We put a network boundary where the function call boundary was, swap the event bus for a message queue, and we are most of the way there.

What I'd Recommend

Start with a modular monolith. Be disciplined about boundaries from day one. Use linting and code review to enforce them. Build an event system early — it is cheap and it pays off whether we stay monolithic or not.

Resist the urge to prematurely distribute. Every network hop we add is a new failure mode, a new latency source, and a new thing to monitor. Earn the complexity by proving it is needed.