MonolithSCALE-SPECIFICCONTESTEDDOMAIN-SPECIFIC

The Modular Monolith

One deployment, several modules, each owning its domain logic, its interface and its data. The internal-boundary discipline of services without the operational bill.

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 survives until the requirement changes.

The question

What has to be true of a module before "modular monolith" describes the codebase rather than the ambition?

The requirement

A three-year-old product has grown to four teams sharing one deployable. Nobody can change anything without breaking somebody, and a proposal to split into microservices is on the table for the second time.

The obvious build

Create top-level folders per domain and agree not to reach across them. It is free, it is obvious, and it needs no tooling.

Why it breaks

Folders are a naming scheme. Nothing prevents an import, so the agreement holds until the first deadline, and the first violation is always defensible in isolation (Decomposition by Folder).

How it breaks as requirements change
  • Folders are a naming scheme. Nothing prevents an import, so the agreement holds until the first deadline, and the first violation is always defensible in isolation (Decomposition by Folder).
  • It says nothing about data. Two modules can be perfectly separated in the source tree and still both write the same table, which is the coupling that actually matters and the one that makes a later split hard (The Shared Database: An Honest Trade, Not a Prohibition).
  • It says nothing about what is public. Every symbol in the folder is reachable, so the module's interface is its entire implementation and any internal change is potentially a breaking change (Exposing Too Much).
  • And it erodes invisibly. There is no moment where somebody decides to abandon modularity; there are forty small commits, each locally reasonable, and then a dependency graph nobody can draw (Dependency Cycles).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • One database, one schema, and eleven tables that at least three teams write to.
  • Four teams with real ownership over features but no ownership over code — anyone edits anything.
  • The product cannot pause; whatever is done has to be incremental and reversible (Incremental Migration).
Invariants
  • A module's data is only ever mutated by that module, so its invariants hold no matter which team wrote the caller (State Ownership).
  • Every module's public interface is the complete set of ways to reach it; there is no second path through the database (Internal Module Contracts).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A module owns a domain area end to end: its rules, its state machine, its persistence and its interface. Not a layer of one — the whole vertical (Vertical Slices).
  • A module owns which of its symbols are public. Everything else is an implementation detail that other modules must not be able to reach (Information Hiding).
  • A module owns its tables. No other module reads or writes them, including for reports and including at three in the morning during an incident.
  • The composition root owns wiring modules together, and it is the only place that knows more than one module's internals (Wiring and the Composition Root).
Boundaries
  • The boundary is the module's public interface plus its data. Both halves are required: an interface with a shared table underneath is not a boundary, and owned data reached by arbitrary function calls is not one either.
  • Boundaries should follow the domain, and secondarily the teams, because a module that two teams change constantly is not a boundary — it is a contention point wearing one (Code Ownership).
  • Cross-module communication is a design decision with two shapes: a direct call to a public interface, or an event the other module subscribes to. The choice is about whether the caller needs an answer, not about fashion (Commands vs Events in Backend covers the mechanics).

What a module owns, and the two halves people implement separately

The arrangement has two requirements, and teams almost always implement one of them. The first is a public interface: a declared, small set of entry points, with everything else private. The second is data ownership: the module's tables belong to it, and no other module touches them.

Implementing only the first produces a codebase with tidy imports and a database that every module reads — which is the coupling that makes extraction expensive and the one no import checker can see. Implementing only the second produces owned data reached through arbitrary internal functions, so every internal change is a potential breakage. Both halves, or neither (Internal Module Contracts).

  • The arrow from billing to accounts is declared and acyclic. Accounts must not know billing exists (Dependency Direction).
  • There is no arrow from billing to the accounts schema. That absence is the half most teams skip.
  • Notifications is reached by event, not by call, because nobody needs an answer from it (Effect Boundaries).
  • Events are published after commit, so a rolled-back transaction cannot have notified anybody (The Transactional Outbox in Backend covers doing this reliably).
Three modules, one deployment, two kinds of boundary
calls the interfacecalls the interfacedeclared dependency, by idonly accounts may touch thisonly billing may touch thispublishes AccountConverteddeliversHTTP layer — routes to module interfaces onlybilling — public: BillingApiaccounts — public: AccountsApischema: billing (own user, own grants)schema: accounts (own user, own grants)In-process event bus — published after commitnotifications — subscribes to events
UserLLMAgentToolDataDecisionHumanGuardrail

What it looks like on disk

The layout below is not the point in itself — several shapes work. What matters is that the tree makes three things visible at a glance: what is public, what is private, and what a module owns in the database. A structure where you have to read the code to know which functions other modules may call has already lost.

Note the two files at the root. One declares the allowed dependency edges and is checked in CI; the other is the composition root, and it is the only file that imports more than one module's internals. Everything else is enforcement (Wiring and the Composition Root).

