Modular Monolith
One deployable, but with module boundaries as strict as service boundaries. See how boundaries are enforced (from code review up to the compiler), and how a well-bounded module becomes a microservice with the strangler fig pattern, without touching its callers.
🎯 What you'll learn
- What makes a monolith modular: enforced boundaries
- Enforcement levels: convention, lint, compiler, packages
- The strangler fig path to extracting a service
- When a modular monolith is the wrong choice
📚 Before you start
1Definition
A modular monolith is a single deployable process (like a plain monolith) whose internal code is organized into strictly bounded modules that communicate through explicit, narrow interfaces — as if they were separate services, except the "network call" between them is still an in-process function call. It deliberately sits between a tangled monolith (any module can reach into any other module's internals) and microservices (every module is a separately deployed process).
┌───────────────────────────────────────────────────┐
│ 1 process, 1 deploy │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ orders │ │ inventory │ │ payments │ │
│ │ module │ │ module │ │ module │ │
│ │ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ │
│ │ │internal│ │ │ │internal│ │ │ │internal│ │ │
│ │ │ (priv) │ │ │ │ (priv) │ │ │ │ (priv) │ │ │
│ │ └────────┘ │ │ └────────┘ │ │ └────────┘ │ │
│ │ public API│ │ public API│ │ public API│ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ └──────only through public APIs──────┘ │
└───────────────────────────────────────────────────┘The defining rule: module internals are private; only the module's declared public interface is callable from outside it — enforced the same way a service's network boundary is enforced, just without the network.
2Why This Sits Between the Two Extremes
| Tangled monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deploy unit | 1 | 1 | N |
| Module boundaries | Convention only, routinely violated | Enforced (compiler/lint/build) | Enforced by the network |
| Cross-module calls | Any function, any time | In-process, through public API only | Network call |
| Cross-module transactions | Trivial (shared DB, one transaction) | Trivial (shared DB, one transaction) | Requires Sagas / 2PC |
| Refactor a boundary | Easy — it's all one codebase | Easy — still one codebase, IDE-assisted rename works | Hard — a versioned API change across teams |
| Extract to a real service later | Painful — nothing is actually separated | Cheap — the module already has one clean entry point to redirect over the network | N/A, already done |
| Ops overhead | Low | Low | High |
The modular monolith gets almost all of the design discipline benefit of microservices (clear ownership, no accidental coupling, one team can't silently break another's invariants) while keeping the operational simplicity of a single deploy (one process to run, one transaction model, no network between modules that don't need one).
3What "Enforced" Actually Requires
Saying "just don't reach into other modules" is not enforcement — it's a promise nobody keeps under deadline pressure. A real modular monolith enforces the boundary at one of these levels, from weakest to strongest:
- Convention + code review — weakest; relies entirely on humans catching violations. Breaks down as the team grows.
- Lint rule (e.g.
eslint-plugin-boundaries,dependency-cruiserimport rules) — a CI check fails the build iforders/imports a file frominventory/internal/. Strong, automated, but only as good as the rule's coverage and can be disabled with a comment. - Language-level module privacy — Java's module system (JPMS), or simply putting non-exported code in an
internal/package (Go enforces this at the compiler level: nothing outside a package can import aninternal/subpackage two directories up). This is enforced by the compiler, not a linter — cannot be bypassed without literally moving the file. - Physical package separation within a monorepo — each module is its own publishable package (
@bright/orders,@bright/inventory) with its ownpackage.json/exportsfield declaring the public surface; other modules can onlyimportwhat's listed inexports. Combines well with option 3.
The stronger the enforcement mechanism, the closer a later extraction to a real microservice becomes a matter of deployment, not redesign.
4The Strangler Fig Path to Microservices
A modular monolith is also the standard staging ground for extracting a real microservice later, using the strangler fig pattern: gradually route traffic for one capability to a new external service while the old in-process module still exists, until the module can be deleted entirely.
Step 1: orders module calls inventory module directly (in-process)
Step 2: inventory module's public API is reimplemented as a thin client
that calls a NEW external inventory-service over HTTP —
orders module's call site doesn't change at all
Step 3: once the new service is proven correct in production,
delete the old in-process inventory module entirelyBecause callers only ever depended on the module's public interface (never its internals), swapping the implementation behind that interface from "in-process code" to "HTTP client calling a new service" requires touching exactly one module, not every caller.
5When a Modular Monolith Is the Wrong Choice
- The team is already organized as multiple fully independent teams that need to deploy on their own schedules — a modular monolith still ships as one artifact, so one team's broken build blocks everyone's deploy.
- Different modules have wildly different scaling needs today, not hypothetically (e.g., one module needs GPU instances, another needs to scale to 1000 replicas under bursty load) — a single process can't scale its parts independently.
- Regulatory/compliance boundaries require genuine process and data isolation (e.g., PCI scope reduction), not just code-level separation.
Under the HoodHow Module Boundary Enforcement Actually Works▾
The core mechanism is the same one that makes a network boundary "real" in microservices — an unbypassable barrier — just implemented without a network. Two concrete enforcement mechanisms, worked through precisely:
Compiler-enforced privacy (Go's internal/ convention)
orders/
public.go // package orders — exported functions
internal/
validation.go // package internal — NOT exported outside orders/
inventory/
public.goGo's compiler applies a hard rule: any package under a path segment named internal/ can only be imported by code whose import path shares the same parent directory as that internal/ segment. inventory/public.go physically cannot import "orders/internal" — not "shouldn't," not "will fail lint" — the build does not compile. This is the same category of guarantee a network boundary provides (you cannot call a private method on a service you don't have an endpoint for) implemented as a compile-time graph check instead of a runtime network permission check.
CI-enforced import graph (dependency-cruiser / boundaries lint rule)
For languages without built-in module privacy (JavaScript/TypeScript), the equivalent is a declared dependency graph checked in CI:
// dependency-cruiser rule, conceptually:
{
"from": { "path": "^src/orders" },
"to": { "path": "^src/inventory/internal" },
"severity": "error"
}Every import statement in the codebase is parsed into a graph; the rule engine checks every edge against the declared allowed-edges list, and the build fails if orders imports anything under inventory/internal. This is weaker than compiler enforcement in one specific way worth naming precisely: it only runs when CI runs, so a developer can locally write and even run code that violates the boundary — the guarantee is "this cannot merge," not "this cannot be written," whereas Go's internal/ guarantee is "this cannot even compile," which is strictly stronger because it also blocks local execution, not just merges.
Why this generalizes to the extraction story in Section 4
Both mechanisms produce the same structural property: every cross-module reference in the codebase provably goes through the module's declared public entry point (a Go package's exported functions, or a package's exports field). That's precisely the property that makes replacing "public entry point calls into local code" with "public entry point calls an HTTP client" a one-module change — the enforcement mechanism is what guarantees, ahead of time, that no caller has a hidden path around that entry point to break.
⚠️ Common Pitfalls
- Calling it "modular" with no enforcement mechanism — without a compiler check or a CI-gated lint rule, module boundaries erode within a few sprints under deadline pressure; this is just a tangled monolith with extra folders.
- Sharing database tables directly across module boundaries — even with clean code-level boundaries, two modules reading/writing the same table are coupled exactly as tightly as if they shared code; each module should own its own tables (or schema) even while sharing one physical database/process.
- Leaking domain types across the public API — if
orders's public function returns inventory's internal ORM entity instead of a small DTO, the "boundary" is cosmetic; a real change to inventory's internals still breaks orders' compiled code. - Treating the module split as permanent and never revisiting it — module boundaries should be reconsidered as the domain is better understood; that's cheap in a modular monolith and expensive after a premature microservices extraction.
- Skipping this stage entirely "because microservices are the goal anyway" — most systems never actually need the operational cost of microservices; a well-enforced modular monolith is a legitimate permanent end state, not just a stepping stone.
Leaky public API
// orders calls inventory's public API…
const item: InventoryOrmEntity = inventory.getItem(sku);
item.reservedQty += 1; // …and mutates its internalsNarrow DTO interface
const ok: boolean = inventory.reserve({ sku, qty: 1 });
// inventory decides how; orders can't touch its tables📋 Quick Reference
Modular monolith = 1 deploy unit + enforced module boundaries + in-process calls
Enforcement strength: convention < CI lint rule < compiler-level privacy (e.g. Go internal/)
Cross-module calls: still function calls (µs, ACID transactions available)
Extraction path: strangler fig — swap a module's implementation behind its public API,
callers never change because they only ever depended on that API
Choose modular monolith when: 1 deploy is fine operationally, but code discipline isn't🧠 Key takeaways
- A modular monolith = one deploy + enforced module boundaries + in-process calls (ACID still available).
- Enforcement strength: convention < CI lint < compiler privacy (e.g. Go
internal/). - Modules own their tables and expose narrow DTOs, never ORM entities.
- The strangler fig pattern swaps a module's implementation for an HTTP client, and callers never change.
🎤 Interview Q&A
Q1What is a modular monolith?core▾
A single deployable application internally split into modules with clear boundaries: each module owns its domain logic and data (schema), exposes a public interface, and hides internals. You get most of microservices' design benefits (cohesion, ownership) without the network and operational costs.
- How is it different from a layered monolith? Layers split by technical concern; modules split by business capability.
Q2How do you enforce module boundaries?core▾
Separate packages/projects with only public APIs exported, architecture tests (ArchUnit, NetArchTest, dependency-cruiser) that fail the build on forbidden imports, separate database schemas per module, and communication via interfaces or in-process events.
- Why are tests needed? Without automated checks, boundaries erode under deadline pressure.
Q3When is a modular monolith the right choice?core▾
For most new products and small-to-medium teams: it keeps deployment simple, allows refactoring across modules cheaply, and lets you extract a module into a service later if scaling or team independence requires it.
- When does it stop working? When many teams contend for one deploy pipeline, or modules need very different scaling or tech stacks.
Q4How do modules communicate?senior▾
Synchronously via public interfaces (in-process calls) for queries, and asynchronously via in-process domain events for side effects, which keeps modules decoupled and makes later extraction easy (swap the in-process bus for a broker).
- Can modules join each other's tables? No; that couples schemas. Use the other module's API or a read model.
Q5How would you extract a module into a microservice later?core▾
If boundaries were respected, replace in-process calls with network calls (same interface), move the module's schema to its own database, switch in-process events to a broker, and deploy separately. The work is mostly infrastructure because the design was already separated.
- What usually makes extraction painful? Shared tables and hidden cross-module calls.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.