# Modular Monolith

## 1. Definition

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.

## 2. Why 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).

## 3. What "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:

1. **Convention + code review** — weakest; relies entirely on humans catching violations. Breaks down as the team grows.
2. **Lint rule** (e.g. `eslint-plugin-boundaries`, `dependency-cruiser` import rules) — a CI check fails the build if `orders/` imports a file from `inventory/internal/`. Strong, automated, but only as good as the rule's coverage and can be disabled with a comment.
3. **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 an `internal/` subpackage two directories up). This is enforced by the compiler, not a linter — cannot be bypassed without literally moving the file.
4. **Physical package separation within a monorepo** — each module is its own publishable package (`@bright/orders`, `@bright/inventory`) with its own `package.json`/`exports` field declaring the public surface; other modules can only `import` what's listed in `exports`. 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*.

![Inside one order-api process, modules talk only through public interfaces and own their tables, with enforcement blocking direct cross-module imports and table access](diagrams/01-boundary-enforcement.svg)

## 4. The 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 entirely
```

Because 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.

## 5. When 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 Hood: How 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.go
```

Go'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:

```json
// 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

1. **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.
2. **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.
3. **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.
4. **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.
5. **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.

## 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
```
