Short answers to what readers ask most about this topic.
01What is a modular monolith?
A modular monolith is a single deployable application whose code is divided into modules with enforced boundaries. Each module exposes a small public API, hides its internals and owns its own data. Modules call each other through facades or in-process events rather than reaching into each other's code or tables.
02Is a modular monolith better than microservices?
It is better until you have a specific constraint that only separate services solve, such as a module with a very different scaling profile or teams that need independent releases. Until then it avoids the network, pipelines and per-service databases that microservices add. Martin Fowler's monolith-first argument makes the same case: boundaries are hard to get right early, and moving them is cheaper inside one codebase.
03How do you enforce module boundaries in a TypeScript project?
Combine three layers. Use ESLint's no-restricted-imports rule with a group of patterns to block deep imports into other modules, use dependency-cruiser's forbidden rules in CI to check the real dependency graph, and use NestJS modules so unexported providers cannot be injected. Then commit a deliberate violation on a throwaway branch to confirm the build fails.
04Should each module have its own database schema?
Yes, if you want the boundary to mean anything. A shared table is stronger coupling than an import, so give each module its own Postgres schema and database role. PostgreSQL requires USAGE on a schema before a user can touch its objects, so the grants become the wall that code review alone cannot provide.
05How do you split a modular monolith into microservices later?
Pick the module that has a real reason to leave, put a routing layer in front and replace its facade with a client that calls the new service. Move its schema to become the service's own database and keep a flag so you can route back. This is the strangler fig approach, and it is only cheap because the module already had a facade and its own data.
Modular Monolith Architecture: When It Beats Microservices
What a modular monolith is and when it beats microservices: one deployable, a public API per module, a Postgres schema per module and lint-enforced boundaries.
A modular monolith is one deployable application split into modules that expose a small public API, own their own database schema and talk through in-process calls or events, with boundaries enforced by tooling. It beats microservices until independent scaling, release cadence or team ownership genuinely forces a split.
Most of the pain people blame on monoliths comes from one thing: every file can import every other file, and every query can join every table. The deployment is not the problem. The missing walls are. A microservice split adds walls, but it adds a network, a pipeline and a database per service along with them.
This post covers the cheaper way to get the walls: a modular monolith. It walks through the folder layout, the public API of a module, schema-per-module in Postgres, in-process events, the lint and dependency rules that make the boundaries mechanical, a worked cost comparison, and the point where microservices really do win. I work on ERP and POS backends in NestJS and Postgres on single VPS boxes, so the cost of every extra container is something I weigh directly. The tool options are checked against the ESLint, NestJS, PostgreSQL and dependency-cruiser documentation.
What is a modular monolith?
A modular monolith is one codebase that builds into one deployable, divided internally into modules with hard boundaries. Each module owns a business capability such as orders, billing or inventory, hides its internals, and offers other modules a deliberately small public surface. It is the same idea as the classic modular programming discipline, applied to a whole application.
The word that matters is enforced. A folder called modules with nothing stopping cross imports is just a monolith with nicer folders. Martin Fowler's monolith-first argument rests on the same point: boundaries are hard to get right at the start and refactoring across services is much harder than inside a monolith, so learn where the lines belong while moving them is still cheap. A modular monolith keeps the option to extract later without paying for it today.
What does a module look like, and what is its public API?
Each module is a folder with one front door. Everything outside that door is private. In plain TypeScript the front door is an index.ts barrel that re-exports only what other modules may use. In NestJS the same idea is built into the framework: a module encapsulates its providers by default, and only the providers listed in its exports array can be injected elsewhere.
src/
main.ts
app.module.ts # imports each module's public Module, nothing else
shared/
kernel/ # ids, Money, the event bus: tiny, no business rules
modules/
orders/
index.ts # THE public API (the barrel)
orders.module.ts
orders.facade.ts # the only class other modules may call
events.ts # OrderPlaced, OrderCancelled (published language)
internal/
orders.service.ts
orders.repository.ts # reads/writes the "orders" schema only
order.entity.ts
billing/
index.ts
billing.module.ts
billing.facade.ts
internal/...
inventory/
index.ts
inventory.module.ts
inventory.facade.ts
internal/...
The layout below is an illustrative ERP-style example, not a template to copy blindly. The facade is the only class other modules ever call. The repository, the entities and everything under internal stay invisible, which means you can rewrite them without a single other module noticing.
// modules/orders/orders.module.ts
@Module({
imports: [InventoryModule], // depends on inventory's PUBLIC module
providers: [OrdersFacade, OrdersService, OrdersRepository],
exports: [OrdersFacade], // only the facade can be injected elsewhere
})
export class OrdersModule {}
// modules/orders/index.ts (the barrel: everything not listed here is private)
export { OrdersModule } from "./orders.module";
export { OrdersFacade } from "./orders.facade";
export type { OrderPlaced } from "./events";
// Deliberately NOT exported: OrdersService, OrdersRepository, order.entity
Export a facade, not the service. A facade with five methods named after business actions is easier to keep stable than a service with thirty methods that grew organically.
Why should each module own its own database schema?
Shared tables are the strongest coupling in any monolith, stronger than imports. The moment billing joins directly against the orders table, billing depends on the column layout of orders, and the public API means nothing. The fix is to give every module its own Postgres schema and its own database role, and to let a module reach another module's data only through that module's facade.
PostgreSQL supports this directly. The documentation says users cannot access objects in schemas they do not own unless the owner grants USAGE on the schema, and that schemas are not rigidly separated, so a user with privileges can reach any schema in the database. That second sentence is why you need roles and not just naming conventions: the privileges are the wall. The script below is a minimal sketch for two modules.
-- One schema and one role per module.
CREATE SCHEMA orders;
CREATE SCHEMA billing;
CREATE ROLE orders_app LOGIN PASSWORD 'change-me';
CREATE ROLE billing_app LOGIN PASSWORD 'change-me';
-- Each role gets USAGE (and CREATE for migrations) on its OWN schema only.
GRANT USAGE, CREATE ON SCHEMA orders TO orders_app;
GRANT USAGE, CREATE ON SCHEMA billing TO billing_app;
-- Unqualified table names resolve to the module's own schema.
ALTER ROLE orders_app SET search_path = orders;
ALTER ROLE billing_app SET search_path = billing;
-- Close the default gap: new objects must not land in public.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
-- Now this fails with "permission denied for schema orders":
-- SET ROLE billing_app; SELECT * FROM orders.orders;
-- Billing has to ask OrdersFacade instead.
The public schema is special. By default everyone has USAGE on it, and in databases upgraded from PostgreSQL 14 or earlier everyone also has CREATE. Keep module tables out of public and revoke the default privileges, or the wall has a gap in it.
How do modules talk to each other without network calls?
There are two in-process options. A direct call through the other module's facade is right when you need an answer now, such as asking inventory whether 3 units are available. A domain event is right when you are announcing that something happened and do not care who reacts, such as OrderPlaced. Billing and inventory subscribe; orders never imports either of them.
// shared/kernel/event-bus.ts
type Handler<E> = (event: E) => Promise<void> | void;
export class InProcessBus {
private handlers = new Map<string, Handler<unknown>[]>();
on<E>(type: string, handler: Handler<E>): void {
const list = this.handlers.get(type) ?? [];
list.push(handler as Handler<unknown>);
this.handlers.set(type, list);
}
async publish<E>(type: string, event: E): Promise<void> {
// Sequential on purpose: one failing handler is visible, not swallowed.
for (const handler of this.handlers.get(type) ?? []) {
await handler(event);
}
}
}
// billing subscribes; orders never imports billing.
bus.on<OrderPlaced>("order.placed", (e) => billing.createInvoice(e.orderId));
The sketch below is a typed in-process bus, about twenty lines. Its limit is durability: the event lives in memory, so if the process dies between the database commit and the publish, the event is lost. For side effects that must not be lost, write the event to an outbox table in the same transaction, as covered in the strangler fig migration post, and the same table later becomes your bridge to a message broker.
How do you enforce module boundaries mechanically?
Convention decays under deadline pressure, so make the build fail instead. The cheapest layer is ESLint's no-restricted-imports rule. Its patterns option takes objects with a group array of gitignore-style patterns and a message, and the documentation says a negated pattern such as an exclamation mark entry re-includes a path and must come last. That lets you write one rule per module: block deep imports into every module except your own.
// eslint.config.mjs (flat config), one block per module
const MODULES = ["orders", "billing", "inventory"];
export default MODULES.map((self) => ({
files: ["src/modules/" + self + "/**/*.ts"],
rules: {
"no-restricted-imports": ["error", {
patterns: [{
// Block every deep import under modules/, then re-include our own module.
// Negated patterns must come last, or the re-include is ignored.
group: ["@/modules/*/**", "!@/modules/" + self + "/**"],
message: "Import another module through its index.ts barrel only.",
}],
}],
},
}));
The second layer is dependency-cruiser, which analyses the real dependency graph rather than import strings. A rule in its forbidden array has a name, a severity, a from section and a to section. Put the module name in a capture group in from, then use the matching group variable in to with pathNot so the rule fires only on imports that cross a module boundary. The third layer is NestJS itself, where an unexported provider simply cannot be injected into another module.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-deep-cross-module-import",
comment: "A module may only touch another module's index.ts",
severity: "error",
// Capture the module name in group 1 ...
from: { path: "^src/modules/([^/]+)/" },
to: {
// ... block any file under a module folder except its index.ts ...
path: "^src/modules/[^/]+/(?!index\\.ts$)",
// ... unless it is the importer's own module ($1 = group from "from").
pathNot: "^src/modules/$1/",
},
},
],
};
// CI: npx depcruise --config .dependency-cruiser.cjs src
Run the lint in your editor for fast feedback and dependency-cruiser in CI as the backstop. Then prove the walls work: commit a deliberate violation on a throwaway branch and confirm CI goes red. A rule that has never failed is a rule you have not tested.
no-restricted-imports matches the import specifier as written, so a relative path such as dot dot slash billing slash internal can slip past a rule written for an alias. Either ban parent-relative imports across modules too, or rely on dependency-cruiser, which resolves the actual file.
How much does it save compared with microservices?
The saving is operational, and you can compute it. Take a system with 6 modules. As a modular monolith that is 1 deployable and 1 pipeline. As microservices it is 6 of each, plus the network between them. With 6 services the number of possible pairwise links is 6 times 5 divided by 2, which is 15 connections that can each time out, be misconfigured or drift out of version.
Concern
Modular monolith
Microservices
Deployables and CI pipelines
1 and 1
6 and 6
Call between two modules
A function call in the same process
A network request that can fail or time out
Possible pairwise links (6 modules)
0 network links
Up to 15 links, from 6 times 5 divided by 2
Database
One server, 6 schemas
Typically 6 databases if you follow database per service
Baseline memory, assuming 150 MB per Node process
150 MB
900 MB, about 44 percent of a 2 GB VPS
Change that spans two modules
One commit, one deploy, one transaction if you really need it
Coordinated releases, plus a saga or outbox for consistency
The 150 MB figure is an assumption for illustration, not a measurement; measure your own idle process before trusting the percentage. The arithmetic still holds: 6 times 150 is 900, and 900 divided by 2048 is roughly 0.44. Availability compounds too. If a request must pass through 4 synchronous services that are each 99.9 percent available and fail independently, the combined figure is 0.999 to the power of 4, about 99.6 percent, which is roughly four times the downtime of a single 99.9 percent process.
This is Fowler's microservice premium in numbers. It is worth paying when the benefits below apply to you. It is a tax when they do not.
When do microservices genuinely win?
Microservices win when the constraint is organisational or physical, not aesthetic. A modular monolith scales by running more identical copies of the whole thing, and that stops being efficient in a few specific situations.
One module needs a different scaling profile, such as a CPU-heavy report renderer or an image pipeline that would force you to scale the entire application to relieve one hot path.
Independent teams need independent release cadences, and waiting on a shared deploy is costing real delivery time rather than just feeling untidy.
A module needs a different runtime or failure domain, for example a model-serving component in another language, or a payment path that must stay up while the rest is deployed.
Compliance or blast-radius rules require hard isolation of one capability, including its own credentials and its own data store.
Notice that none of these says the code is getting big. Size alone is solved by modules. If your reason is that the monolith feels messy, fix the boundaries first; splitting a tangled system only turns tangled function calls into tangled network calls.
How do you decide, and extract a module later if you must?
Extraction is the payoff for the discipline above. A module with a facade, its own schema and event-based coupling can be lifted out with the strangler fig approach: put a routing layer in front, replace the facade implementation with a client that calls the new service, and move the schema as the service's own database. The sibling post on the strangler fig pattern walks through that procedure. Without the walls, the same extraction is a multi-month untangling job first. For the broader trade-off, see the earlier post on microservices versus monolith, and for the folder side of the same idea see the Next.js project structure post.
Use this checklist before splitting anything. If you cannot tick the first box for a module, extraction is not ready.
The module is reachable only through its facade and published events, and CI proves it.
No other module reads or writes its schema, confirmed by database roles and not by habit.
You can name the concrete constraint: a scaling profile, a release cadence, a runtime or a compliance rule.
A team will own the new service end to end, including on-call and its pipeline.
The cost table above still comes out in favour of splitting once you add your real per-service overhead.
You have a rollback plan, such as a routing flag that sends traffic back to the in-process facade.
Build the walls first and decide on the network later. A modular monolith gives you the structure that makes microservices possible, at a fraction of the operating cost, and it keeps the extraction option open for the one module that earns it. Split only when you can name the constraint, not when the codebase merely feels large.