IAnswerable

Structure a Modular Monolith

13 Jul 2026

The pitch for a modular monolith is simple: keep the deploy simple, get the boundaries anyway. The hard part isn’t the pitch, it’s actually enforcing the boundaries once three engineers are all shipping features under deadline. Here’s the structure that holds up.

Organize by domain, not by layer

The classic mistake is organizing by technical layer first:

src/
  controllers/
  services/
  models/
  repositories/

This looks tidy and is actually the enemy of modularity — every feature is smeared across four folders, so there’s no single place that “owns” billing or users, and nothing stops billing code from reaching directly into the users model.

Organize by domain instead, with layers inside each module:

src/
  billing/
    controller.ts
    service.ts
    model.ts
    index.ts        # the ONLY file other modules may import
  users/
    controller.ts
    service.ts
    model.ts
    index.ts
  notifications/
    ...

Now “everything about billing” is one folder, and the boundary between modules is a file, not a convention people have to remember.

The public interface is the whole point

Each module exports exactly what other modules are allowed to touch, through index.ts:

// billing/index.ts
export { chargeCustomer, refund } from './service'
export type { Invoice } from './model'
// notably NOT exporting the raw database model or internal helpers

Other modules import from billing, never from billing/service or billing/model directly. This one rule is what makes the boundary real instead of aspirational — it means billing can change its internal implementation freely as long as the exported functions keep their contract.

Enforce it with the linter, not the honor system

Boundaries that rely on code review discipline decay within a quarter, because someone under deadline pressure will reach for the one-line internal import instead of going through the proper interface, and review will miss it once. Enforce it mechanically:

// eslint.config.js — using eslint-plugin-boundaries or import/no-restricted-paths
{
  "rules": {
    "import/no-restricted-paths": ["error", {
      "zones": [
        { "target": "./src/users", "from": "./src/billing", "except": ["./src/billing/index.ts"] }
      ]
    }]
  }
}

Now a cross-module internal import fails CI, not code review. This is the single highest-leverage thing you can do — everything else about a modular monolith is convention, this is the part that’s actually enforced.

Each module owns its data

If two modules both write to the same database table, they’re not actually modular, they’re sharing state through the back door. Each module should own its tables, and cross-module reads go through the module’s public functions, not direct SQL against another module’s schema.

-- billing owns invoices, users owns accounts
-- if notifications needs invoice data, it calls billing.getInvoice(),
-- it does NOT run `SELECT * FROM invoices`

This is the rule that most determines whether you can actually split a module into a service later. If notifications is already going through billing’s public functions instead of its tables, extracting billing later is a network call swap, not a redesign.

Communication between modules: direct calls, with one exception

For synchronous needs (billing needs to know if a user exists), call the module’s exported function directly — it’s all one process, this is cheap and simple, don’t build an internal event bus for it.

For things that shouldn’t block the caller (send a welcome email after signup), use an in-process event emitter, not a message queue — you don’t need the operational overhead of a queue until you actually need cross-process delivery:

events.emit('user.created', { userId })
// notifications/index.ts subscribes, sends the email async

The one thing that ruins this

A “shared” or “common” module that every other module imports and that itself imports nothing. It sounds harmless and becomes a dumping ground for anything that doesn’t cleanly belong somewhere — which is exactly how boundaries quietly dissolve. Keep shared code to genuine cross-cutting concerns (logging, a base HTTP client) and be suspicious of anything domain-shaped that lands there.