Architecture / Monolith / Backend Engineering
The Modular Monolith Is a Real Architecture
A monolith is a deployment choice, not a code-quality verdict. How to structure one with enforced module boundaries, scale it further than expected, and extract services later only where it pays.
On this page
"Monolith" is often used as a synonym for "legacy" or "messy". That conflates two independent properties. A monolith is a system deployed as one unit. A big ball of mud is a system without internal boundaries. Plenty of microservice systems are muddy, and a monolith can be one of the best-structured codebases you will work in.
The modular monolith takes the boundary discipline usually associated with services and applies it inside a single deployable. You keep the operational simplicity of one process and one database, and you avoid the coupling that makes large monoliths hard to change.
What a monolith gives you for free
These properties are easy to undervalue until they are gone:
- Function calls instead of network calls. No serialization, no timeouts, no partial failure between modules, and stack traces that cover the whole request.
- Transactions. "Create the order, reserve the stock, record the payment" can be one ACID transaction. In a distributed system the same flow needs a saga, compensations, and idempotent steps.
- Atomic refactoring. Renaming a concept across modules is one commit, checked by one compiler and one test suite.
- One thing to deploy, monitor, and run locally. A new engineer can clone the repository and run the whole system.
How monoliths actually go wrong
Large monoliths rarely become painful because of their size in lines of code. They become painful because of unconstrained dependencies:
- Any module can read or write any table, so nobody knows who depends on a column.
- Shared "utils" or "common" packages grow until everything depends on everything.
- Circular dependencies between areas make it impossible to change one without the other.
- The test suite and the deploy pipeline become shared bottlenecks for every team.
- One slow or memory-hungry feature affects every other feature in the same process.
Only the last two are inherent to the deployment model. The rest are boundary problems, and boundary problems are solvable inside one codebase.
Structure: modules with public interfaces
A module corresponds to a business capability, such as catalog, orders, or billing, and has three properties:
- A public interface. A small set of functions and types other modules may use. Everything else is private.
- Owned data. The module is the only code that writes its tables. Ideally it is also the only code that reads them.
- Explicit dependencies. A module declares which other modules it uses, and the dependency graph has no cycles.
In a TypeScript codebase, a layout like this keeps the public surface obvious:
src/modules/
orders/
index.ts ← public interface: the only file others import
domain/ ← entities, rules
application/ ← use cases
infrastructure/ ← repositories, SQL, external clients
catalog/
index.ts
...// src/modules/orders/index.ts
export type { Order, OrderId } from './domain/order';
export { placeOrder } from './application/place-order';
export { getOrderSummary } from './application/get-order-summary';
// Nothing else from inside orders/ is importable by other modules.Enforce the boundaries mechanically
Conventions erode under deadlines. Boundaries that matter should fail the build when crossed.
Imports. A lint rule can forbid reaching into another module's internals:
// eslint.config.mjs (excerpt)
{
files: ['src/modules/**'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['@/modules/*/*'],
message: "Import another module through its index.ts, not its internals.",
}],
}],
},
}Dedicated tools such as dependency-cruiser can additionally forbid cycles and enforce an allowed dependency graph between modules.
Data. Give each module its own database schema (orders.*, billing.*) and, if you want the database to enforce it, its own database role with privileges only on its schema. Cross-module joins then become visible design decisions instead of accidents.
Tests. Architecture tests that assert "billing does not depend on orders' internals" document the intended structure where every engineer will see it fail.
Communication between modules
Modules communicate in the same two ways services do, with much lower cost:
- Direct calls through the public interface when the caller needs an answer. These are function calls, so they are fast and transactional.
- In-process events when the caller only needs to announce that something happened.
OrderPlacedcan be dispatched to subscribers in billing and notifications inside the same process. If a subscriber's work must survive a crash, write the event to an outbox table in the same transaction and process it asynchronously. That is the same pattern you would use between services, which makes later extraction easier.
Reading another module's data for reports or search is a legitimate need. Serve it through the owning module's query functions or a read-only view the owning module maintains, not through ad hoc joins against its tables.
Scaling a monolith further than expected
"It doesn't scale" is usually said about a specific bottleneck, not about the deployment model. Common steps, roughly in order:
- Run more instances. A stateless web tier behind a load balancer scales horizontally like any service.
- Split processes, not code. Build the same codebase into separate entry points: web servers, background workers, scheduled jobs. Each scales and fails independently while sharing modules and types.
- Fix the database first. Most monolith scaling problems are query, index, and connection-pool problems. Read replicas, caching of read-heavy paths, and moving heavy work to queues address many of them.
- Isolate the noisy feature. If one capability has a very different load profile, route it to dedicated instances of the same build before considering a separate service.
Extracting a service later
A well-modularized monolith makes extraction a mechanical exercise rather than an archaeology project. The module already has a public interface, owned data, and explicit dependencies. Martin Fowler's strangler fig pattern describes how to move it without a rewrite:
- Put a routing layer in front of the capability, either at the HTTP edge or behind the module's interface.
- Build the new service behind the same interface, and copy or synchronize its data.
- Shift traffic gradually, comparing results where possible, and keep the ability to route back.
- When the new service carries all traffic, delete the old module.
Extract when one of the reasons for services applies, as discussed in Microservices Are an Ownership Decision: a separate owning team, a genuinely different scaling or security profile, or release cadence contention. "The codebase is big" is not on that list. Boundaries fix that.
Choosing a starting point
| | Monolith (unstructured) | Modular monolith | Microservices | | --- | --- | --- | --- | | Deployables | One | One (or a few entry points) | Many | | Internal boundaries | Convention, if any | Enforced by tooling | Enforced by the network | | Cross-boundary consistency | Transactions | Transactions | Sagas, eventual consistency | | Operational cost | Low | Low | High; needs a platform | | Team independence | Low | Medium | High | | Cost of moving a boundary | Low but risky | Low | High | | Good fit | Prototypes, very small teams | Most products and teams | Many teams with clear domain ownership |
For most products, the modular monolith is the right default. It keeps the cheap path cheap, and it leaves the expensive path open for the parts of the system that will need it.
Further reading
- Martin Fowler, "StranglerFigApplication" (bliki).
- Simon Brown's talks and writing on modular monoliths and the C4 model.
- Kamil Grzybek's open-source Modular Monolith with DDD reference project.