NestJS ships with a module system that is, on the surface, a convenient way to organise controllers and services. Most teams stop there. But if you look at what a NestJS module actually is — an explicit boundary around a set of providers, with deliberate imports and exports — you realise it is structurally identical to what Domain-Driven Design calls a bounded context.
This article maps those two ideas onto each other. By the end, you will have a concrete approach for designing a NestJS project around the language of your business, not just the shapes of your database tables.
"What belongs together, and what should stay apart?"
What is a Bounded Context?
DDD's Strategic Design layer is about dividing a large domain into smaller, coherent subdomains — each with its own ubiquitous language. The bounded context is the explicit boundary within which a particular model is valid.
The classic example: the word Customer means something different in a Sales context versus a Support context. In Sales, a Customer has a pipeline stage and an account manager. In Support, the same entity has open tickets and a satisfaction score. Forcing one model to carry all that weight creates a bloated, coupled mess.
How NestJS Modules Map to Contexts
A NestJS module is defined by three things: what it provides internally, what it imports from other modules, and what it exports to the outside world. That maps cleanly to a bounded context's published interface.
Bounded Contexts as NestJS Modules — e-commerce domain
- Order
- OrderLine
- OrderService
- OrderRepository
- OrdersController
- User
- AuthService
- JwtStrategy
- UserRepository
- AuthController
- Invoice
- Payment
- BillingService
- StripeAdapter
- BillingController
- Product
- Category
- CatalogService
- SearchService
- CatalogController
- Notification
- MailService
- SmsService
- TemplateRenderer
- DomainEvent (base class)
- Money (value object)
- Pagination (DTO)
- EventBusModule
- LoggerModule
- DatabaseModule
Notice that each box owns its own entities, services, and infrastructure adapters. The Orders context does not import a UserService — it receives a thin BuyerId value object from Identity. The full User aggregate stays behind the Identity boundary.
Structuring a Context Module
Each bounded context module follows a consistent internal layout. This predictability is itself a DDD tool — new developers can navigate any module because the structure communicates intent.
src/orders/
domain/
order.entity.ts ← aggregate root
order-line.entity.ts
order.repository.ts ← port (interface)
events/
order-placed.event.ts
application/
place-order.command.ts
place-order.handler.ts
get-order.query.ts
get-order.handler.ts
infrastructure/
order.typeorm-repository.ts ← adapter
order.schema.ts
interface/
orders.controller.ts
dto/
place-order.dto.ts
order-response.dto.ts
orders.module.ts
The layers inside the module mirror clean architecture: domain has no dependencies, application depends only on domain, infrastructure depends on both, and interface is the entry point. This is enforced by import direction — never import up from infrastructure into domain.
The Module File as a Context Map
The .module.ts file is where your DDD intent becomes explicit NestJS configuration. Reading it should tell you everything about the context's boundary.
import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; // domain port token import { ORDER_REPOSITORY } from './domain/order.repository'; // application layer import { PlaceOrderHandler } from './application/place-order.handler'; import { GetOrderHandler } from './application/get-order.handler'; // infrastructure adapters import { OrderTypeOrmRepository } from './infrastructure/order.typeorm-repository'; import { OrderSchema } from './infrastructure/order.schema'; // interface import { OrdersController } from './interface/orders.controller'; // cross-context imports (shared kernel only) import { EventBusModule } from '../shared/event-bus/event-bus.module'; @Module({ imports: [ CqrsModule, TypeOrmModule.forFeature([OrderSchema]), EventBusModule, // ← shared kernel only ], controllers: [OrdersController], providers: [ PlaceOrderHandler, GetOrderHandler, { provide: ORDER_REPOSITORY, // ← inject via port token useClass: OrderTypeOrmRepository, }, ], exports: [/* intentionally narrow — nothing exported by default */], }) export class OrdersModule {}
Two things to notice. First, the repository is registered against a port token (ORDER_REPOSITORY), not the concrete class. The domain layer never sees TypeORM. Second, the exports array is deliberately empty — OrdersModule does not expose its internals to the rest of the app.
Cross-Context Communication
The hardest part of bounded context design is deciding how contexts talk to each other without creating tight coupling. DDD gives us three tools here, all of which NestJS supports naturally.
1. Domain Events via an Event Bus
The preferred mechanism. When an order is placed, the Orders context publishes an OrderPlacedEvent. The Billing context subscribes and creates an invoice. Neither context knows the other exists.
@CommandHandler(PlaceOrderCommand) export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> { constructor( @Inject(ORDER_REPOSITORY) private repo: OrderRepository, private eventBus: EventBus, ) {} async execute(cmd: PlaceOrderCommand): Promise<void> { const order = Order.place(cmd.buyerId, cmd.lines); await this.repo.save(order); // publish — Billing, Notifications listen independently this.eventBus.publish(new OrderPlacedEvent(order.id, order.total)); } }
2. Shared Kernel
A small set of types and utilities that multiple contexts genuinely share — things like a Money value object, a Pagination DTO, or base classes. Keep this ruthlessly small. If you find yourself adding business logic to the shared kernel, extract a new context instead.
3. Anti-Corruption Layer (ACL)
When one context must consume data from another but the models differ, build a translation adapter. This is an especially important pattern when integrating with third-party APIs or legacy systems.
// Billing's internal model — independent of Stripe's API shape export interface PaymentGateway { charge(amount: Money, token: string): Promise<PaymentResult>; } @Injectable() export class StripePaymentAdapter implements PaymentGateway { async charge(amount: Money, token: string): Promise<PaymentResult> { // translate Billing's Money → Stripe's integer cents const charge = await stripe.charges.create({ amount: amount.toCents(), currency: amount.currency, source: token, }); // translate Stripe's response → Billing's PaymentResult return new PaymentResult(charge.id, charge.status === 'succeeded'); } }
Common Pitfalls
The God Module
A module that imports everything and is imported by everything. Usually surfaces as a CommonModule or AppModule that has grown out of control. Treat this as a smell that you have not found the right context boundaries yet.
Leaking Domain Objects Across Contexts
Exporting your Order entity from OrdersModule so that BillingModule can query it directly. Now Billing is tightly coupled to Orders' persistence model. Instead, emit an event or expose a thin read-model DTO behind a dedicated query endpoint.
Over-granular Contexts
Splitting so aggressively that a single user story requires coordinating five modules. DDD's rule of thumb: if two things change together almost every time, they probably belong in the same context. Start coarse; split when you feel the pain of coupling.
The beauty of NestJS is that the module system gives you a place to make these boundaries explicit in code. You do not need a distributed system to get the benefits of context isolation — a well-structured monolith with intentional module boundaries is a perfectly valid architecture, and a much cheaper one to operate.
Start with the language of your domain. Name your modules after business capabilities, not technical layers. Let the imports and exports of each module tell the story of how those capabilities relate to each other. The framework will handle the rest.