Domain-Driven Design in TypeScript with NestJS: Bounded Context, Aggregates, and Domain Events for Production

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

When Your NestJS Codebase Becomes a Maintenance Nightmare

Picture this: you’re three sprints into a new e-commerce platform built with NestJS and TypeScript. The codebase looks clean. Services are thin, controllers are minimal, and everything passes CI. Then the product team drops the roadmap: subscription billing, loyalty points, and inventory reservations — all tied to the same “order” concept.

Suddenly your OrderService is 800 lines long. The updateOrder() method calls five other services. Nobody on the team can confidently trace what fires when an order gets cancelled. I’ve hit this exact wall in production — more than once. Every time, the fix wasn’t a TypeScript refactor. It was a design change.

This is a design problem, not a TypeScript problem.

Root Cause: The Anemic Domain Model Trap

Most NestJS tutorials teach you to put business logic inside services. It feels clean at first — a UserService, an OrderService, a PaymentService. But this pattern has a name. Eric Evans called it the anemic domain model. Your entities become bags of data. The real behavior lives scattered across service classes.

The symptoms show up fast:

  • Services that import each other in circular chains
  • Business rules duplicated across multiple services
  • Tests that require mocking half the dependency tree to verify a single assertion
  • New developers spending days tracing where a specific rule actually lives

The root issue is ownership — or the lack of it. Who owns the rule “an order can only be cancelled if it hasn’t shipped”? Is that in OrderService? ShipmentService? Some utility helper? When nobody agrees, it ends up everywhere — or worse, inconsistently enforced across the codebase.

Two Approaches Side by Side

Before going deep on DDD, here’s the same cancellation logic in both styles — it makes the tradeoff concrete:

Anemic service approach:

// order.service.ts
async cancelOrder(orderId: string): Promise<void> {
  const order = await this.orderRepo.findById(orderId);
  const shipment = await this.shipmentService.findByOrder(orderId);

  if (shipment && shipment.status === 'shipped') {
    throw new Error('Cannot cancel shipped order');
  }

  order.status = 'cancelled';
  await this.orderRepo.save(order);
  await this.notificationService.sendCancellationEmail(order.userId);
  await this.inventoryService.releaseStock(order.items);
}

DDD aggregate approach:

// order.aggregate.ts
export class Order extends AggregateRoot {
  private status: OrderStatus;
  private items: OrderItem[];

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new OrderCancellationError('Shipped orders cannot be cancelled');
    }
    this.status = OrderStatus.CANCELLED;
    this.apply(new OrderCancelledEvent(this.id, this.items));
  }
}

// order.service.ts — now just orchestration
async cancelOrder(orderId: string): Promise<void> {
  const order = await this.orderRepository.findById(orderId);
  order.cancel();
  await this.orderRepository.save(order);
}

The business rule moved into the domain object. Notifications and inventory updates happen in event subscribers reacting to OrderCancelledEvent. The service is pure coordination — no business logic in sight.

DDD Building Blocks in NestJS

Bounded Contexts as NestJS Modules

A Bounded Context is a boundary around a coherent set of domain concepts. In NestJS, each context maps naturally to a module. Your OrderingContext shouldn’t reach into InventoryContext internals — they communicate through published events or explicit API contracts, not direct service calls.

// ordering/ordering.module.ts
@Module({
  imports: [CqrsModule],
  providers: [
    OrderService,
    OrderRepository,
    OrderCancelledHandler, // Handles side effects via events
  ],
  exports: [OrderService],
})
export class OrderingModule {}

// inventory/inventory.module.ts
@Module({
  imports: [CqrsModule],
  providers: [
    InventoryService,
    InventoryRepository,
    ReleaseStockOnOrderCancelled, // Reacts to cross-context event
  ],
})
export class InventoryModule {}

Building a Proper Aggregate

NestJS ships with @nestjs/cqrs, which includes an AggregateRoot base class built for collecting and publishing domain events. Here’s a full Order aggregate with real validation guards:

// ordering/domain/order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { OrderPlacedEvent } from '../events/order-placed.event';

export enum OrderStatus {
  PENDING = 'PENDING',
  CONFIRMED = 'CONFIRMED',
  SHIPPED = 'SHIPPED',
  CANCELLED = 'CANCELLED',
}

export class Order extends AggregateRoot {
  constructor(
    public readonly id: string,
    private status: OrderStatus,
    private readonly customerId: string,
    private items: OrderItem[],
  ) {
    super();
  }

  static place(id: string, customerId: string, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error('Order must have at least one item');
    }
    const order = new Order(id, OrderStatus.PENDING, customerId, items);
    order.apply(new OrderPlacedEvent(id, customerId, items));
    return order;
  }

  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error(`Cannot confirm order in status ${this.status}`);
    }
    this.status = OrderStatus.CONFIRMED;
  }

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error('Shipped orders cannot be cancelled');
    }
    this.status = OrderStatus.CANCELLED;
    this.apply(new OrderCancelledEvent(this.id, this.items));
  }

  getStatus(): OrderStatus {
    return this.status;
  }
}