A module tree that can be checked
1src/
2 modules.allowed <- declared edges; CI fails on any other import
3 main.ts <- composition root: the only cross-module wiring
4
5 modules/
6 accounts/
7 index.ts <- THE public interface. Nothing else is exported.
8 internal/
9 account.ts <- entity, rules, state machine
10 repository.ts <- talks to schema "accounts" only
11 handlers.ts
12 migrations/ <- owns its own schema migrations
13 accounts.test.ts <- exercises index.ts, never internal/
14
15 billing/
16 index.ts <- imports accounts/index.ts, by AccountId
17 internal/
18 migrations/
19
20 notifications/
21 index.ts <- subscribes; publishes nothing others depend on
22 internal/
23
24 platform/ <- genuinely generic, no domain knowledge
25 clock.ts ids.ts result.ts
26
27# modules.allowed
28# billing -> accounts
29# notifications -> (none; event subscriber only)
30# accounts -> (none)

Two properties are visible without reading any code: what a module exposes (one file) and what it owns (one migrations directory, one schema). The platform/ directory is deliberately narrow — the moment domain logic appears in it, it has become a common module and the boundaries start dissolving from the inside (The Common Module).

How modular monoliths stop being modular

The decay is never a decision. It is a sequence of individually reasonable commits, each made under time pressure by someone who intended to come back to it. Knowing the specific shapes makes them visible in review, which is the only place they can be caught cheaply.

The row to take most seriously is the second. Data-level coupling is invisible to every import checker, survives every refactor, and is the single thing that turns a two-week extraction into a two-quarter one (The Shared Database: An Honest Trade, Not a Prohibition).

The characteristic decay paths
TriggerSymptomCauseResponse
A module needs a field that another module's interface does not exposeA new method appears on the other module's public interface, tailored to exactly one callerThe interface is being extended per-caller rather than per-concept, so it grows toward being the module's entire internalsAsk what the caller actually needs conceptually. Often the right answer is that the logic belongs in the owning module, not that the data should leave it (Feature Envy).
An incident, a report, or a migration needs a cross-module queryA join across two modules' tables, added with a comment saying it is temporaryData ownership was a convention, and conventions lose to incidentsEnforce with database grants so the query cannot run, then provide a legitimate path: an export, a read model, or a method on the owner (Materialized Views: A Read Model That Lags in Distributed Systems).
Two modules need the same helperIt moves to a shared location, then accumulates domain knowledge from both sidesThe helper looked generic and was not; it encoded knowledge owned by one module (Duplicate Knowledge)Decide which module owns the concept and let the other call it. If neither owns it, it is probably a third concept that needs naming (Shared Libraries).
A cycle appears between two modulesThe CI dependency check is suppressed for one file, with a ticket numberThe boundary was drawn in the wrong place, and the cycle is the domain telling you soTreat a cycle as a modelling finding, not a lint failure. Either the two are one module or a third concept is missing between them (Breaking Cycles).
A team reorganisationModule boundaries no longer match who owns what, and every module has three part-time ownersModules were drawn around the team chart rather than around the domainRedraw around the domain, which is more stable than the org chart, and assign ownership to the new shape rather than the reverse (Code Ownership).
A module's interface exposes its ORM entitiesA schema change breaks compilation in three other modulesThe interface was written for convenience: returning the entity was less work than defining a typeReturn module-owned types or identifiers. This is the same boundary-adapter rule applied internally (Boundary Adapters).

How to build it

Most important first.

  • Give each module its own public interface — a single entry file, an explicit export list — and make everything else private to it (Designing a Module Interface).
  • Give each module its own tables, and ideally its own schema. Then take away the ability to cheat: a separate database user or connection with grants only on that schema turns a convention into an error (Internal Module Contracts).
  • Make the dependency graph explicit and acyclic, and check it in CI. Cycles between modules are the specific thing that makes extraction impossible later (Breaking Cycles).
  • Pass identifiers across boundaries, not objects. A module receiving another module's entity has just acquired a dependency on its internals (Boundary Adapters).
  • Keep the transaction where the invariant is. Inside a module, use it freely; across modules, prefer publishing an event after commit, because that is the shape a future split would need anyway (Consistency Boundaries).
  • Do not create modules for everything at once. Extract the two or three areas with the clearest ownership and leave the rest as a shrinking common area with an explicit plan (The Strangler Pattern).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • A change inside one module: one commit, one module's tests, and a compiler-verified blast radius that stops at the interface. This is the number the whole arrangement is buying.
  • A change to a module's public interface: the compiler enumerates every caller, so the cost is proportional to genuine dependents rather than to the size of the codebase — and it is still one commit and one deploy.
  • A change spanning three modules: still one commit and one deploy, unlike the service version, but it now requires agreement between three owners. The cost has moved from technical to social, which is the honest shape of the trade.
  • What did not get cheaper: cross-cutting changes such as adding a tenant identifier to everything still touch every module. Module boundaries contain domain change, not infrastructural change (Change Amplification).
What the recommended approach costs
  • Boundaries are enforced by tooling you maintain rather than by a network, so they need continuous, unglamorous attention and they decay the moment it stops.
  • Module-owned data means giving up joins across modules. Reporting becomes a genuine design problem rather than a query, and this is the cost teams feel first (Cost-Aware Interfaces).
  • The discipline costs speed early. A four-person team building a new product should not do this yet; it is the right structure once the domain is understood and the team has grown (When Design Does Not Pay).

What can go wrong

Failure modes
  • The interface exists but every type it exposes is a database entity, so callers depend on the schema and the boundary is decorative (Schema Leakage in Backend names the mechanism).
  • A "temporary" direct table read is added during an incident and never removed. Data-level coupling is invisible to every import check, so it accumulates undetected (Internal Module Contracts).
  • Modules are drawn around teams rather than around the domain, so a reorganisation invalidates the structure and the boundaries no longer match anything (Cohesion).
  • The mitigation fails specifically: an import lint rule is added, and within a month there are twelve suppression comments — each individually justified, collectively an unenforced rule with an audit trail.
Dependencies, and their direction
  • Modules depend on each other's public interfaces only, in a declared, acyclic direction. That is the entire content of the arrangement (Dependency Direction).
  • Everything still shares one runtime and one dependency tree, so a library upgrade is a whole-system event. This is a genuine and permanent cost of the shape (Transitive Dependencies).
  • Watch for the dependency that is not in the source tree: two modules that both assume the same row exists are coupled through data even with perfect import hygiene (Shared-State Coupling).
Misreads
  • "Modular monolith is a step toward microservices." It is a valid end state, and most systems that reach it never need to go further. Treating it as a waypoint produces splits that no constraint required (YAGNI, With Its Bill Attached).
  • "We have modules because we have folders." The test is whether a violation is prevented or at least fails a build. If the only enforcement is review, the modules are aspirational (Decomposition by Folder).
  • "Modules should be as small as possible." Small modules multiply cross-module calls and interface churn. Granularity should follow how the domain actually clusters, and too fine is a real failure mode (Module Granularity).
  • "Once modules own their data, we can join across them for reporting." That is the same coupling arriving through a different door. Reporting needs its own read model or an explicit export, not a bypass (Materialized Views: A Read Model That Lags in Distributed Systems covers the technique).
Smells this explains
  • shotgun-surgery
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Test each module through its public interface only. A test that reaches into internals pins them and makes the module harder to change than if it had no test (What a Unit Is).
  • Assert the module dependency graph in CI: allowed edges declared in one file, violations failing the build. It is the cheapest structural test available (Stable Dependencies).
  • Assert data ownership by giving the test database one restricted user per module, so a cross-module query fails as a permission error rather than passing quietly.
  • Keep contract tests on the interfaces you expect to become service boundaries later, which turns extraction into a mechanical exercise (Contract Tests).
How this design ages
  • The first year is spent shrinking the common area, not creating modules. Extraction is the slow part and the count of well-owned modules grows slowly by design (Extract Module).
  • Module boundaries move as the domain is understood — cheaply, because moving code inside one deployable is a refactor. Expect two or three significant boundary changes in the first two years and treat them as learning (Finding Seams).
  • When a module does need to become a service, the work is mostly done: an interface exists, the data is owned, the callers are known. Weeks rather than quarters, which is the second payoff and the one that makes the modular monolith a strategy rather than a compromise.
  • It stops being sufficient when release coordination between module owners is the bottleneck, which is a team-count threshold (Designing a Monolith).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • SCALE-SPECIFICBelow about two teams the ceremony is not repaid: one person holds the whole domain and module interfaces are friction with no benefit. Above five or six teams the release coordination cost starts to dominate and the argument shifts toward genuine separate deployables. The arrangement is at its best in the middle, which is where most products spend most of their life.
  • CONTESTEDThe strongest case against: internal boundaries are enforced by tooling the team owns, and tooling the team owns is tooling the team can suppress — so under sustained pressure the modular monolith reliably degrades into a monolith with folders, while a service boundary does not degrade at all. Practitioners who have watched that decay twice are right to distrust it, and the honest answer is that this arrangement requires ongoing, visible enforcement and is not a structure you can set up and leave. The counter-argument is that a network boundary makes wrong boundaries permanent rather than making right ones durable.
  • DOMAIN-SPECIFICThe arrangement assumes the domain divides into areas with genuinely different reasons to change. Some do not: a system whose entire value is one deeply interconnected model — a scheduling engine, a solver — will produce modules that all change together, and the boundaries will be pure overhead. The dividing question is whether requirements arrive addressed to one area or to the whole thing.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the module boundaries you draw here are the candidate service boundaries later, so the criteria for a good one are the same criteria at a different grain.
  • Testing & Reliability Engineering — contract tests kept on internal module interfaces are what make a future extraction mechanical, and they are cheap to run while everything is still in one process.