What a module is in a Node.js modular monolith

A modular monolith is a single deployable Node.js process whose code is split into vertical slices, such as billing, catalog and identity. Each slice owns everything about its part of the domain: its business rules, its persistence code, and the database tables that only it writes to. A layered codebase works differently. It groups code by technical role into folders like controllers/, services/ and repositories/, so a single feature is spread across every layer, and any repository can query any table.

Vertical slicing changes what a change costs. A new billing rule touches one directory and one schema. The blast radius of a refactor is visible in the folder tree instead of buried in import chains. Each module exposes a small public API, and everything behind it can be rewritten freely. Cross-module side effects go through an in-process event bus rather than direct calls into another module's internals.

            ┌──────────── composition root ────────────┐
            │  wires modules, injects dependencies     │
            └─────┬──────────────┬──────────────┬──────┘
                  │              │              │
          ┌───────▼─────┐ ┌──────▼──────┐ ┌─────▼───────┐
          │  identity   │ │   catalog   │ │   billing   │
          │ [public API]│ │ [public API]│ │ [public API]│
          │  internals  │ │  internals  │ │  internals  │
          └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
                 │◄──── in-process event bus ───►│
          ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
          │ identity.*  │ │ catalog.*   │ │ billing.*   │
          │   schema    │ │   schema    │ │   schema    │
          └─────────────┘ └─────────────┘ └─────────────┘

This structure suits backend and platform teams with a growing codebase and several contributors who keep colliding in shared files. It fits teams that need clear ownership but don't yet need independent deployment, separate scaling or per-service release cycles.

┌─ single deployable Node.js process ──────────────────────────────────────┐
│                                                                          │
│  ┌─────────────────── composition root (main.js) ───────────────────┐    │
│  │  imports each module's index.js only; injects db, bus, config    │    │
│  └────────┬───────────────────────┬───────────────────────┬─────────┘    │
│           ▼                       ▼                       ▼              │
│  ┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐    │
│  │     identity     │    │     catalog      │    │     billing      │    │
│  ├──────────────────┤    ├──────────────────┤    ├──────────────────┤    │
│  │ index.js (public)│    │ index.js (public)│    │ index.js (public)│    │
│  ├──────────────────┤    ├──────────────────┤    ├──────────────────┤    │
│  │ domain/ (private)│    │ domain/ (private)│    │ domain/ (private)│    │
│  │ repos/  (private)│    │ repos/  (private)│    │ repos/  (private)│    │
│  └────┬───────┬─────┘    └────┬───────┬─────┘    └────┬───────┬─────┘    │
│       │       │               │       │               │       │          │
│  ═════╧═══════╪═══════════════╧═══════╪═══════════════╧═══════╪══════    │
│               │ in-process event bus  │ (emit / subscribe)    │          │
└───────────────┼───────────────────────┼───────────────────────┼──────────┘
                ▼                       ▼                       ▼
   ┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
   │  identity schema │    │  catalog schema  │    │  billing schema  │
   │ (only it writes) │    │ (only it writes) │    │ (only it writes) │
   └──────────────────┘    └──────────────────┘    └──────────────────┘
   one shared database; each schema is written only by its own module

One Node.js process in which the composition root wires vertical modules through their public index.js files; modules never import each other's internals, coordinate through an in-process event bus, and each one alone writes to its own database schema.

Designing the module public API: one entry file, explicit exports

Every module exposes exactly one entry file, such as modules/billing/index.ts, and nothing outside the module imports from any other path inside it. That file is the module's public API, and it should export four kinds of things: service interfaces (BillingService), command and query functions (createInvoice, getInvoiceById), DTO types (InvoiceDto), and domain events (InvoicePaid). Repositories, ORM entities, mappers, and helpers stay unexported.

Before: index.ts is a wide barrel of export * from './repositories', export * from './entities', export * from './utils'. Another module imports InvoiceRepository because it's available, and the boundary has failed.

