ArchitectureSCALE-SPECIFICLANGUAGE-SPECIFIC

The Modular Monolith

One deployable with boundaries the build enforces: most of what services give you, without the network.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Can you get real internal boundaries without paying for distribution?

The requirement

The codebase is two years old and entangled enough that estimates are unreliable. The team wants boundaries. It does not want twelve pipelines, twelve on-call rotations and a distributed trace to debug a checkout.

The obvious build

Reorganise into folders by domain — orders/, billing/, catalog/ — and agree in review that modules should not reach into each other's internals.

Why it breaks

A convention that only review enforces has a half-life measured in months. One urgent fix imports a repository across a boundary, it passes review because it is urgent, and the next one cites it as precedent.

How it breaks in production
  • A convention that only review enforces has a half-life measured in months. One urgent fix imports a repository across a boundary, it passes review because it is urgent, and the next one cites it as precedent.
  • Folders do not stop a query. The coupling that actually matters is billing selecting from the orders table, and no amount of directory structure prevents it.
  • A shared/ or common/ directory becomes the place anything difficult goes, and within a year it is the most depended-on module with no owner.
  • When the boundaries are only conventional, an extraction later is a full archaeology exercise — which is precisely the situation this structure exists to avoid.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A module is a public interface plus private everything else. Other modules may call the interface; they may not touch its internal types, its repositories, or its tables.
  • The enforcement is what makes it real, and it has to be mechanical: language visibility where available, and a build- or lint-level dependency rule where it is not. The check must fail CI, not produce a warning (Transport, Application, Domain, Infrastructure).
  • The data boundary is the one that decides whether this works. Each module owning its own tables — ideally its own schema, with a database user that can only see it — is what stops the coupling from re-forming underneath the code (Multi-Tenancy uses the same mechanism for a different reason).
  • Cross-module communication stays in process: a direct call to another module's interface, or an in-process event dispatched after commit. So you keep real transactions, real stack traces and compiler-checked refactoring while gaining independent reasoning.
  • The dependency graph between modules should be acyclic and stated somewhere a tool reads. A cycle between two modules means they are one module that has not admitted it.

A module is an interface and a schema

Two rules make the difference between modules and folders, and they are both mechanical. Code outside a module may import only its public entry point. Code outside a module may not touch its tables.

The second is the one teams skip, and it is the one that decides whether the boundary survives. Code coupling can be refactored in an afternoon; a reporting query that joins across three modules' tables becomes load-bearing and outlives everyone who wrote it.

Billing needs an order total
Reach across
// billing/invoice-service.ts
import { OrderRepository } from '../orders/infra/order-repository'
import { Order } from '../orders/domain/order'

const order = await new OrderRepository(db).findById(orderId)
const total = order.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0)
// billing now depends on orders' storage, entity shape and pricing rule
Ask the owner
// orders/index.ts — the module's entire public surface
export interface OrderSummary { id: string; totalCents: number; currency: string; placedAt: Date }
export const orders = { getSummary(id: string): Promise<OrderSummary | null> { /* ... */ } }

// billing/invoice-service.ts
import { orders } from '@/modules/orders'
const summary = await orders.getSummary(orderId)
if (!summary) throw new OrderNotFound(orderId)

The right-hand version depends on four fields with a stated meaning instead of on another module's storage layout and pricing logic. Orders can change its tables, its entity or how totals are computed without touching billing — and if the two are ever separated, this call is already the shape of a service call.

Enforcement is the whole design

LANGUAGE-SPECIFICThis shape is for ecosystems with no real visibility control (TypeScript, Python). In Java or Go, package-private and internal packages give you the same guarantee from the compiler, and the CI rule is only needed for the cycle check.

Everything above is a convention until a machine rejects a violation. The configuration is short, and it converts an architectural intention into a property of the build — the same move as making a security control structural rather than procedural (Defence in Depth).

Add the rule on the day you draw the boundaries. Adding it to an existing codebase means starting with a suppression list, and a suppression list is a to-do that never gets done.

A boundary rule that fails CI
1{
2 "forbidden": [
3 {
4 "name": "no-module-internals",
5 "comment": "Modules may only be reached through their index. Internals are private.",
6 "severity": "error",
7 "from": { "path": "^src/modules/([^/]+)/" },
8 "to": { "path": "^src/modules/(?!$1/)[^/]+/.+", "pathNot": "^src/modules/[^/]+/index\\.ts$" }
9 },
10 {
11 "name": "no-module-cycles",
12 "comment": "A cycle between two modules means they are one module.",
13 "severity": "error",
14 "from": { "path": "^src/modules/" },
15 "to": { "circular": true }
16 }
17 ]
18}

The interesting part is severity: error. A warning is a convention with extra steps; the rule only holds if a violating pull request cannot be merged. Pair it with per-module database schemas — the code rule and the data rule fail differently, which is what makes them two layers instead of one.

What you keep, and what you still do not get

The honest summary is that a modular monolith buys the reasoning and ownership benefits of services and none of the isolation benefits. That is a good trade for most systems, and it is a bad trade for a system whose actual problem is that one component must scale or fail independently.

Read the last two rows as the decision criteria. If independent deployment and independent failure are what you need, modules will not deliver them at any level of discipline, and Microservices is the conversation — for that component, not for the system.

PropertyMonolithModular monolithServices
Clear ownership boundariesBy convention, erodesEnforced by the buildEnforced by the network
Real transactions across the domainYesYes, within and across modulesNo — saga or outbox
One stack trace per requestYesYesNo — a distributed trace, if you built one
Refactor a shared conceptOne atomic changeOne atomic changeA versioned migration across live versions
Local developmentRun one processRun one processRun the graph or mock it
Independent deploymentNoNoYes — the main thing you are buying
Independent failure and resource profileNoNoYes — the other thing you are buying

