Modular Monolith
A modular monolith keeps one deployable and one database but enforces service-grade boundaries inside it — each module exposes a public API, owns its tables, and communicates through in-process events — so teams get independence in the codebase without paying for a network, and a module can later be extracted along a boundary that already exists.
Teams in a monolith collide because nothing stops any package from calling or querying any other; teams in microservices pay for that separation with network calls and lost transactions. A modular monolith puts the boundaries in the code and keeps the process, which is where most of the value of boundaries actually lives.
Boundaries without the network
A modular monolith is a monolith whose internal structure is organised by business capability — User, Order, Payment — with rules that make each module look, from the others’ point of view, like a service: it has a small public API, private internals, and tables nobody else may touch. The application is still one process and one deploy; the database is still one Postgres. What changes is that the coupling which quietly grows in an ordinary monolith is made impossible by construction, and checked by tooling on every pull request.
Inside a module, the usual code structure applies: a domain layer with the entities and rules, an application service that orchestrates a use case, and a repository that talks to the module’s own tables (Layered Architecture, Hexagonal Architecture (Ports & Adapters)). The module’s public API is the only thing exported; the domain, the repository and the tables are private. The Order module that needs a user’s email address calls users.getContact(userId) — it does not import the User repository or join the users table.
The three rules, and how to enforce them
Rule 1 — a module has one public API. One exported facade (an interface plus DTOs) is the only thing another module may import. Everything else — entities, repositories, ORM models, helpers — is internal. Rule 2 — no cross-module table access. Each module owns a database schema (users.*, orders.*, payments.*) or at least a table prefix, and only its own repository queries it; there are no foreign keys across schemas, because a foreign key is a join waiting to happen. If Orders needs user data, it calls the User API or keeps its own copy of the fields it needs (a user’s display name on the order, written at order time). Rule 3 — consequences travel as in-process events. When an order is placed, the Order module publishes OrderPlaced; Payment and Notification subscribe. The publisher does not know who listens, which is what keeps it from growing a dependency on every downstream module.
Rules that are not enforced erode within a quarter. Enforcement is cheap: an ESLint no-restricted-imports rule (or dependency-cruiser) that forbids importing anything but modules/*/api; separate packages in a monorepo so the compiler refuses the import; Go’s internal/ directories; ArchUnit tests in the JVM world; a database role per module in Postgres so a stray query fails at runtime, not in a code review. Put the check in CI so a "temporary" cross-module import cannot merge.
1// modules/users/api.ts — the ONLY file other modules may import2export interface UsersApi {3 getContact(userId: string): Promise<{ email: string; name: string } | null>4}5 6// modules/orders/placeOrder.ts7export async function placeOrder(users: UsersApi, bus: EventBus, cmd: PlaceOrder) {8 const contact = await users.getContact(cmd.userId) // API call, not a JOIN9 if (!contact) throw new Error('unknown user')10 const order = await orderRepo.insert({ ...cmd, buyerName: contact.name })11 await bus.publish({ type: 'OrderPlaced', orderId: order.id, total: order.total })12 return order // payments/notifications react to the event13}14 15// eslint: forbid deep imports across modules16// 'no-restricted-imports': ['error', { patterns: ['@/modules/*/!(api)', '@/modules/*/internal/*'] }]Monolith vs modular monolith vs microservices
The matrix is the decision most teams actually face, and the middle column is the most common right answer. A modular monolith keeps the transaction, the local development story and the single deploy, and adds only the discipline of boundaries. It loses the plain monolith’s freedom to join anything, which is exactly the freedom that was hurting. It does not deliver what microservices deliver — independent deploys, independent scaling, independent runtimes, failure isolation between capabilities — but for most teams below a few hundred engineers those are not the measured problem.
| Monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deployment | One artifact, one release train | One artifact, one release train | One pipeline per service; independent releases |
| Transactions | One DB transaction across anything | One DB transaction; across modules only via the public API | None across services; sagas and compensation |
| Team boundaries | By convention only; erode over time | Enforced in code and CI; a module per team | Enforced by the network; a service per team |
| Failure isolation | None: one process, one failure domain | None at runtime; a bug in one module still crashes the process | Per service, if timeouts and breakers are in place |
| Ops cost | Lowest: one thing to run | Lowest: still one thing to run | High: discovery, gateway, tracing, N pipelines, on-call per service |
| Refactor cost | Low locally, high globally (everything depends on everything) | Low: change a module’s internals freely; API changes are visible | High: contract changes need versioning and coordinated consumers |
How it becomes microservices later
The reason to keep the rules strict is that they make extraction a mechanical operation instead of a rewrite. When a measured reason appears — the Payment module needs PCI isolation, or a different scaling curve, or its team wants its own release cadence — you extract *that module* along a boundary that already exists. Its public API becomes an HTTP or gRPC client with the same method signatures. Its in-process event subscriptions become subscriptions on a real broker (Message Queues). Its schema moves to its own database, and because rule 2 forbade foreign keys and joins across schemas, nothing else breaks. This is the strangler approach applied to one module at a time, with the monolith still serving everything that has not moved.
What does change is the thing rule 3 was preparing you for: a use case that used to commit an order and a payment in one transaction now spans a process boundary, so it becomes a saga with a pending state and a compensation path (Distributed Transactions, Saga Pattern). Any module you have kept honest about that — publishing an event instead of calling the payment repository inside the order transaction — extracts cleanly. Any module you let cheat is the one that will fight you.
- Facade → network client with the same signatures; callers change one import.
- In-process events → broker topics; subscribers gain retries and duplicates, so handlers must be idempotent.
- Module schema → its own database; possible only because no foreign keys or joins crossed the boundary.
- Cross-module transactions → sagas; the modules that stayed honest extract cleanly.
Where modular monoliths fail
The failure is always the same: boundary erosion. One cross-module import merged at 6 p.m. before a release "just this once"; one report that joins orders to users because the API would need three calls; one common package that starts as shared types and becomes the place all the real logic lives, which is a monolith wearing a costume. Each is reasonable alone and together they remove the property the architecture existed for. The defence is tooling in CI, a database role per module, and a team norm that a boundary exception is a design review, not a code comment.
The second failure is expecting runtime isolation. A modular monolith is still one process: a runaway query in Reports still exhausts the connection pool that Checkout uses, and an unhandled exception in a Notification handler still crashes the request that triggered it. Boundaries in code do not give you bulkheads; if failure isolation is the measured need, that is the argument for extracting a service.
Key points
- Same process, same database, same deploy — but each module has one public API, owns its tables, and publishes in-process events instead of calling neighbours’ internals.
- Enforce the rules in CI (import lint, packages, database roles); unenforced boundaries erode within a quarter.
- Keeps the single transaction and the single deploy; gives up nothing that most teams below a few hundred engineers were using.
- Extraction becomes mechanical: facade → client, in-process events → broker, schema → own database — because the boundary already exists.
- It does not provide runtime failure isolation or independent scaling; if those are the measured need, extract.
Enforce module boundaries
How data moves through it
One request or event, hop by hop.
- 1Browser → Order module:
POST /ordershandled by the Order module’s HTTP adapter. - 2Order module → User module API:
users.getContact(userId)— an in-process call through the public facade, never a query onusers.*. - 3Order module → Postgres (
orders.*): insert the order and, in the same transaction, an outbox row forOrderPlaced. - 4Outbox → In-process event bus → Payment module: the Payment module charges and writes to
payments.*in its own transaction. - 5Payment module → Event bus → Notification module:
PaymentSucceededtriggers the confirmation email via a queue.
When to use — and when not
- A monolith where several teams collide in one codebase and coupling is measurably slowing changes, but deploy independence is not yet the bottleneck.
- A new system whose domain boundaries are known well enough to name modules but not well enough to commit to network boundaries.
- As the deliberate stop before microservices: prove the boundaries in-process before paying for them across a network.
- When runtime failure isolation is the requirement (a PCI-scoped payment path, a CPU-heavy job that must not starve the API) — boundaries in code do not isolate failures.
- When components need genuinely different scaling or runtimes; a module cannot be scaled separately from the process it lives in.
- A tiny team with one bounded context: the module ceremony costs more than the coupling it prevents.
Tradeoffs
Operationally identical to a monolith and consistent within one database. The extra complexity is discipline and tooling in the codebase, not machinery in production — which is why this is the most common right answer.
How it fails
- Boundary erosion: one cross-module import or one cross-schema join merged under deadline, and within months the modules are a monolith again.
- The
commonmodule that absorbs the real logic, so every module depends on it and it depends on nothing — the monolith has moved, not gone. - Assumed isolation: a runaway report query exhausts the shared connection pool and checkout fails, because a module is not a bulkhead.
- Cross-module transactions that were allowed "for now" and make the module impossible to extract later without a saga rewrite.
- In-process events treated as fire-and-forget without an outbox, so a crash between commit and publish silently loses the
OrderPlacedevent.
How it scales
- Exactly like a monolith: stateless copies behind a load balancer; the database is the ceiling.
- Because each module owns a schema, a hot module’s tables can be moved to their own database first, before any service is extracted.
- A module with a different scaling curve is extracted as a service along its existing boundary; the rest of the application stays a single deployable.
How it interacts with databases, queues, caches, APIs and external systems
- Database: one Postgres, one schema per module, no foreign keys across schemas; a database role per module makes cross-schema queries fail loudly.
- Cache: shared Redis is fine, but keys are namespaced per module so a cache is not a back channel between them.
- Queue: in-process events for module-to-module consequences; a real queue for slow external work, and the same broker later when a module is extracted.
- APIs: one external HTTP surface; internally, each module’s facade is the API, and it should be designed as if it might one day be remote (coarse-grained, DTOs, no lazy-loaded entities).
- External systems: reached only through the module that owns the relationship (the payment provider through Payment), so extraction takes the integration with it.