What a modular monolith is
A modular monolith is a single deployable application with strong internal module boundaries. From the outside it looks exactly like a monolith: one process, one build, one database, one deploy. On the inside it is organised into modules that own their data and expose a narrow public interface, and those boundaries are enforced rather than aspirational.
This is not a compromise or a stepping stone you settle for. It is a legitimate architecture that many systems should never leave. It keeps the properties that make software easy to build — in-process calls, one transaction, one pipeline, cheap refactors — and adds the discipline that keeps a growing codebase from turning into a tangle.
The distinction that matters is between one deployable and one unstructured blob. A traditional layered monolith has no notion of ownership: any controller can reach any table, and a change ripples across the whole codebase. A modular monolith says that the billing module is the only thing that understands invoices, and everyone else talks to it through a door it controls.
Why it is the sensible default
Most teams reach for microservices to solve problems they do not have. A modular monolith addresses the problems they do have — a codebase that is hard to change, unclear ownership, slow delivery — without adding a network between the parts.
The practical advantages are substantial:
- In-process calls are free. A module calling another module is a function call, not a request with a timeout, a retry policy and a failure mode.
- Transactions are real. A use case that touches one module commits atomically. There is no saga, no compensation and no window of inconsistency.
- Refactoring is a commit. Moving a boundary, renaming a concept or merging two modules is ordinary work, not a migration with dual writes and a cutover.
- Operations stay small. One build, one deploy, one set of logs, one on-call. This is not a minor point for a team of five.
- The domain gets time to settle. You can defer boundary decisions until you understand the business, instead of freezing guesses into infrastructure.
The honest cost is that modules do not fail or scale independently, and one module’s bug can take the whole application down. For most teams and most stages of a product, that is a trade worth making.
Modules follow business capabilities
A module should map to a business capability, not a technical layer. Billing, catalog, shipping, identity and notifications are capabilities. controllers, services, repositories and utils are layers, and grouping by them produces the classic layered monolith where every feature change spans four directories.
A good module boundary has the same properties as a good service boundary:
- It contains a coherent set of rules that change together.
- It hides far more than it exposes.
- It can be understood by one team without reading the rest of the system.
Concretely, each module is a folder with its own internal structure:
src/modules/billing/
domain/ # entities, value objects, invariants
invoice.ts
money.ts
application/ # use cases and orchestration
billing-service.ts
order-handlers.ts
infra/ # persistence and external clients
invoice-repository.ts
stripe-client.ts
index.ts # the only public entry point
The domain and infra folders are private. index.ts re-exports the small set of types and services the rest of the application is allowed to use. That single file is the module’s contract, and it should be small enough to read in a minute.
If you have ever seen the Clean Architecture dependency rule, this is the same idea applied at the module level: the domain at the centre depends on nothing outward, and infrastructure depends on the domain, not the other way around.
The shared kernel
Some concepts genuinely belong to no single module. Money, a CustomerId, a Clock or a Result type are used everywhere and owned by nobody. Put these in a small, explicit shared package and keep it deliberately tiny.
src/shared/
money.ts # value object, no dependencies
ids.ts # branded id types
clock.ts # a testable time source
A shared kernel is a coupling point, so treat its growth as a warning sign. The moment shared contains a service, a repository or anything that knows a business rule, it has become a module with no owner and every other module now depends on it. The rule of thumb is that shared holds types and pure functions, never orchestration or state.
Every module owns its data
The rule that gives modular monoliths their power is data ownership. Each module is the only writer to its tables or its schema, and no other module reads those tables directly. Cross-module access goes through the owning module’s interface or through an event.
In a single database you have three practical ways to express ownership:
- Separate schemas.
billing.invoices,catalog.products,shipping.shipments. The clearest signal, and it maps cleanly onto a future database split. - Table prefixes.
billing_invoices,catalog_products. Simpler, same intent. - Separate databases from the start. The strongest boundary, but it costs you the single-transaction superpower across modules and adds operational work.
Most modular monoliths should start with one database and separate schemas. The key is the ownership rule, not the physical separation: only the owning module’s repository touches its tables. If the shipping module needs a customer’s address, it asks the customer module, it does not SELECT from customers.
This is what makes extraction possible later. When a module already owns its data and speaks through an interface, moving it to its own service is a deployment change rather than a redesign.
Enforcing boundaries with tooling
Boundaries that are only a convention erode. Under deadline pressure, importing another module’s repository is always faster than adding a method to its interface, and one shortcut becomes twenty. Make the boundary mechanical.
A dependency rule is easy to state and easy to check: a module may import its own files and the public interface of other modules, and nothing else. Tools like eslint-plugin-boundaries and dependency-cruiser can express this and fail the build.
{
"forbidden": [
{
"name": "no-cross-module-internals",
"from": { "path": "^src/modules/([^/]+)/" },
"to": {
"path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
}
},
{
"name": "no-cycles",
"from": {},
"to": { "circular": true }
}
]
}
Two rules do most of the work: no importing another module’s internals, and no circular dependencies. Add a CODEOWNERS entry per module so reviews land with the people who own the code, and treat an interface change like a small API change — it deserves a second look.
In-process communication without a tangle
The failure mode of a monolith is a dependency graph where everything points at everything. A modular monolith keeps that graph acyclic and shallow. There are two ways for one module to use another.
Call the public interface. When the caller needs an answer immediately, inject the other module’s service and call it. Orders calls catalog.getProduct(sku) to price a line. The call is synchronous and in-process, so it is fast and fails as an exception, not a timeout.
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: CatalogService,
) {}
async execute(input: PlaceOrderInput): Promise<OrderId> {
const order = Order.create(input.customerId, input.lines);
for (const line of order.lines) {
const product = await this.catalog.getProduct(line.sku);
if (!product.isAvailable()) throw new OutOfStock(line.sku);
order.priceLine(line, product.priceCents);
}
await this.orders.save(order);
return order.id;
}
}
Publish an event. When the caller does not need an answer, it announces a fact and other modules react. Orders publishes order.placed; billing, analytics and notifications subscribe. The order module does not know they exist, which is what removes the coupling.
Events also break cycles. If orders needs a side effect from shipping and shipping already depends on orders, a direct call would create a loop. An event lets shipping react without orders depending on it. Keep the number of direct cross-module calls small; if two modules call each other constantly, they are probably one module or their boundary is in the wrong place.
One database, separate schemas
A single database is a feature, not a compromise. It gives you transactions, foreign keys, one connection pool and one migration story. What you give up is physical isolation, and you replace it with the ownership rule.
Within one database, prefer a schema per module. It keeps table names clean, makes ownership visible in every query, and gives each module a namespace for migrations. When a module is later extracted, its schema moves with it.
Avoid cross-module foreign keys. A foreign key from billing.invoices to catalog.products hard-couples the two modules at the database level: catalog cannot delete or restructure without considering billing, and extraction requires dropping the constraint. Store the id and validate it through the interface instead. The PostgreSQL guide covers schemas and constraints in detail.
Migrations deserve the same discipline. Each module owns its migration files, and the application startup or a migration step applies them in order. Because there is one deploy, you can migrate and release together, which is a luxury services do not have.
Transactions and consistency inside a module
The single-transaction superpower only holds when a use case stays inside one module. Make that the common case. A use case that loads an aggregate, validates an invariant and writes it back should be one transaction that either commits fully or rolls back cleanly.
await db.transaction(async (tx) => {
const order = await orders.getForUpdate(orderId, tx);
order.confirm();
await orders.save(order, tx);
await outbox.add(
{ type: "order.confirmed", orderId: order.id },
tx,
);
});
Two patterns keep cross-module consistency honest.
The outbox. Write the domain event to an outbox table in the same transaction as the state change, then a dispatcher publishes it afterwards. This guarantees the event is not lost if the process crashes between the commit and the publish, and it is the same pattern you would use after extraction.
A process manager. When a flow spans modules, a small coordinator can listen for events and issue the next command, handling retries and timeouts explicitly. This is the in-process equivalent of a saga, and it is far simpler than a distributed one because the coordination state lives in a normal table.
Do not reach for a distributed transaction. If a use case genuinely spans modules and must be atomic, that is usually a signal that the modules are one module.
The path to extraction
The reason to invest in boundaries is optionality. A modular monolith whose modules communicate through interfaces and events can be taken apart later, one module at a time, with the strangler fig approach.
- Pick the module with the clearest boundary and the strongest pressure. Independent scaling, a separate team cadence or a compliance requirement are good reasons.
- Confirm it owns its data. If other modules still read its tables, fix that first by routing them through the interface.
- Give it its own database and pipeline. Move its schema, point its repository at the new store, and keep the interface stable.
- Replace in-process calls with network calls or events. The callers already depend on an interface, so this is an adapter change, not a rewrite.
- Swap the event dispatcher for a broker. The outbox pattern means the event flow barely changes when the transport becomes Kafka or RabbitMQ.
Because the seam already exists, each step is bounded. This is the strongest argument for the modular monolith: it is not a different destination from microservices, it is the option to arrive there deliberately, only for the parts that earn it.
Testing modules in isolation
Module boundaries pay off in tests. Because a module exposes a public interface and owns its data, you can test it on its own without booting the whole application.
- Domain tests are pure and fast. Instantiate the aggregate, exercise the rules, assert the outcome. No database, no HTTP.
- Module interface tests drive the module’s public service against a test database scoped to its schema. They verify the contract other modules depend on.
- Event contract tests assert that the module publishes the events consumers expect, with the fields they rely on.
- End-to-end tests exercise the HTTP layer for a small number of critical flows. Keep these few, because they are slow.
The layered alternative forces every meaningful test through all layers, which is why those suites become slow and flaky. Testing at the module boundary keeps most tests fast while still protecting the interfaces that matter.
Modular monolith versus layered monolith
It is worth being precise, because both are “a monolith” and only one is modular.
| Layered monolith | Modular monolith | |
|---|---|---|
| Grouping | By technical layer | By business capability |
| Change impact | Spans every layer | Stays in one module |
| Data access | Any layer reaches any table | Module owns its tables |
| Ownership | Unclear | One team per module |
| Testability | End-to-end dominates | Module-scoped tests |
| Extraction | A rewrite | A bounded change |
The layered monolith is not wrong for a small application; it is simple and familiar. It becomes a liability when many people work in it, because there is no seam along which to divide the work and no way to reason about a change locally.
The failure mode: a big ball of mud
The modular monolith fails in one specific way: the boundaries dissolve. It starts with a reasonable shortcut and becomes the norm.
Warning signs:
- A module imports another module’s
infrafolder. - Two modules write to the same table.
- A
utilsorsharedpackage grows into a second application. - Changing one module’s schema breaks another module’s build.
- The dependency graph has a cycle, usually introduced by a single “just this once” import.
The prevention is the same discipline the rest of the architecture depends on: lint rules that fail the build, code owners per module, a small public interface, and a review culture that treats an internal import as a bug. Boundaries are cheap to keep and expensive to restore, so enforce them from the first module.
Events inside one process
In-process events decouple modules without a broker. A small dispatcher receives a published fact and calls the subscribed handlers, all within the same process and, when you want it, the same transaction.
type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;
class EventBus {
private handlers = new Map<string, Handler[]>();
on(type: string, handler: Handler) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
}
async publish(event: DomainEvent, tx?: Transaction) {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event, tx);
}
}
}
Two cautions. If handlers run inside the caller’s transaction, a slow handler extends the transaction and any failure rolls back the whole use case. If they run after the commit, a crash between the two can lose the event. The outbox resolves this by writing the event to an outbox table in the same transaction, then dispatching from there.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
type: "order.placed",
payload: order.toEvent(),
});
});
Because the event is committed with the state, it cannot be lost, and a relay can retry dispatch until every subscriber has processed it. When a module is later extracted, the relay is the only thing that changes.
A process manager for cross-module flows
When a use case spans modules and must react to failures, a process manager coordinates it explicitly instead of hiding the flow in a chain of event handlers. It listens for events, keeps its own state and issues commands.
class PlaceOrderProcess {
async onOrderPlaced(event: OrderPlaced) {
await this.catalog.reserve(event.orderId, event.lines);
}
async onReservationFailed(event: ReservationFailed) {
await this.orders.cancel(event.orderId, "out_of_stock");
await this.notifications.send(event.customerId, "order_cancelled");
}
async onReservationConfirmed(event: ReservationConfirmed) {
await this.payments.charge(event.orderId, event.totalCents);
}
}
The process manager is the in-process equivalent of a saga. It makes the happy path and the compensation path visible in one place, which is exactly what choreography hides. Keep the process state in a normal table so a restart resumes where it left off.
Versioning a module’s interface
A module’s index.ts is an internal API and deserves the same care as a public one. Other modules compile against it, so a careless change can break their builds.
- Add, do not break. Add optional parameters and new methods; avoid changing an existing signature.
- Keep the surface small. Every export is a promise. If a type does not need to leave the module, do not export it.
- Deprecate in stages. Mark the old method, migrate callers in separate commits, then remove it. Because it is one codebase, you can search for every caller.
- Test the contract. Module interface tests protect the promise you made to other modules.
This is cheaper than versioning a network API because there is no deploy ordering to respect, but it is the same discipline, and it is what keeps a future extraction from becoming a rewrite.
Migrations per module
Because a modular monolith has one database, it is tempting to keep one giant set of migration files. That recreates a coupling the architecture is trying to remove. Instead, let each module own its migrations, scoped to its schema or table prefix.
migrations/
catalog/ 20260901_add_product_status.sql
orders/ 20260903_add_order_confirmed_at.sql
billing/ 20260905_add_invoice_paid_at.sql
A migration that touches another module’s tables is a boundary violation in disguise and should fail review. Keeping migrations modular means the ownership rule holds all the way down to the schema, and it makes moving a module’s tables to its own database a matter of replaying one folder.
When to merge modules back
Not every boundary is right the first time. If two modules always change together, share a transaction, and call each other in both directions, they are one module with an artificial seam. Merging them back is a legitimate and healthy decision.
The signs are concrete: a pull request routinely touches both modules, their interface changes on every feature, and the dependency graph has a cycle you keep working around. Merging is cheap in a monolith — move the code, collapse the interface, update the imports — and it removes a coordination cost that would only get worse. The goal is clear boundaries, not a particular number of them.
Keeping the seams healthy
Boundaries decay quietly, so check them on a schedule rather than waiting for a rewrite. A few automated fitness functions catch the drift while it is still cheap to fix.
- Fail CI when a module imports another module’s internals.
- Fail CI when the dependency graph contains a cycle.
- Fail CI when a migration references a table owned by another module.
- Report the number of files and public exports per module as a trend; a module that keeps growing is a boundary that may be in the wrong place.
- Require a
CODEOWNERSreview for changes to a module’s public interface.
None of these require new infrastructure. They are ordinary tests and lint rules, and together they turn “we agreed to keep the boundaries” into something the build enforces.
Best practices
- Define modules by business capability, never by technical layer.
- Give every module a single public
indexfile and keep it small. - Make each module the only writer to its own tables or schema.
- Forbid cross-module imports of internals and cycles with lint rules in CI.
- Prefer a direct interface call when you need an answer, an event when you do not.
- Keep the dependency graph acyclic and shallow; if two modules call each other, merge or re-cut them.
- Use one database with a schema per module and avoid cross-module foreign keys.
- Keep a use case inside one module so it fits in one transaction.
- Use an outbox for cross-module consistency instead of distributed transactions.
- Test modules through their public interface, with a few end-to-end tests at the edge.
- Keep interfaces event-ready so a module can be extracted without a rewrite.
Common mistakes
- Calling it a modular monolith but sharing tables and importing internals.
- Organising folders by layer and believing the modules are real.
- Skipping the lint rules because “everyone knows the convention”.
- Letting a shared utilities package become a hidden coupling point.
- Running a cross-module flow as a distributed transaction when the modules should be one.
- Adding circular dependencies and papering over them with dynamic imports.
- Extracting a service before the module has a clean interface or owns its data.
- Putting business rules in controllers so modules cannot be tested in isolation.
- Treating the style as a temporary stop and never enforcing the boundaries.
- Assuming one deploy means one failure domain and skipping basic resilience work.
Where to go next
If a concrete pressure later justifies a split, the Microservices guide explains what you gain, what you pay and how to extract one module at a time. To organise the code inside each module, read Clean Architecture, and to decouple modules through facts rather than calls, read Event-Driven Architecture. When you need the storage patterns behind module ownership, PostgreSQL covers schemas, transactions and constraints.