After: index.ts lists each export by name. Adding something to the public surface becomes a deliberate change that a reviewer can see in the diff.

Wide barrels cause more than design problems:

  • Circular imports. When one module imports another's barrel, it loads the whole module, which can include files that import back.
  • Load-order bugs. Inside those cycles, a value can be undefined at the moment it is read.
  • Slower type-checking and test startup. Importing one type pulls in the module's entire file graph.
  • Weaker tree-shaking. Re-exported files with side effects can't be safely removed by the bundler.

Use TypeScript to make the contract stricter. Export interfaces, not classes, so consumers depend on behavior and can't call new or reach private state. Keep DTOs separate from persistence models, so you can rename a column without breaking callers in other modules.

// modules/billing/index.ts: the only file other modules may import.

// BEFORE (removed): a wide barrel that leaked internals.
// export * from './repositories'; // exposed InvoiceRepository
// export * from './entities';     // exposed the ORM InvoiceEntity
// export * from './utils';        // exposed mappers and helpers

// AFTER: every export is listed by name, so any addition shows up in the diff.

// Service contract: an interface, so consumers cannot call `new` or reach private state.
export type { BillingService } from './billing.service';

// Commands and queries: the operations other modules are allowed to call.
export { createInvoice } from './commands/create-invoice';
export { getInvoiceById } from './queries/get-invoice-by-id';

// DTOs: shapes for crossing the boundary, kept separate from persistence models.
// `export type` is erased at compile time, so importing these loads no runtime code.
export type { InvoiceDto, CreateInvoiceInput } from './dto/invoice.dto';

// Domain events other modules can subscribe to.
export { InvoicePaid } from './events/invoice-paid';

// Deliberately NOT exported:
//   ./repositories/invoice.repository.ts  (data access)
//   ./entities/invoice.entity.ts          (ORM mapping; columns can change freely)
//   ./mappers/invoice.mapper.ts           (entity <-> DTO translation)
//   ./utils/*                             (internal helpers)

A billing module entry file that names each public export (a service interface, command and query functions, DTO types and a domain event) and replaces a wildcard barrel that exposed repositories, ORM entities and helpers.

How to enforce internal boundaries with tooling, not convention

Conventions erode under deadline pressure. A deep import that saves five minutes today becomes a hidden coupling that blocks a refactor next quarter. The fix is to make the forbidden import fail at resolve time, lint time, or compile time.

Workspace packages with exports. If each module is its own workspace package, list only the entry file in the exports field of its package.json. Node then refuses to resolve @app/billing/src/internal/.... TypeScript enforces the same map only when moduleResolution is set to node16, nodenext or bundler.

ESLint no-restricted-imports. In a single-package layout, add patterns that reject any path reaching past another module's index. This works anywhere, but it is only as strong as the patterns. Relative paths such as ../../billing/internal need their own rule.

TypeScript project references. Give each module a composite tsconfig that references only the modules it is allowed to depend on. The compiler rejects files outside a project's declared inputs, and it builds against the referenced projects' declaration outputs instead of their source.

Run lint and tsc --build as required CI checks so a violation blocks the merge and is not left for a reviewer to catch.

Approach Setup cost Strength Pitfalls
exports field Medium Strong at runtime Conditional import/require entries can diverge; tsconfig paths aliases bypass it
ESLint patterns Low Moderate Missed relative paths; easy to disable inline
Project references High Strong at compile time Slower builds; stale declaration outputs

Test early whether your test runner and bundler honor exports. Some resolve through their own alias config and silently allow deep imports that Node would reject.

