
Modular Monolith with NestJS: A Practical Architecture Guide
- NestJS
- modular monolith
- bounded modules
- domain events
- transactions
- architecture testing
- microservices

Modular Monolith with NestJS: A Practical Architecture Guide
A modular monolith is one deployable application composed of business modules whose boundaries matter. It keeps the operational advantages of one process—local calls, straightforward debugging, atomic database transactions, and a single deployment pipeline—while limiting the coupling that makes a conventional monolith increasingly expensive to change.
That combination is a strong default for many products. A team can discover the domain and move quickly without paying for network failure, distributed data, fleet operations, and cross-service observability before those costs create value. The word “modular,” however, must describe enforceable ownership. A directory tree and a set of NestJS decorators do not prevent one feature from importing another feature’s repository.
NestJS provides useful mechanics: modules encapsulate providers by default, imports connect modules, and exports define the providers available to consumers. Architecture still comes from decisions about capability, language, public contracts, data ownership, transactions, dependency direction, and change authority. When those decisions are explicit and tested, the monolith can remain healthy for years and offer an honest path to later extraction.
Start with Business Boundaries, Not Folders
Discover modules from business capabilities and invariants, not horizontal technical layers. Orders, Billing, Inventory, Fulfilment, and Identity are plausible boundaries because they answer different business questions and change for different reasons. A module should own the use cases, rules, terminology, persistence mapping, and migrations required to protect its decisions. Controllers and message handlers are adapters; repositories are private mechanisms.
Run event-storming or focused domain workshops with people who know the work. Identify commands, completed facts, policies, conflicts in language, consistency requirements, and who approves change. A term such as “customer” may mean a purchaser in Orders, a legal account in Billing, and an identity in Access. Forcing one shared model can create more coupling than translating identifiers at a boundary.
Assign every table, queue, scheduled job, cache key, and externally visible contract to one owning module. Another module may store an identifier or a local snapshot, but it must not update the owner’s tables or import its ORM entity. Shared database infrastructure is acceptable; shared ownership is not. For multi-tenant systems, the same boundary must preserve tenant isolation described in a NestJS multi-tenant architecture.
Keep genuinely generic mechanisms—configuration, clocks, structured logging, tracing, cryptography primitives, and database connection setup—in a small platform layer with no business policy. Resist a universal “shared” folder. If a value object is tiny and stable, duplication can be cheaper than creating a dependency that synchronizes unrelated release decisions.
Give Every Module a Small Public API
Treat a module’s exports as an architectural surface, not a convenience list. NestJS encapsulates providers unless a module exports them, so expose only purpose-specific facades, command or query ports, and stable contract tokens. Keep controllers, handlers, repositories, ORM entities, transaction helpers, and internal services private. Avoid global modules for business capabilities because invisible availability erases dependency direction.
A public operation should express business intent: place an order, reserve stock, quote a price, or retrieve an approved read model. Do not return a query builder or persistence entity that lets callers compose private storage behavior. Use transport-neutral request and response contracts, validate at the delivery boundary, and map into domain values. Callers should depend on what the module promises, not how it implements the promise.
Choose synchronous calls and events by meaning. When the caller cannot decide without an immediate answer, invoke the owner through its public port. When another module merely reacts after a committed fact, publish a past-tense domain or integration event. Events should not disguise a request that needs a result, and a synchronous facade should not become an unrestricted back door into another module.
Version public surfaces with the discipline of an external API. Make additive changes where possible, add characterization and contract tests before changing behavior, and deprecate with usage evidence. This boundary is also what enables an incremental Express-to-NestJS migration: old and new adapters can call the same application port while implementation moves behind it.
// orders/public/orders.port.ts
export abstract class OrdersPort {
abstract place(command: PlaceOrder): Promise<PlacedOrder>;
abstract find(id: string): Promise<OrderView | null>;
}
// orders/orders.module.ts
@Module({
imports: [OrdersPersistenceModule],
controllers: [OrdersController],
providers: [
PlaceOrderHandler,
FindOrderHandler,
{ provide: OrdersPort, useClass: NestOrdersFacade },
],
exports: [OrdersPort], // the complete public module API
})
export class OrdersModule {}
// Billing imports OrdersPort; it never imports OrdersRepository,
// OrderEntity, or a handler from an orders/internal path.Keep Transactions Local and Publish Events Reliably
Place every strong consistency invariant inside one module and one local transaction. If placing an order must validate its lines and total, the Orders module should own those values and write them atomically. A transaction that freely edits Orders, Inventory, and Billing tables gives the appearance of safety while binding their schemas and release decisions together.
Cross-module workflows need explicit trade-offs. Reserve stock synchronously when an order cannot be accepted without the answer. Use a durable process with retries and compensation when steps can complete independently. Define idempotency, timeout, partial failure, operator intervention, and what the user sees. CQRS, sagas, or workflows can organize complexity, but they should follow a real process rather than decorate simple CRUD.
In-memory events are useful for best-effort reactions but can disappear during a crash or deploy. Publishing to a broker after commit leaves a gap between database success and message delivery; publishing before commit can announce state that later rolls back. For an integration event that must survive, insert an outbox record in the same transaction as the owning state. A worker claims, publishes, and marks it while recording attempts and lag.
Assume at-least-once delivery. Give each message a stable ID, event type and schema version, aggregate identity, occurrence time, correlation data, and bounded payload. Consumers record processed IDs or make their writes naturally idempotent. Monitor unpublished age, attempt count, dead letters, consumer lag, duplicates, and reconciliation. The same guarantees apply whether the event feeds an internal module, BullMQ job, or a wider event-driven NestJS system.
await dataSource.transaction(async (manager) => {
const order = Order.place(command);
await manager.getRepository(OrderEntity).save(mapOrder(order));
await manager.getRepository(OutboxMessage).insert({
id: randomUUID(), // stable consumer idempotency key
type: 'orders.order-placed.v1',
aggregateId: order.id,
payload: JSON.stringify({
orderId: order.id,
customerId: order.customerId,
totalMinor: order.totalMinor,
}),
occurredAt: new Date(),
publishedAt: null,
});
});
// A worker claims unpublished rows, publishes them, and records delivery.
// Retries can create duplicates, so consumers must be idempotent.Turn Dependency Rules into Executable Architecture
Architecture documentation explains intent; automated rules preserve it under deadline pressure. Define allowed dependency directions and public entry points. Fail continuous integration when a module imports another module’s internal path, domain code imports NestJS or ORM packages, a circular dependency appears, or an application layer reaches directly into another owner’s persistence.
Use compiler and lint rules, dependency-graph tools, and focused architecture tests. Treat repeated forwardRef usage as a signal to inspect the boundary rather than a routine fix: it resolves dependency injection but does not explain why two capabilities depend on each other. Verify that barrel files do not create file-level cycles and that dynamic or global modules do not introduce multiple or invisible provider instances.
Test each module through its public API. Fast domain tests protect invariants without NestJS. Application tests exercise commands and queries with controlled ports. Integration tests use real persistence when constraints, locking, transactions, or mappings matter. Contract tests protect public request, response, and event schemas. A small end-to-end suite proves module wiring and delivery adapters without turning every rule into a slow HTTP test.
Make violations actionable: show the forbidden edge, owning module, approved alternatives, and migration guidance. Track boundary exceptions with an owner and expiry rather than weakening the rule globally. Review the module graph and change coupling over time; a boundary that every feature must bypass is either misplaced or missing a necessary public capability.
Extract a Service Only When Evidence Justifies It
Design boundaries that can be extracted, but do not pretend the network exists before it does. A candidate module should own its data access, expose serializable contracts, emit observable events, avoid ambient in-process context, and handle repeated work safely. Keep calls local while one deployment is sufficient. Adding HTTP, service discovery, distributed tracing, and eventual consistency speculatively creates real failure modes for an imagined benefit.
Extract for measured reasons: the capability needs an independent release cadence, materially different scaling, stronger security or failure isolation, a separate availability target, geographic placement, or durable ownership by an autonomous team. High code volume and organizational fashion are weak reasons. Quantify the constraint and the operational cost of another service before approving the split.
Prepare the transition at the existing public port. Make contracts backward compatible, trace calls and dependencies, define service objectives, and decide data ownership. Move the implementation behind a transport adapter, migrate or replicate owned data with reconciliation, and introduce timeouts, retries, circuit behavior, authentication, and idempotency. If an API gateway is involved, give it routing and edge policy rather than displaced domain logic.
Release gradually. Compare old and new results where possible, route a bounded cohort, observe latency, errors, saturation, event lag, and business outcomes, then increase traffic. Define the last safe rollback point and how writes reconcile after reversal. Extraction is complete only when the old path, data access, and operational responsibility are retired—not when a second deployment first returns 200.
Modular Monolith Implementation Checklist
- Boundaries: map business capabilities, language, invariants, change authority, data, jobs, caches, and externally visible contracts before creating NestJS modules.
- Public surface: export a small facade, port, or event contract; keep controllers, handlers, repositories, entities, transactions, and internal orchestration private.
- Consistency: keep each invariant and strong-consistency transaction inside one owner; use an outbox, versioned events, idempotent consumers, reconciliation, and lag monitoring where delivery must survive failure.
- Enforcement: fail CI on internal cross-module imports, forbidden layer direction, framework-dependent domain code, and cycles; test modules through public APIs and real persistence where behavior depends on it.
- Extraction: create a service only for measured deployment, scaling, isolation, location, availability, or ownership needs, then migrate contracts, data, traffic, telemetry, and rollback deliberately.
Is a NestJS module automatically a bounded context?
Can modules in a modular monolith share one database?
Are in-process events reliable enough for important workflows?
When should a NestJS module become a microservice?
Share the module and dependency graph, data ownership, critical transactions, event flows, team boundaries, and deployment constraints. We will identify the coupling that matters and define an incremental modularization path with tests, acceptance evidence, and rollback.
Our research
Research and development of AI-powered solutions to optimize business workflows and enhance decision-making processes.
Analysis of machine learning models for predictive analytics in finance, e-commerce, and SaaS platforms.
Exploration of natural language processing and computer vision technologies to strengthen automation, personalization, and customer support.


