Archtin
All articles
ArchitectureSystem Design11 min read

The Modular Monolith: Microservice Boundaries Without the Bill

A modular monolith keeps one deployable while enforcing hard internal boundaries, module-owned data and explicit interfaces. It is the pragmatic middle ground — and the best on-ramp to services if you ever need them.

The idea

A modular monolith deploys as one unit but is internally partitioned into modules that behave like services: each owns its data, exposes a narrow public interface, and may not reach into another module's internals. Calls stay in-process, so you keep transactions, one stack trace and a ten-minute deploy — while getting the boundaries that make a system evolvable.

Why it matters
Most microservice failures are boundary failures: services split along the wrong seams end up calling each other synchronously for every request — a distributed monolith, with all the cost and none of the benefit. A modular monolith lets you find the right seams cheaply.

The four rules

  1. A module has a public API. One entry point per module; everything else is internal and unreachable from outside.
  2. A module owns its tables. No other module reads or writes them — no joins, no direct queries, no "just this once".
  3. No cyclic dependencies. If A needs B and B needs A, one of them should be publishing an event instead.
  4. Rules are enforced by tooling. Import-linting or compiler-level module visibility. A convention that is only in the wiki is already broken.

What it looks like in code

src/modules/
  billing/
    index.ts        <- the ONLY file other modules may import
    internal/       <- services, repositories, entities
    events.ts       <- events this module publishes
    migrations/     <- billing_* tables only
  catalogue/
    index.ts
    internal/
    migrations/     <- catalogue_* tables only
  identity/
    index.ts
    ...
src/platform/       <- db, cache, bus, logging (shared, no domain logic)
src/app.ts          <- wires modules, one deployable
Each module exposes a single public surface; internals are private by construction.

The public index.ts exports commands, queries and DTOs — never entities, never repositories, never the ORM session. If another module can hold your entity, it can mutate your invariants.

Data ownership without separate databases

You do not need one database per module. You need one owner per table. Practical techniques, in increasing strength:

TechniqueEnforcement strengthCost
Table name prefixes + reviewWeakNone
Per-module migration foldersMediumLow
Separate Postgres schema per moduleStrongLow
Per-module DB user with grantsVery strongMedium
Separate database per moduleAbsoluteHigh — loses cross-module transactions

Schema-per-module with per-module database roles is the sweet spot: the database itself rejects a cross-module query, and you still get single-transaction writes when a use case legitimately spans modules through an orchestrating application service.

How modules talk

  • Synchronous, in-process: catalogue.getProduct(id) through the public API. Use when the caller needs the answer now and the coupling is acceptable.
  • Domain events, in-process bus: billing publishes InvoicePaid; notifications and analytics subscribe. Producers stay unaware of consumers — exactly the microservice pattern, minus the broker.
  • Deferred events: write the event to an outbox table in the same transaction, dispatch after commit. This also makes a future move to a real broker a configuration change rather than a rewrite.

Default to events for anything that is a reaction, and to direct calls only for queries the caller must have to complete its own use case.

Enforcement, or it doesn't exist

# eslint / dependency-cruiser style rule
forbidden:
  - name: no-deep-module-imports
    from: { path: "^src/modules/([^/]+)" }
    to:   { path: "^src/modules/(?!$1)([^/]+)/(?!index)" }
    comment: "modules may only import another module's index.ts"

  - name: no-module-cycles
    from: { path: "^src/modules" }
    to:   { circular: true }
Boundary rules belong in CI, next to the tests.

Add an architecture test that fails the build on violation. Teams do not erode boundaries maliciously; they erode them at 5pm on a Friday when the alternative is a red pipeline.

Extracting a module into a service

  1. Confirm the module's public API is already the only way in (the linter proves it).
  2. Replace the in-process call with a client implementing the same interface.
  3. Move the module's schema to its own database; break remaining joins first.
  4. Switch the in-process event bus to the broker for that module's events.
  5. Deploy the service, route traffic gradually, delete the in-process copy.

Because the interface and data ownership already exist, extraction is days of work, not a quarter. That optionality is the whole point.

Where it stops working

  • Teams need genuinely independent release cadences and are blocked weekly.
  • One module needs a different runtime, hardware profile or compliance boundary.
  • One module's load pattern is orders of magnitude different from the rest.
  • Build and test times exceed what the team will tolerate even with test selection.

Interview framing

Strong answer

"Modular monolith: schema per module, module-owned tables enforced by database grants, public API per module enforced by an import linter, in-process event bus with a transactional outbox. That gives us microservice-shaped boundaries with single-transaction writes and one deploy. When the pricing engine needs different hardware, we extract it — the interface and events are already there, so it's a client swap rather than a rewrite."

Follow-ups

  • How do you handle a use case that spans two modules transactionally?
  • What happens when a module needs another module's data for a report?
  • What prevents boundary erosion over two years and three team changes?

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free