// eslint.config.js: flat config for a single-package modular monolith.
// Modules live in src/modules/<name>/ and expose only src/modules/<name>/index.ts,
// which is imported elsewhere as '@app/<name>' through a path alias.
export default [
  {
    files: ['src/**/*.ts'],
    rules: {
      'no-restricted-imports': ['error', {
        patterns: [
          {
            // '@app/billing' is the public API and stays allowed.
            // '@app/billing/internal/ledger' reaches past index and is rejected.
            group: ['@app/*/*'],
            message: 'Import other modules only through their public entry (@app/<module>).',
          },
          {
            // Relative paths never match the alias pattern above, so they need their own
            // patterns. Without these, '../../billing/internal/ledger' would pass lint.
            group: [
              '../*/internal/**',
              '../../*/internal/**',
              '**/modules/*/internal/**',
            ],
            message: 'Do not deep-import another module\'s internals via a relative path.',
          },
        ],
      }],
    },
  },
];

// In CI, run lint as a required check so violations block the merge:
//   npx eslint . --max-warnings=0
// Also consider banning inline disables of this rule. A single
// '// eslint-disable-next-line' comment quietly brings the deep import back.

An ESLint flat config that uses no-restricted-imports patterns to reject deep imports into another module, with separate patterns for alias paths and relative paths because the alias pattern does not catch relative ones.

Wiring modules at a composition root and communicating between them

Boundaries enforced at import time still say nothing about how modules find each other at runtime. The cleanest answer is that they don't. Each module exports a factory, such as createBillingModule(deps), that receives everything it needs as arguments: a database handle, a clock, a logger, and the public APIs of the modules it depends on. No module imports another module's instance or reaches for a global container.

A single composition root, usually the file that starts the HTTP server or worker, calls these factories in order and passes the results along. That file becomes the readable dependency graph of the whole system. If billing needs accounts, the call site shows it. The same factory also makes isolated tests cheap: hand createBillingModule a fake accounts API and an in-memory repository, and no other module gets loaded.

Calls versus events

Use direct calls through the public API for commands and queries that need an immediate answer, such as "is this account active?" Use an in-process event bus for side effects the caller should not wait on or know about, such as sending a receipt after a payment settles. Treat every event payload as a versioned contract. Add fields freely, but never repurpose or remove one without introducing a new version.

In-process events vanish if the process crashes between the commit and the handler. The outbox pattern closes that gap: write the event to an outbox table in the same transaction as the state change, and a relay publishes it afterward.

Refuse shared transactions across modules. They feel safe, but they couple schemas and make later extraction impossible. Accept eventual consistency and design handlers to be idempotent.

// main.ts: the composition root, the only file that sees the whole module graph
import { EventEmitter } from "node:events";
import { createDb } from "./infra/db";
import { createOutboxRelay } from "./infra/outbox-relay";
import { createAccountsModule } from "./modules/accounts";
import { createBillingModule } from "./modules/billing";
import { createNotificationsModule } from "./modules/notifications";
import { startHttpServer } from "./http";

const db = createDb({ connectionString: process.env.DATABASE_URL });
const clock = { now: () => new Date() };
const logger = console;
// In-process only. Durability comes from the outbox table, not from the bus.
const bus = new EventEmitter();

// Dependencies are passed in explicitly, so this file shows who depends on whom.
const accounts = createAccountsModule({ db, clock, logger });

// billing receives the accounts public API for synchronous queries
// ("is this account active?"). Inside the same transaction as the payment
// state change, it inserts "billing.payment_settled.v1" rows into its own
// outbox table. It never opens a transaction that spans another module's tables.
const billing = createBillingModule({ db, clock, logger, accounts });

// notifications does not import or call billing. It depends only on the
// versioned event contract.
const notifications = createNotificationsModule({ db, logger });
bus.on("billing.payment_settled.v1", (event) => {
  // The relay may deliver an event more than once, so the handler
  // deduplicates on event.id before sending the receipt.
  notifications.sendReceiptOnce(event).catch((err) => logger.error(err));
});

// The relay reads committed outbox rows, emits them on the bus,
// and marks each row as published only after it has been emitted.
createOutboxRelay({ db, bus, logger, pollIntervalMs: 1000 }).start();

