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.
The four rules
- A module has a public API. One entry point per module; everything else is internal and unreachable from outside.
- A module owns its tables. No other module reads or writes them — no joins, no direct queries, no "just this once".
- No cyclic dependencies. If A needs B and B needs A, one of them should be publishing an event instead.
- 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 deployableThe 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:
| Technique | Enforcement strength | Cost |
|---|---|---|
| Table name prefixes + review | Weak | None |
| Per-module migration folders | Medium | Low |
| Separate Postgres schema per module | Strong | Low |
| Per-module DB user with grants | Very strong | Medium |
| Separate database per module | Absolute | High — 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 }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
- Confirm the module's public API is already the only way in (the linter proves it).
- Replace the in-process call with a client implementing the same interface.
- Move the module's schema to its own database; break remaining joins first.
- Switch the in-process event bus to the broker for that module's events.
- 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?