How to build it

Most important first.

  • Draw boundaries around business capabilities, not technical layers. orders, billing, catalog — not controllers, services, repositories, which cut across every capability and isolate nothing (Transport, Application, Domain, Infrastructure).
  • Give each module one public entry point — a facade or an application-service object — and make everything else private to it.
  • Enforce it in CI: an import-boundary lint rule, a dependency-cruiser configuration, an architecture test. Ten lines of configuration replace an unwinnable review argument.
  • Separate the data: one schema per module, no cross-module joins, and where the engine allows it, a database user per module. Cross-module reads go through the owning module's interface (The Repository Layer).
  • Prefer direct calls between modules. Introduce an in-process event bus only where you genuinely want the caller not to know its consumers, and dispatch after the transaction commits (Commands vs Events, The Transactional Outbox).
  • Keep it one deployable. The entire value proposition is boundaries without distribution — if you also split the deployment you have bought a distributed system and should read Microservices first.
  • Treat "which module owns this" as a question with an answer. Every table, every endpoint, every job. Ambiguity here is where shared/ comes from.

What can go wrong

Failure modes
  • Boundary rules configured and then suppressed case by case, until the suppression list is the real architecture.
  • A shared or core module that accumulates entities everyone needs, re-coupling everything through the back door. Duplicate the small type instead; two 20-line structs cost less than a universal dependency.
  • Cross-module joins in a reporting query, which quietly makes two schemas one.
  • A module interface that returns internal entities, so callers depend on internals through the type system even though the import rule passed (Three Models, Not One).
  • An in-process event bus used for control flow, so the actual sequence of a request is not readable from any one place — the debuggability cost of services with none of the isolation benefit.
  • Modules drawn from the current org chart, which changes, rather than from the domain, which changes more slowly.
What can race
  • Modules share the process, so shared mutable state crosses boundaries invisibly — a module-level cache in one module is visible to every request in every module (Backend Races).
  • In-process events dispatched inside a transaction can be observed by a consumer before the commit, or lost if it rolls back. Dispatch after commit (The Transactional Outbox).
Security
  • Per-module database users are the strongest containment available inside a single process: a bug in catalog cannot read billing tables even though it shares an address space (Defence in Depth).
  • It is still one process, so credentials, memory and the runtime are shared. Code execution anywhere is code execution everywhere (Command Injection). Module boundaries constrain honest mistakes, not an attacker who is already executing.
  • Authentication and authorization remain a single, auditable pipeline — a genuine advantage over a service graph where each service must be trusted to enforce the same rules (Where the Check Belongs).
  • Sensitive modules can get an explicit internal authorization check at their facade, so a call from another module is authorized rather than implicitly trusted.
Misreads
  • "It is microservices without the network." No: modules share a process, a runtime, a deploy and a failure domain. You get boundaries, not isolation — and boundaries were the part you wanted.
  • "It is a stepping stone to microservices." It is a destination that many systems never need to leave. Treating it as a phase leads to splitting on a schedule instead of on a reason (Microservices).
  • "Layers are modules." Controllers/services/repositories is a technical layering that cuts across every capability. It has value and it is not a boundary — a change to orders still touches all three.
  • "We have modules." Unless CI fails on a cross-boundary import, you have folders. That is the whole difference.
  • "An event bus makes modules decoupled." An in-process bus used for control flow adds indirection and removes the stack trace. Direct calls to a facade are usually more decoupled in practice and much easier to follow.

Operating it

How you see it in production
  • Tag metrics, logs and spans with the owning module. Without that, per-module latency and error attribution are unavailable and you are back to one undifferentiated service.
  • Count cross-module calls at the facade. A module called by everything is either genuinely foundational or a shared module wearing a better name.
  • Track boundary-rule violations and suppressions over time. A rising suppression count is the erosion signal, and it is visible months before anyone feels it.
  • Per-module database query counts and connection use tell you which module would be the expensive one to extract (Connection Pools).
What changes at 10x and 100x
  • At 10x traffic: unchanged. This is a code-structure decision, and instances scale as before (Horizontal vs Vertical Scaling).
  • At 10x team size the structure earns its keep: modules give ownership, and clear ownership is what makes a large codebase changeable. This is the scale it is designed for.
  • It is also the best possible starting position for an extraction, if one becomes justified: a module with a real interface and its own schema is a service-shaped thing already, and the extraction is a deployment change rather than an excavation (Microservices).
What this costs
  • Discipline is a permanent cost. The rules have to be maintained, argued about occasionally, and enforced when someone is in a hurry.
  • Going through a facade instead of reaching into another module's repository is more code and one more indirection, for a benefit that is invisible on the day.
  • Still one failure domain, one runtime, one release cadence. Everything under tradeoffs in The Monolith still applies.
  • Duplicating small types across module boundaries feels wrong to most engineers and is usually correct. Expect to defend it.
  • Per-module schemas make cross-module reporting harder, which is a real operational cost — usually answered with a read model or a warehouse rather than by relaxing the boundary (Read Replicas From the Application).

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • SCALE-SPECIFICUnder about ten engineers the enforcement overhead usually exceeds the benefit — the codebase is small enough to hold in one head. Between roughly ten and a hundred engineers this is the strongest default available. Above that, deploy contention starts to select for extraction regardless of how good the modules are.
  • LANGUAGE-SPECIFICEnforcement mechanism differs sharply: Java and Go have real package or module visibility that the compiler enforces; TypeScript and Python have effectively none, so the boundary must be a lint or CI rule (eslint import rules, dependency-cruiser, import-linter). Same architecture, materially different failure rate for the enforcement layer.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — the costs on the right-hand column of that last table are what that field is about, and they are the reason not to move into it casually.