startHttpServer({ accounts, billing, port: 3000 });

A composition root that wires module factories with explicit dependencies, uses a synchronous public API call between billing and accounts, and delivers a versioned event through an outbox relay to an idempotent in-process handler.

Data ownership and keeping the dependency graph acyclic

The most common boundary leak is not an import. It is a SQL join. Someone needs a report of orders with customer names, the customers table is in the same database, and one join later the orders module depends on the customer schema without a single line in the import graph showing it. Rename a column in customers and the report breaks. Lint rules will not catch it.

Give each module exclusive ownership of its tables. Only that module's repositories read or write them. A cross-module report then has two honest options. The first is that the owning module exposes a query in its public API, such as a batch lookup of customer summaries by ID, and the reporting code combines the results in memory. The second is that the reporting module builds its own read model: it subscribes to CustomerRenamed and OrderPlaced events and maintains a denormalized table it owns outright. Either way you write more code than one join, plus a projection to backfill and keep current. The payoff is that schema changes stay local.

Keep the graph acyclic

Generate the module dependency graph in CI from the public-API imports, and fail the build on any cycle. A cycle means two modules cannot be understood, tested, or extracted separately. Also diff the graph against the main branch. Any new edge between modules should show up in the pull request as a design decision a reviewer approves explicitly, not something that slips in with a refactor.

When to use it, when to avoid it, and the alternatives

A modular monolith pays off when you understand the domain well enough to name boundaries that will hold, and when a single deployable is acceptable to the business. If you can describe what billing owns without mentioning inventory's tables, you are ready to enforce that line.

When the domain lines are still unclear, delay strict boundaries. A wrong split produces two modules that call each other on every request and public APIs that change with every feature. Each change to those contracts is churn you created yourself. Start with fewer, larger modules and split them once the seams show up in real change patterns.

Be honest about shared fate. Everything runs in one process with one event loop. A memory leak in reporting can exhaust the heap for checkout. A synchronous CPU-heavy loop in one module blocks requests for all of them. An unhandled crash takes down every module at once. You also cannot scale or deploy one module on its own. Module boundaries organize code; they do not isolate runtime failures.

Layered monolith Modular monolith Microservices
Boundary enforcement Convention only Lint and dependency-graph checks in CI Network and separate repos or deployables
Deployment Single unit Single unit Independent per service
Consistency model Local transactions anywhere Local transactions, scoped per module Eventual consistency across services
Operational overhead Low Low High: discovery, tracing, versioning
Extraction path Costly untangling first Replace the public API with a network client Already extracted

Impact on development cost, engineering time, and later extraction

The boundaries are not free. Someone has to configure the lint rules that block deep imports, maintain an exports map per package, and keep TypeScript project references in sync as modules are added. Builds get more moving parts, and new engineers hit errors that a plain monolith would never raise. The larger risk is erosion. Under deadline pressure, a single eslint-disable comment or a widened exports map gets merged "temporarily." A few months later the codebase is a plain monolith again, with extra ceremony on top. Guard against this by running boundary checks in CI as a required check that cannot be suppressed per file without review.

Where the time comes back

Public APIs give reviewers a small, explicit contract to examine. Module tests can stub other modules at their entry file instead of mocking internals. Accidental couplings fail at lint time rather than surfacing as production regressions. When a module is extracted, callers keep the same interface, and only the implementation behind it changes from in-process calls to a network client.

That contained change is not the whole extraction. You still have to migrate data, handle partial failures and retries, and absorb added latency.

Illustrative estimate (assumed numbers, not a benchmark). Break-even months = setup hours ÷ (hours saved per month − maintenance hours per month). Assume 80 hours of setup, 15 hours saved per month, and 5 hours of upkeep per month. That gives 80 ÷ (15 − 5) = 8 months.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to modular monolith node.js, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.