Modular Monoliths: Strong Boundaries in One Deployment
Design a modular monolith with module ownership, public contracts, and enforced dependency rules while keeping one shared deployment and runtime.
Modular monoliths keep an application in one deployment while giving each capability an owner, public contract, and private implementation. This guide shows how to block cross-module imports and writes, handle workflows that span modules, and migrate boundaries with compatible schema changes. Use the pattern when shared runtime and release operations still fit, then consider service extraction only when independent scaling, release cadence, fault isolation, or regulation justify it.
Modular Monolith Architecture: Strong Boundaries in One Deployment
Introduction
An application can ship as one process and still have useful boundaries. The trouble starts when an order handler reaches into the catalog module’s private repository:
// Coupled: Orders knows Catalog's storage implementation.
import { catalogRepository } from "../catalog/catalogRepository";
const product = await catalogRepository.findById(productId);
Route the same request through Catalog’s public contract instead:
// Bounded: Orders depends on Catalog's supported API.
import { lookupProduct } from "../catalog";
const product = await lookupProduct(productId);
The modules still share a deployment, but Catalog can change its storage without exposing that change to Orders. This guide covers when that structure fits, how to enforce its contracts, and how to carve boundaries out of an existing application.
When to Use / When Not to Use
Use this structure when the product has distinct business capabilities but shared deployment, transactions, and local development are still useful. It fits teams that need clear ownership and want to defer the network, operations, and data consistency costs of service separation.
It is not the right choice when a module truly needs independent scaling, release cadence, fault isolation, or regulatory isolation and the team can operate that boundary. Nor does it suit code that has not yet developed meaningful domain boundaries; forcing modules too early can freeze guesses into APIs. A modular monolith is a deliberate deployment choice, not a promise that it will later become microservices.
Core concepts
Give each module an owner, a public API, and control over its data. Other modules call the public API or consume an explicitly published event; they do not reach into internal classes. Keep public contracts small and in the language of the capability, such as Orders.placeOrder, rather than exposing persistence tables.
Dependency direction needs enforcement. In a simple application, package boundaries and lint rules may suffice. In a larger TypeScript or Java system, separate packages, build modules, or architecture tests can reject imports that cross into internals. A shared database does not automatically invalidate modularity, but direct cross-module writes do: they erase ownership and make schema changes risky.
Mermaid diagram
flowchart LR
WEB[Web application] --> ORDERS[Orders module]
ORDERS --> CATALOG_API[Catalog public API]
CATALOG_API --> CATALOG[Catalog module]
ORDERS --> ORDER_DATA[(Orders-owned data)]
CATALOG --> CATALOG_DATA[(Catalog-owned data)]
SHIPPING[Shipping module] --> ORDERS_API[Orders public API]
ORDERS_API --> ORDERS
Every module runs inside the same application process and release, but its internal data and implementation remain private. Calls can be ordinary in-process calls; a network hop is not needed to make a boundary real.
Implementation / code example
One useful repository shape makes the public entry point visible and keeps internals out of import paths:
src/
modules/
orders/
index.ts # exported public API
placeOrder.ts # internal use case
orderRepository.ts
catalog/
index.ts # lookupProduct() contract
lookupProduct.ts
catalogRepository.ts
// modules/catalog/index.ts
export interface ProductSnapshot {
id: string;
unitPriceCents: number;
available: boolean;
}
export { lookupProduct } from "./lookupProduct";
// modules/orders/placeOrder.ts
import { lookupProduct } from "../catalog"; // public entry point only
export async function placeOrder(productId: string, quantity: number) {
if (!Number.isInteger(quantity) || quantity < 1) {
throw new Error("Quantity must be a positive integer");
}
const product = await lookupProduct(productId);
if (!product?.available) throw new Error("Product unavailable");
// Orders persists its own order record through its own repository.
return { productId, quantity, totalCents: product.unitPriceCents * quantity };
}
The example leaves inventory reservation unresolved on purpose: checking availability and then writing an order can race. A real system needs a reservation contract or a durable workflow that defines which module owns stock changes. The API boundary makes that decision visible; it does not make the business operation atomic by itself.
Enforcing module boundaries
Make the public entry point the only supported import path. Keep internal files out of package exports, then add an architecture test or import-lint rule that rejects imports such as ../catalog/catalogRepository from another module. Run that check in CI so a shortcut cannot merge unnoticed. In code review, treat changes to a module’s public API like changes to any other interface: callers should not need to know which tables or classes implement it.
The right enforcement depends on the repository. A small codebase can use a short architecture test; a larger one can use package boundaries or a dependency rule tool such as dependency-cruiser. Start by blocking cross-module access to internals. Add stricter rules for shared utilities or cycles only when those risks appear. If modules share a database server, separate schemas or database roles can reinforce ownership, but application rules still need to prohibit one module from writing another module’s records.
Migrating an existing application
Extract one capability at a time. First map its callers and data writes, then define the public operations it needs to expose. Move implementation behind that API while keeping existing routes and user behavior in place. Once callers use the contract, move tables or schemas only if ownership is still unclear; schema movement adds risk and is not required just to create a module boundary.
Deploy schema changes with an expand-and-contract sequence: add the new shape, make the owning module write it, migrate existing records, switch readers, then remove the old shape after all callers have moved. Keep each release compatible with the previous one so a rollback does not restore code that expects a deleted column. For workflows that span modules, decide whether they need one database transaction, a reservation, or an asynchronous process before splitting the writes across APIs.
This migration keeps one release unit. A deployment still ships every module, and a bad migration or process-wide resource spike can affect the whole application. That is usually cheaper than coordinating independent services, but it means module-level ownership does not provide independent rollback or fault isolation.
Trade-off table
| Choice | Benefit | Cost |
|---|---|---|
| One deployment | Simple local runs, release, and in-process calls | All modules share runtime and release risk |
| Module-owned data | Schema changes have a clear owner | Cross-module reads require explicit contracts or projections |
| Public APIs only | Refactoring internals is safer | API design and mapping take deliberate effort |
| Shared process | No network call for every module interaction | A crash or resource exhaustion can affect all modules |
Production failure scenarios + mitigations
A team bypasses an API and queries another module’s table. The shortcut becomes an undocumented contract. Restrict database access where practical, add import/schema checks, and replace the query with a supported API or read model.
A synchronous module call creates a long chain. Latency and failure propagate across capabilities. Measure call depth, set timeouts where external I/O is involved, and consider an event or explicit workflow for naturally asynchronous work.
A migration breaks another module. This usually reveals shared ownership. Coordinate expand-and-contract schema changes, test module contracts, and move access behind the owning module before changing the schema.
A cross-module workflow updates one capability but not the next. For example, an order may be recorded while inventory reservation fails. Decide whether the operation needs one local transaction or a durable workflow with retry and compensation; do not hide partial success behind a method that looks atomic.
Observability checklist
- Add module and operation names to traces while preserving one request trace across in-process calls.
- Track latency and error rates by public module operation.
- Monitor database pool pressure and slow queries, which affect the whole process.
- Record deployment version and migration outcome for incident diagnosis.
- Audit asynchronous events with event IDs, consumer outcomes, and retry/dead-letter counts.
Security/compliance notes
Module boundaries are not automatically security boundaries: code in one process may share credentials and memory. Apply authorization at the use case, restrict database roles or schemas when useful, and avoid passing unnecessary personal data across module APIs. Keep audit events owned and retained according to the relevant business process. If policy requires process-level isolation, a modular monolith alone cannot satisfy it.
Common pitfalls / anti-patterns
- Using module names as folders while allowing unrestricted imports.
- Treating one shared
commonpackage as a place for every model and helper. - Letting multiple modules write the same tables.
- Publishing internal persistence entities as stable module contracts.
- Extracting services merely because a module exists, before independent operations justify the cost.
- Assuming one process means module failures cannot affect other capabilities.
Quick Recap Checklist
- Does each module have an owner and a narrow public API?
- Are private imports and cross-module writes blocked or detectable?
- Does each module own its data changes?
- Are cross-module workflows and race conditions explicit?
- Is one deployment still the right operational boundary?
Interview Questions
A modular monolith keeps modules in one process and deployment, usually communicating through in-process contracts. Microservices add independent deployment and network boundaries, along with distributed operations and consistency concerns.
Clear ownership, a public contract, private internals, and dependency rules that are checked by the build or review process. Naming a folder is not enforcement.
They can share one database server, but modules should own their tables or schemas and avoid direct writes into one another's data. A shared database must not become an excuse for hidden coupling.
Expose a module entry point, then enforce allowed imports with an architecture test, package exports, or an import-lint rule in CI. The check should reject private paths while allowing callers to use the public contract.
No. A module needs clear ownership of its data changes. Separate schemas or database roles can strengthen that rule, but a shared database can work when other modules access the data through an owner-approved contract.
Choose the consistency model explicitly. Use one local transaction when both writes share the same transactional boundary; use a reservation or durable workflow with retries and compensation when partial completion is possible.
Start with one capability, map its callers and data writes, define its public operations, and route callers through that contract. Move data only when ownership requires it; changing code boundaries does not require an immediate database migration.
It lets old and new application versions work during deployment. Add the new shape, migrate writes and readers, then remove the old shape only after callers have moved, so rollback does not restore code that depends on deleted data.
No. A modular monolith has one release unit. Module boundaries improve code ownership and change safety, while independent deployment and rollback require separate deployable services.
Consider extraction when a capability needs independent scaling, release timing, fault isolation, or regulatory controls and the team can operate the new boundary. A module folder alone is not evidence that a service is needed.
Further Reading
- Software Architecture Patterns Roadmap maps this pattern against other architecture choices.
- Microservices vs. Monolith compares the deployment and operating trade-offs of the two approaches.
- Architecture Testing with Fitness Functions covers automated checks that can enforce module boundaries.
- Martin Fowler’s MonolithFirst discussion explains why a monolith can be a sensible starting deployment shape.
- Martin Fowler’s microservices article outlines the trade-offs of independently deployed services.
Conclusion
A modular monolith combines one deployment with meaningful internal ownership. It is a good fit when teams need structure but do not yet need the network and operational costs of separate services. Make the contracts visible, enforce dependency rules, and treat data ownership as part of the boundary. That gives the codebase room to change without claiming that deployment topology has solved every design problem.
Category
Related Posts
Clean and Onion Architecture: Keeping Policy at the Center
Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.
Hexagonal Architecture: Ports, Adapters, and Testable Cores
Learn hexagonal architecture through ports, adapters, and inward dependencies, with practical code, trade-offs, failure modes, and security guidance.
Layered Architecture: A Guide to Responsibility Tiers
Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.