A team rarely wakes up wanting a distributed system. It usually arrives there one reasonable decision at a time: a slow release needs an independent deployment, a hot path needs separate scaling, or one database has become politically difficult to change. Soon, an operation that used to be a function call crosses a network, carries a trace ID, retries after timeouts, and occasionally succeeds after its caller has given up.
The opposite failure is familiar too. A single application begins cleanly, then every feature reaches into every table and helper. Changing tax calculation breaks checkout; a reporting query locks order writes; nobody knows which team may alter the customers table. The problem is not that the process is monolithic. The problem is that the design is.
A modular monolith keeps one deployment unit while dividing the code into explicit business modules. Each module owns its data and behavior, publishes a small in-process API, and declares which other modules it may depend on. It does not provide the operational isolation of microservices, but it captures much of their architectural discipline without paying the network and platform tax on day one.
The useful choice is where boundaries live
“Monolith” describes deployment topology, not code quality. A well-structured application can run as one process; a badly structured service fleet can still be a distributed monolith. The useful questions are more concrete: Who owns this state? Through which contract may it change? In which direction may dependencies point? What can fail independently?
The three common shapes differ less in diagrams than in what they force teams to manage:
| Concern | Layered monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployment | One artifact | One artifact | Many independently released artifacts |
| Boundaries | Often technical layers | Business modules with enforced APIs | Process and network boundaries |
| Data | Common schema, broadly accessible | Owned tables or schemas per module | Database ownership per service |
| Calls | Direct internal calls | Calls through in-process contracts | Remote calls with partial failure |
| Transactions | Easy to span everything | Local by default; explicit workflows across modules | Local only; sagas or outbox across services |
| Scaling | Whole application | Whole application | Per service |
| Operations | Lowest overhead | Low to moderate overhead | Highest observability and platform burden |
The modular monolith is not a temporary embarrassment on the road to services. For many products it is the stable destination: one runtime, one release pipeline, straightforward debugging, and strong conceptual boundaries. Its limitation is equally clear. A memory leak, CPU spike, or bad deployment can affect the entire application, and modules cannot be scaled independently.
Tip
Choose a modular monolith for the system you can operate now, then preserve options with ownership and contracts. Do not buy distributed-systems complexity merely to demonstrate architectural ambition.
Good boundaries normally follow business capabilities such as Catalog, Billing, and Orders, not technical categories such as Controllers, Services, and Repositories. A capability can change internally without forcing unrelated capabilities to move with it. That is the practical test of cohesion.
Make every module an owner, not a folder
A directory tree is only documentation until access is constrained. A real module owns decisions, code, and data. Orders may own order state and its tables; Billing owns payment attempts and refunds; Catalog owns products and prices. Other modules may ask Orders to cancel an order, but they cannot update an order row or import its repository.
Physical data separation does not require three database servers. PostgreSQL schemas, table-name prefixes, or separate migration directories can make ownership visible inside one database. Credentials can reinforce it where practical. The important rule is behavioral: only a module’s code reads and writes its tables. A cross-module report should use a published read model, a replicated projection, or an explicitly governed analytical query rather than turning private schemas into a shared API.
The public surface should describe business operations and stable data, not expose persistence machinery. Here is a compact Orders module. Consumers can construct and call it through public.ts; its repository and orchestration remain private.
// src/modules/orders/public.ts
import { buildOrders } from './internal/build-orders.js';
export type OrderId = string & { readonly orderId: unique symbol };
export interface PlaceOrder {
customerId: string;
lines: ReadonlyArray<{ productId: string; quantity: number }>;
}
export interface OrderView {
id: OrderId;
status: 'pending' | 'confirmed' | 'rejected';
totalCents: number;
}
export interface Orders {
place(command: PlaceOrder): Promise<OrderView>;
find(id: OrderId): Promise<OrderView | undefined>;
}
export interface OrdersDependencies {
prices: {
quote(lines: PlaceOrder['lines']): Promise<number>;
};
payments: {
authorize(customerId: string, amountCents: number): Promise<boolean>;
};
transaction<T>(work: () => Promise<T>): Promise<T>;
}
export function createOrders(dependencies: OrdersDependencies): Orders {
return buildOrders(dependencies);
}
// src/modules/orders/internal/build-orders.ts
import type {
OrderView,
Orders,
OrdersDependencies,
PlaceOrder,
} from '../public.js';
import { createOrderRepository } from './order-repository.js';
export function buildOrders(dependencies: OrdersDependencies): Orders {
const repository = createOrderRepository();
return {
async place(command: PlaceOrder): Promise<OrderView> {
const totalCents = await dependencies.prices.quote(command.lines);
const authorized = await dependencies.payments.authorize(
command.customerId,
totalCents,
);
return dependencies.transaction(async () =>
repository.insert({
command,
totalCents,
status: authorized ? 'confirmed' : 'rejected',
}),
);
},
find: (id) => repository.find(id),
};
}
The dependency interfaces belong to Orders because they express what Orders needs, not everything Catalog or Billing can do. At the composition root, adapters translate those narrow ports to the other modules’ public APIs. This keeps an internal refactor from rippling across the application and makes tests cheap: a test can supply two small fakes without booting the entire process.
In-process does not mean unversioned or casual. Contracts still deserve deliberate names, immutable inputs, explicit errors, and compatibility discipline. The advantage is that the compiler checks them and calls do not need serialization, retries, authentication between services, or network telemetry.
Enforce dependency direction in the build
Architecture that depends on memory decays under deadline pressure. Decide the allowed graph, then make violations fail locally and in CI. In the diagram above, Orders may depend on Catalog and Billing, while those modules know nothing about Orders. Cycles are rejected because they make ownership and initialization ambiguous.
The following TypeScript script uses the compiler parser rather than a regular expression. It checks every relative cross-module import: the target must be public.js, and the edge must appear in the allowlist.
// scripts/check-module-dependencies.ts
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
const modulesRoot = path.resolve('src/modules');
const allowed = {
catalog: [],
billing: [],
orders: ['catalog', 'billing'],
} as const;
type ModuleName = keyof typeof allowed;
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(entryPath);
return entry.name.endsWith('.ts') ? [entryPath] : [];
}),
);
return nested.flat();
}
function moduleName(file: string): ModuleName | undefined {
const [name] = path.relative(modulesRoot, file).split(path.sep);
return name in allowed ? (name as ModuleName) : undefined;
}
function importedFile(source: string, specifier: string): string | undefined {
if (!specifier.startsWith('.')) return undefined;
return path.resolve(path.dirname(source), specifier.replace(/\.js$/, '.ts'));
}
const violations: string[] = [];
for (const file of await sourceFiles(modulesRoot)) {
const sourceModule = moduleName(file);
const text = await readFile(file, 'utf8');
const ast = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
ast.forEachChild((node) => {
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return;
const targetFile = importedFile(file, node.moduleSpecifier.text);
const targetModule = targetFile && moduleName(targetFile);
if (!sourceModule || !targetModule || sourceModule === targetModule) return;
const publicOnly = path.basename(targetFile) === 'public.ts';
const directionAllowed = (allowed[sourceModule] as readonly string[]).includes(targetModule);
if (!publicOnly || !directionAllowed) {
violations.push(`${path.relative('.', file)} -> ${node.moduleSpecifier.text}`);
}
});
}
if (violations.length > 0) {
console.error(`Module boundary violations:\n${violations.join('\n')}`);
process.exitCode = 1;
}
Run this check beside type checking and unit tests. Larger codebases may use dependency-cruiser, Nx tags, ESLint boundaries, or language-level visibility, but the principle is the same: make the intended graph executable. Also review the allowlist itself. A green build can still encode a poor architecture if every module is allowed to depend on every other module.
Keep transactions local and workflows explicit
A shared database makes it technically easy to wrap Orders, Billing, and Inventory changes in one ACID transaction. Resist making that the default. A transaction that crosses ownership boundaries couples schemas, lock behavior, failure handling, and release timing. It also creates a misleading contract that cannot survive later extraction.
Use a local transaction for invariants owned by one module: creating an order and its lines, recording a payment and its ledger entry, or changing stock while appending an inventory movement. For a workflow across modules, choose semantics explicitly. A synchronous in-process call is appropriate when the caller needs an immediate answer, as in a price quote. An event is better when consumers can react after commit, such as sending confirmation or updating a search projection.
For reliable events, write the module’s state and an outbox record in the same local transaction. A background publisher delivers the event and records progress; consumers are idempotent. Inside a monolith, the publisher may invoke handlers in the same process. That can later change to a broker without redefining the business event or pretending that exactly-once delivery exists.
Warning
Do not imitate microservices by adding an in-memory event bus for every interaction. Hidden asynchronous control flow is harder to debug than a function call. Use events for genuine decoupling and post-commit reactions, not to avoid naming dependencies.
Longer workflows need compensation and visible state. If payment succeeds but stock reservation fails, the order workflow can move to refund_pending, request a refund, and resume safely after a crash. The same state machine is useful whether all handlers share a process or eventually cross a network.
Test modules independently, deploy the whole
The test strategy should reflect both boundaries and runtime reality. Unit tests exercise domain rules with plain values. Module tests call the public API against a real repository implementation, often using a disposable database schema, and verify that private tables change correctly. Contract tests pin assumptions between a consuming port and the adapter at the composition root. A smaller set of application tests boots the assembled monolith and follows critical workflows across modules.
Avoid asserting private class calls or importing internal repositories from another module’s tests. Those tests reward coupling. Prefer observable behavior at the public API, plus direct private tests located inside the owning module when an algorithm deserves focused coverage. Add an architecture test for forbidden imports and, if possible, database permissions or migration checks that prevent accidental table ownership violations.
Deployment remains intentionally boring: build one artifact, run all migrations in a controlled order, deploy it, and monitor it as one application. Module labels should still appear in logs, metrics, and traces so a latency or error increase can be attributed to Billing rather than to a generic endpoint. Feature flags and backward-compatible migrations reduce coordination risk even though there is one release train.
One artifact does not mean one replica. The monolith can run behind a load balancer and process jobs in separate modes from the same codebase. What it cannot do cleanly is give one module a different runtime, security boundary, release cadence, or scaling curve. When those needs become material rather than hypothetical, extraction becomes an economic decision instead of a fashion choice.
Extract only after the boundary earns a network
A well-designed module is a candidate service, not a service waiting to happen. Extraction starts by preserving its public contract and replacing the in-process adapter at the composition root with a remote client. Its owned tables move behind the new service. Outbox events become broker messages, tracing crosses the new hop, and callers gain deadlines, retries, circuit breaking, idempotency, and fallbacks. The business boundary survives; the operational contract becomes much more demanding.
Extraction is rarely mechanical. In-process contracts may contain rich types or assumptions about low latency that make poor wire protocols. Workflows that relied on one database need explicit compensation. Reports need projections rather than joins. The modular monolith makes these dependencies visible early; it does not eliminate the work.
Microservices are justified when independent deployment removes a real organizational bottleneck, when one capability has a sharply different scale or availability profile, when regulatory isolation is required, or when autonomous teams can truly own build, data, operations, and incidents end to end. They are harder to justify for a small team, an uncertain domain, low traffic, or a product whose features change together.
Organizational fit matters as much as code. A modular monolith works well when teams can agree on ownership and share a release pipeline. It fails when modules are merely folders, seniority is measured by cross-cutting cleverness, or deadlines routinely excuse private imports. It also fails when a central architecture group owns all contracts while product teams lack authority to evolve their modules.
Watch for characteristic failure modes: a shared common module that becomes a dependency sink; direct cross-module table reads; APIs that expose ORM entities; bidirectional calls; global events with no owner; and a composition root filled with business logic. Each is evidence that a decision has escaped its owner. Fix the ownership before drawing a more elaborate deployment diagram.
Takeaways
A modular monolith separates two decisions that are too often bundled together: how code and data are divided, and how software is deployed. Start with business-capability modules. Give each one exclusive data ownership, a narrow public API, and an explicit place in an acyclic dependency graph. Enforce those rules with tools, not conventions alone.
Keep invariants and transactions inside one owner. Model cross-module work as visible synchronous contracts or durable workflows, depending on its semantics. Test modules through their public surfaces, then test the assembled application where integration risk is real. Deploy one observable artifact until independent scaling, isolation, or team autonomy is valuable enough to pay for networks and distributed failure.
The middle ground is pragmatic because it does not assume the future is knowable. It gives a team the simplicity to ship now, enough structure to keep changing, and boundaries sturdy enough to support a service extraction when evidence finally calls for one.