Domain Events That Cross Context Boundaries

Domain events let bounded contexts react to each other without coupling. Define them as plain classes — no framework magic, no decorators:

// ordering/events/order-cancelled.event.ts
export class OrderCancelledEvent {
  constructor(
    public readonly orderId: string,
    public readonly items: OrderItem[],
  ) {}
}

// inventory/handlers/release-stock.handler.ts
@EventsHandler(OrderCancelledEvent)
export class ReleaseStockOnOrderCancelled
  implements IEventHandler<OrderCancelledEvent> {
  constructor(private readonly inventoryService: InventoryService) {}

  async handle(event: OrderCancelledEvent): Promise<void> {
    await this.inventoryService.releaseStock(event.items);
  }
}

The inventory context reacts to the event. It knows nothing about how orders work internally. Adding a second reaction — say, a warehouse alert or a push notification — means one new handler file and zero changes to existing code. That’s the actual payoff.

Repository Pattern for Aggregates

Repositories in DDD aren’t generic database wrappers. They speak the domain language and return fully reconstituted aggregates — ready to have methods called on them:

// ordering/repositories/order.repository.ts
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomer(customerId: string): Promise<Order[]>;
}

// ordering/repositories/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(
    @InjectRepository(OrderEntity)
    private readonly repo: Repository<OrderEntity>,
    private readonly eventBus: EventBus,
  ) {}

  async save(order: Order): Promise<void> {
    const entity = this.toEntity(order);
    await this.repo.save(entity);
    // Publish domain events collected by AggregateRoot
    order.getUncommittedEvents().forEach(event => this.eventBus.publish(event));
    order.commit();
  }

  async findById(id: string): Promise<Order | null> {
    const entity = await this.repo.findOne({ where: { id } });
    return entity ? this.toDomain(entity) : null;
  }

  private toDomain(entity: OrderEntity): Order {
    return new Order(
      entity.id,
      entity.status as OrderStatus,
      entity.customerId,
      entity.items.map(i => new OrderItem(i.productId, i.quantity, i.price)),
    );
  }
}

Wiring the Command Handler

Commands drive state changes in CQRS. Here’s the complete cancel-order flow, end to end:

// ordering/commands/cancel-order.command.ts
export class CancelOrderCommand {
  constructor(public readonly orderId: string) {}
}

// ordering/handlers/cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
  constructor(private readonly orderRepository: OrderRepository) {}

  async execute(command: CancelOrderCommand): Promise<void> {
    const order = await this.orderRepository.findById(command.orderId);
    if (!order) throw new NotFoundException('Order not found');

    order.cancel(); // Domain logic stays in the aggregate
    await this.orderRepository.save(order); // Events get published here
  }
}

Controllers just dispatch commands — no business logic, no conditionals:

// ordering/order.controller.ts
@Controller('orders')
export class OrderController {
  constructor(private readonly commandBus: CommandBus) {}

  @Delete(':id')
  async cancel(@Param('id') id: string): Promise<void> {
    await this.commandBus.execute(new CancelOrderCommand(id));
  }
}

What This Structure Delivers in Practice

Three concrete wins that show up consistently in production:

  • Testability: Aggregates are pure TypeScript classes with zero DI dependencies. Testing cancellation logic is a single order.cancel() call — no mocks, no test doubles, no module bootstrapping.
  • Faster onboarding: New engineers find business rules by reading aggregate methods, not grep-ing across a dozen service files. One engineer on my team went from “what even is an order?” to shipping a billing feature in two days instead of the usual week.
  • Surgical change isolation: Adding a new reaction to OrderCancelledEvent means one new handler. Nothing else changes. No regression risk from touching existing code.

One thing worth flagging: NestJS’s EventBus from @nestjs/cqrs is in-process only. In a microservices setup, you’ll bridge domain events to a message broker — RabbitMQ and Kafka both work well here. The domain model stays identical. Only the transport layer changes.

When DDD Is the Wrong Call

DDD adds real upfront complexity. For a CRUD admin panel with no meaningful business rules, a simple service layer is genuinely the right call — don’t overcomplicate it.

The pattern earns its cost when you have intricate invariants, multiple bounded contexts that need decoupling, and business rules that evolve at different rates. For a to-do app: overkill. For an order management system with billing, inventory, and fulfillment running on separate schedules: worth every hour of setup.

Start by identifying your aggregates. Ask: “what group of objects must change together to keep data consistent?” That question cuts through architecture diagrams faster than anything else. The answer usually points directly at your aggregate boundaries.

Share: