MonolithSCALE-SPECIFICCONTESTEDLIFETIME-SPECIFIC

Internal Module Contracts

Inside one deployment, the contract that matters is who may touch which data. Arbitrary cross-module table access is what makes a modular monolith aspirational rather than real.

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

Inside a single process with a single database, what stops a module boundary from being a suggestion?

The requirement

Reporting needs order totals broken down by customer segment. The orders table is right there, the join is four lines, and the alternative is asking the orders team for an interface change they will schedule for next sprint.

The obvious build

Write the join. It is one query, it is read-only, it changes nothing, and forbidding it is bureaucracy that slows delivery for a theoretical benefit.

Why it breaks

The read-only argument is the strongest one and it is why this always starts with a read. It breaks because a read is a dependency on shape: the orders team can no longer rename a column, split a table or change a status encoding without breaking a module they have never opened (Stable Boundaries).

How it breaks as requirements change
  • The read-only argument is the strongest one and it is why this always starts with a read. It breaks because a read is a dependency on shape: the orders team can no longer rename a column, split a table or change a status encoding without breaking a module they have never opened (Stable Boundaries).
  • It breaks worse when the shape carries meaning. Reporting now has to know that a status of 4 with a non-null cancelled_at means refunded, which is a business rule that has just been copied out of the orders module and will not be updated when the rule changes (Duplicate Knowledge).
  • The second query is easier than the first. Once a precedent exists, "orders is queried by reporting" becomes a fact of the system, and within a year six modules read those tables and the orders team's schema is a public API nobody agreed to (API Stability).
  • And it silently deletes the extraction option. When orders becomes a service, six queries in five modules must be replaced simultaneously, which turns a mechanical extraction into a coordinated project (The Strangler Pattern).
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 connection pool, one set of credentials — so every module can technically read every table today.
  • The reporting need is real and urgent; refusing it without offering a path is not an option.
  • The team wants the option of extracting orders into its own service within a year, which the join would quietly remove.
Invariants
  • An order's state is only ever changed by the orders module, so its state machine cannot be violated by a writer that does not know the rules (Invalid Transitions).
  • Any module's internal schema can change without breaking another module, because no other module knows its shape (Information Hiding).

Who owns what, and where the seams fall

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

Responsibilities
  • The owning module is responsible for its tables, their shape, their meaning and their migrations — and therefore for providing legitimate ways to get at what other modules need (State Ownership).
  • The calling module is responsible for asking in domain terms rather than in schema terms: "totals by segment", not "rows from these two tables".
  • The team is responsible for enforcement that does not depend on remembering, because everyone will be under deadline pressure eventually (Design Review).
  • Someone is responsible for reporting as a genuine architectural need rather than as a series of exceptions, because reporting is where this pressure always originates (Cost-Aware Interfaces).
Boundaries
  • The contract has two clauses and both are needed: the module's public interface is the only entry point in code, and the module's schema is the only place its data lives. A boundary with one clause is a boundary in one dimension.
  • The strongest available line is a separate database schema with its own role and grants, so a cross-module query fails as a permission error at the first attempt rather than as a coupling discovered a year later.
  • Where a genuine cross-module read is required — reporting, search, exports — the answer is a declared, versioned read path: a published view, a projection, or an export the owner maintains. That is a contract; an ad-hoc join is not (Versioned Interfaces).

The four-line join and what it actually costs

The request is reasonable, the query is correct, and the cost is entirely in the future — which is why this is the point at which most modular monoliths quietly stop being modular. The useful thing is to make the future cost concrete rather than to argue about principle.

Look at what the two versions know. The query knows the orders schema, the status encoding and the refund rule. The interface call knows that orders can produce totals by segment. When the orders team changes the status encoding, one of those two breaks silently and produces wrong numbers (Invariant Leaks).

Reporting needs order totals by segment
The join across the boundary
-- reporting/queries.sql
SELECT c.segment, SUM(o.total_cents)
FROM   orders o
JOIN   customers c ON c.id = o.customer_id
WHERE  o.status = 4
  AND  o.cancelled_at IS NULL     -- "4 + not cancelled = settled"
GROUP  BY c.segment;

-- reporting now depends on:
--   the orders table shape
--   the customers table shape
--   the meaning of status 4
--   the refund rule, copied out of the orders module
-- none of which the orders team knows it has promised.
A contract in domain terms
// orders/index.ts  — public interface
export interface OrdersApi {
  settledTotalsBySegment(period: Period): Promise<SegmentTotal[]>
}

// reporting/report.ts
const totals = await orders.settledTotalsBySegment(lastQuarter)

// reporting now depends on:
//   one method signature and the word "settled"
// the orders team is free to re-encode status, split the
// table, or become a service, and does so behind that word.

The join couples reporting to storage; the method couples it to a concept. Storage is the thing that changes — migrations, denormalisations, encodings — and the concept is the thing that does not. The refund rule is the sharpest illustration: in the first version it exists in two places and will diverge the first time it changes, because the person changing it has no way to know reporting encoded it too (Duplicate Knowledge).

Writing the contract down

A contract that lives in a wiki is a preference. Written as code, it does three things: it names what is public, it names what other modules may depend on, and it gives CI something to check. The version below is deliberately small — the discipline is in what is absent from it.

The type detail that matters is that nothing in the signature is a database row or an ORM entity. Every type crossing the boundary is owned by the module and can be kept stable while its storage changes underneath, which is the entire purpose (Boundary Adapters).

orders/index.ts — the whole contract
1// The only file other modules may import from orders/.
2export type OrderId = string & { readonly brand: unique symbol }
3export interface SegmentTotal { segment: string; totalCents: number }
4
5export interface OrdersApi {
6 place(cmd: PlaceOrder): Promise<OrderId>
7 cancel(id: OrderId, reason: CancelReason): Promise<void>
8 settledTotalsBySegment(period: Period): Promise<SegmentTotal[]>
9}
10
11// NOT exported, and unreachable from other modules:
12// OrderRow, OrderRepository, statusToDomain(), the migrations,
13// and every helper that knows status 4 means settled.
14
15// Enforced by three things, not by agreement:
16// 1. modules.allowed -> reporting may import orders
17// 2. an import check -> only orders/index.ts, never internal/
18// 3. GRANT USAGE ON SCHEMA orders TO orders_role; (and nobody else)

Clause three is the one that distinguishes this from a naming convention. Clauses one and two are checked by tools the team maintains and can suppress; clause three is checked by the database and produces a permission error at the first attempt, in every environment, including during an incident at 3am when discipline is at its lowest (Least Privilege as a Design Decision).

Enforcement mechanisms, and what each one actually catches

Enforcement is a spectrum, and the useful question is not "is this enforced" but "what would it take to violate this, and would anyone notice". Ranking the options by that question produces a clear ordering — and shows why the two that teams reach for first are the two weakest.

The pattern in the last column is worth internalising: mechanisms that fail loudly at the moment of violation are the only ones that survive pressure. Everything else records violations rather than preventing them (Debuggability by Design).

  • No single row covers both columns. The practical answer is an import check plus per-module database grants, which together cost about two days.
  • The mechanisms get stronger downward and also more expensive; the honest stopping point for most teams is the schema-grant row (The Complexity Budget).
  • Whatever you choose, provide the legitimate path at the same moment. Enforcement without service is bypassed, and the bypass is undocumented (The Requirements Nobody States).
MechanismCatches code accessCatches data accessCost to set upHow it is defeated
Convention and code reviewSometimes — depends who reviewsRarely — a join looks like ordinary SQLNoneA deadline, a new hire, or the advocate leaving. Fails precisely when attention is scarce (Bus Factor).
Folder structure and namingNo — folders do not restrict importsNoNoneAn import statement. It was never a mechanism, only a signal (Decomposition by Folder).
Import lint rule in CIYes, reliablyNo — SQL strings are opaque to itAn afternoonA per-file suppression comment. Watch the count: twelve suppressions is an unenforced rule with an audit trail.
Language module system (packages, internal visibility)Yes, at compile timeNoHours to days, depending on the languageReflection, or moving the caller into the package. Hard to do by accident, which is what matters (Designing a Module Interface).
Separate database schema + per-module role and grantsNoYes — the query fails with a permission errorA day, plus migration and local-dev changesA shared superuser connection in production while CI uses restricted roles. Check the production connection, not the test one.
Separate build unit or package artifactYes — the symbol does not exist to importNoDays, plus a versioning and release storyVersion pinning turns a compile error into a stale dependency; you gain enforcement and acquire a release-coordination problem (Semantic Versioning).
Separate deployableYesYes, if the database is also splitWeeks, plus permanent operational costNothing — which is the appeal, and the reason it is chosen for problems that a schema grant would have solved (Designing a Monolith).

How to build it

Most important first.

  • Give each module its own schema and its own database role with grants only on that schema. This is the single highest-leverage move in the module, and it converts a convention into an error message (Least Privilege as a Design Decision).
  • Provide the legitimate path at the same time as the restriction. Restricting access without offering an alternative guarantees the restriction is bypassed under pressure, and correctly so (Error Boundaries).
  • Express the contract in domain terms. A method that returns totals by segment can be reimplemented, cached or moved behind a service; a query that returns rows cannot (Naming and Domain Language).
  • Publish a stable view for read-heavy cross-module needs, and treat that view as a public interface with the same compatibility obligations as any other (Backward Compatibility as a Constraint).
  • For reporting specifically, build a separate read model fed by events rather than by joins, so analytical needs do not constrain transactional schemas (Materialized Views: A Read Model That Lags in Distributed Systems covers how).
  • Check the code-side clause in CI as well: a module's repository classes should be unreachable from outside its own package (Stable Dependencies).

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
  • Under the contract: the orders team renames a column, runs its own migration, and no other module notices. The cost of a schema change is bounded by the module, permanently, which is the property being purchased.
  • Adding a new cross-module need costs one interface method or one published view plus a test — a day, and it is a conversation with a known owner rather than a search for who might break.
  • Extracting orders into a service later costs replacing one interface implementation, because the callers already speak in domain terms. Weeks instead of quarters (Incremental Migration).
  • What got more expensive, honestly: the first reporting request. Four lines of SQL became a conversation, an interface method and a test — call it a day instead of an hour. That cost is paid every time, and it is the real reason teams abandon this (The Cost of Change).
What the recommended approach costs
  • You give up joins across module data, which is a real loss of expressive power and the thing that makes this unpopular in practice.
  • Every cross-module read becomes someone's work item, so a boundary that is well enforced and poorly served is worse than no boundary at all.
  • Separate schemas complicate migrations, backups and local development setup — modest costs, but they are paid by everyone every day.

What can go wrong

Failure modes
  • The grant model is set up and then one module is given broad read access "for reporting", which reopens the entire hole through a legitimate-looking door.
  • The public interface becomes a thin wrapper over queries — a method per caller returning rows — so the contract is nominal and the schema is still the real interface (Not Leaking Your Internals in Backend names the pattern).
  • The owning module refuses reasonable requests, so callers route around it. A boundary defended without service is a boundary that will be bypassed, and the bypass will not be documented (Tone, Disagreement and Receiving Review).
  • The mitigation fails in production: separate schemas are enforced in CI but the production application uses one superuser connection, so nothing is actually prevented where it matters.
Dependencies, and their direction
  • A permitted dependency is on another module's public interface, in the direction declared in the module graph. A forbidden one is on another module's tables, in any direction (Dependency Direction).
  • Note the asymmetry: the interface dependency is checked by the compiler and moves when the interface moves; the table dependency is checked by nothing and survives every refactor (Shared-State Coupling).
  • The read model, if you build one, depends on events from the owning modules — which is a dependency on their published facts rather than on their storage, and that is the whole difference (Commands vs Events in Backend).
Misreads
  • "Reads are harmless." A read is a dependency on shape and often on meaning. The write restriction is more obviously important and the read restriction is what actually preserves the option to change (Kinds of Coupling).
  • "This is just microservices discipline with extra steps." It is the same discipline, and that is the point: it is the part of a service split that provides the benefit, available without the operations bill (The Modular Monolith).
  • "We will enforce it in code review." Every team says this and none of them survive a quarter of incidents. Enforcement that depends on attention fails exactly when attention is scarce (Bus Factor).
  • "Then reporting is impossible." Reporting is a first-class requirement that needs its own design — a read model, an export, a warehouse. What is being refused is reporting implemented as an undeclared dependency on someone else's schema (The Requirements Nobody States).
Smells this explains
  • feature-envy
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Run integration tests with the per-module restricted role, so an unauthorised query fails the test suite rather than passing quietly (Where a Test Must Be Real).
  • Assert the code-side clause with an import check: nothing outside a module may import its internal package (Stable Dependencies).
  • Test published views as contracts, with their own test suite, because they are now an interface and will be treated as one by consumers whether or not you intended it (Contract Tests).
  • Test that the owning module rejects invalid state transitions, since the contract's entire purpose is that this cannot be bypassed by another writer (Invalid Transitions).
How this design ages
  • The first year is spent finding the cross-module reads that already exist. There are always more than anyone expects, and cataloguing them is most of the work (Characterization Tests).
  • Published views tend to accumulate consumers and become the hardest thing to change in the system, so they need the same deprecation discipline as any external API (Deprecation).
  • As modules mature, the interface stops being a wrapper over queries and starts holding real operations, which is the signal the boundary has become genuine rather than syntactic.
  • The contract stops being enough when read volume across boundaries is high enough that interface calls become an N+1 problem, at which point a proper read model is not optional (N+1 as a Design Problem).

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-SPECIFICAt one team the contract is nearly free to violate and nearly free to fix — the same person owns both sides, so a join is a local decision with a local consequence. It becomes essential the moment the owner of the table and the author of the query are different people, because that is when the dependency stops being visible to anyone who could act on it.
  • CONTESTEDThe strongest opposing view: one database with shared access is a feature, not a flaw. Relational databases are extremely good at joining, ownership rules discard that capability, and the result is application code reimplementing joins in a loop — slower, buggier and harder to read than the SQL it replaced. That critique is right about the mechanism and right about the cost, and teams that enforce ownership without providing a designed read path do produce exactly that outcome. It is wrong that the alternative is free: shared access means every table is a public interface, which is a promise nobody made and everybody is then bound by.
  • LIFETIME-SPECIFICThe benefit is entirely about future change, so it scales with how long the system must keep absorbing requirements. For an internal tool that will run unchanged for two years and then be retired, the contract costs real time and returns nothing; the restriction is worth its cost only where schemas will move and modules may be extracted.

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 analytical read path this lesson keeps deferring is a design problem in its own right: a warehouse, a projection or a replica, chosen on freshness and cost rather than on convenience.
  • Testing & Reliability Engineering — running the test suite under each module's restricted database role is the cheapest way to turn an ownership rule into something that fails a build.