Modules as Bounded Contexts

How to apply Domain-Driven Design principles inside NestJS by mapping each module to a distinct bounded context.

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.

A module is NestJS's answer to the question:
"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.

💡
Key Insight A bounded context is not a microservice. It is a conceptual boundary that could live in a monolith, a module, or a separate service. The boundary matters; the deployment topology is secondary.

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

Orders
  • Order
  • OrderLine
  • OrderService
  • OrderRepository
  • OrdersController
Identity
  • User
  • AuthService
  • JwtStrategy
  • UserRepository
  • AuthController
Billing
  • Invoice
  • Payment
  • BillingService
  • StripeAdapter
  • BillingController
Catalog
  • Product
  • Category
  • CatalogService
  • SearchService
  • CatalogController
Notifications
  • Notification
  • MailService
  • SmsService
  • TemplateRenderer
Shared Kernel
  • 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.

orders/ — directory layout TREE
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.

orders.module.ts TS
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.

place-order.handler.ts — publishing an event TS
@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.

stripe-payment.adapter.ts — ACL example TS
// 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.

⚠️
Rule of thumb A bounded context should be owned by a single team. If two teams frequently need to modify the same module, that is the architectural signal to draw a new boundary between them.

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.