How I actually structure NestJS apps around bounded contexts, with branded types, discriminated unions, and Result types pulling their weight.
The first time I tried to wedge DDD into NestJS, I built a domain folder, dropped a couple of aggregate root classes in there, and shipped it. About six weeks later I was the one paging myself on a Saturday because two different services were both calling OrderRepository.save from inside the same HTTP request, and the second call was quietly overwriting the first. Yeah. That one’s on me.
So this post is about what I do now. NestJS modules per bounded context, TypeScript branded types so my IDs stop swapping themselves at runtime, discriminated unions for domain events, and neverthrow Result types so the application layer stops pretending exceptions are control flow. I’ll keep the e-commerce example honest. A real Order aggregate with real money.
The single biggest call you make in a NestJS app is what a module means. Default Nest scaffolding nudges you toward technical modules. OrdersController, OrdersService, OrdersRepository, all in one OrdersModule. That’s fine for a CRUD app. It falls apart the moment Orders, Payments, and Inventory start sharing entities through some “shared” module that nobody owns.
I treat each bounded context as its own Nest module. Catalog, Ordering, Billing, Fulfillment. Inside each module I keep four folders, in this order: domain (aggregates, value objects, events, repository interfaces), application (use cases, DTOs), infrastructure (Prisma/TypeORM implementations of the interfaces), interface (controllers, GraphQL resolvers, message handlers).
// src/ordering/ordering.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '../infrastructure/prisma/prisma.module';
import { PlaceOrderHandler } from './application/place-order.handler';
import { OrderRepository } from './domain/order.repository';
import { PrismaOrderRepository } from './infrastructure/prisma-order.repository';
import { OrderController } from './interface/order.controller';
@Module({
imports: [PrismaModule],
controllers: [OrderController],
providers: [
PlaceOrderHandler,
{ provide: OrderRepository, useClass: PrismaOrderRepository },
],
exports: [OrderRepository],
})
export class OrderingModule {}
OrderRepository is an abstract class used as the DI token. The Prisma implementation lives in infrastructure and gets wired in here. Nothing outside this module knows Prisma exists. That single rule saved me weeks during a portfolio-wide DDD migration I led at a London product agency, where dozens of client codebases were leaking ORM types into their HTTP layer.
Plain string IDs are a bug factory. I’ve passed a customerId where an orderId was expected, watched TypeScript shrug, and only caught it because Postgres threw a foreign-key error in staging. Branded types fix this at compile time, with zero runtime cost.
// src/shared/branded.ts
export type Brand<T, B extends string> = T & { readonly __brand: B };
export type OrderId = Brand<string, 'OrderId'>;
export type CustomerId = Brand<string, 'CustomerId'>;
export type SkuId = Brand<string, 'SkuId'>;
export const OrderId = {
from(raw: string): OrderId {
if (!/^ord_[0-9a-z]{16}$/.test(raw)) {
throw new Error(`invalid OrderId: ${raw}`);
}
return raw as OrderId;
},
};
Now placeOrder(customerId, orderId) won’t accept its arguments in the wrong order. The IDE refuses. I had a junior on my squad once spend half a day debugging a “ghost order” bug that turned out to be exactly this kind of swap. We branded the IDs that afternoon. The class of bug disappeared.
The aggregate is where the business rules live. Not in the service. Not in the controller. Here.
// src/ordering/domain/order.ts
import { OrderId, CustomerId, SkuId } from '../../shared/branded';
import { Money } from '../../shared/money';
import { OrderPlaced, OrderLineAdded, DomainEvent } from './events';
type OrderState = 'draft' | 'placed' | 'cancelled';
export class Order {
private events: DomainEvent[] = [];
private constructor(
readonly id: OrderId,
readonly customerId: CustomerId,
private lines: { sku: SkuId; qty: number; price: Money }[],
private state: OrderState,
) {}
static draft(id: OrderId, customerId: CustomerId): Order {
return new Order(id, customerId, [], 'draft');
}
addLine(sku: SkuId, qty: number, price: Money): void {
if (this.state !== 'draft') throw new Error('order not editable');
if (qty <= 0) throw new Error('qty must be positive');
this.lines.push({ sku, qty, price });
this.events.push(OrderLineAdded(this.id, sku, qty));
}
place(): void {
if (this.state !== 'draft') throw new Error('already placed');
if (this.lines.length === 0) throw new Error('cannot place empty order');
this.state = 'placed';
this.events.push(OrderPlaced(this.id, this.customerId, this.total()));
}
total(): Money {
return this.lines.reduce((acc, l) => acc.add(l.price.times(l.qty)), Money.zero('USD'));
}
pullEvents(): DomainEvent[] {
const e = this.events;
this.events = [];
return e;
}
}
The repo writes the aggregate, then publishes the events it pulled. No if (order.state === 'draft') checks scattered across services. The aggregate refuses to do the wrong thing.
Domain events are tagged unions. Not classes. Not a base DomainEvent interface with a payload: any. A real union the compiler can narrow.
// src/ordering/domain/events.ts
import { OrderId, CustomerId, SkuId } from '../../shared/branded';
import { Money } from '../../shared/money';
export type DomainEvent =
| { type: 'OrderPlaced'; orderId: OrderId; customerId: CustomerId; total: Money }
| { type: 'OrderLineAdded'; orderId: OrderId; sku: SkuId; qty: number }
| { type: 'OrderCancelled'; orderId: OrderId; reason: string };
export const OrderPlaced = (orderId: OrderId, customerId: CustomerId, total: Money): DomainEvent =>
({ type: 'OrderPlaced', orderId, customerId, total });
export const OrderLineAdded = (orderId: OrderId, sku: SkuId, qty: number): DomainEvent =>
({ type: 'OrderLineAdded', orderId, sku, qty });
Handlers switch on type and the compiler narrows the fields. No casts. No runtime type checks. If somebody adds a new event variant and forgets to handle it, the exhaustive switch complains at build time.
The application layer is where I used to throw exceptions for everything. Validation, not-found, business rule violations, all the same try/catch. It was fine until I had a war story I’d rather not relive. A trading platform I architected was streaming live market data, and a transient downstream timeout was being caught by a generic exception handler four layers up and silently swallowed. Charts froze. Customers saw stale prices for about 14 minutes during market open before I tracked it down. That afternoon I started leaning on Result types. Errors as values, in the type signature, where they belong.
// src/ordering/application/place-order.handler.ts
import { Injectable } from '@nestjs/common';
import { Result, ok, err } from 'neverthrow';
import { OrderRepository } from '../domain/order.repository';
import { Order } from '../domain/order';
import { OrderId, CustomerId } from '../../shared/branded';
type PlaceOrderError =
| { kind: 'OrderNotFound' }
| { kind: 'AlreadyPlaced' }
| { kind: 'EmptyOrder' };
@Injectable()
export class PlaceOrderHandler {
constructor(private readonly orders: OrderRepository) {}
async execute(id: OrderId): Promise<Result<void, PlaceOrderError>> {
const order = await this.orders.findById(id);
if (!order) return err({ kind: 'OrderNotFound' });
try {
order.place();
} catch (e) {
const msg = (e as Error).message;
if (msg === 'already placed') return err({ kind: 'AlreadyPlaced' });
if (msg === 'cannot place empty order') return err({ kind: 'EmptyOrder' });
throw e;
}
await this.orders.save(order);
return ok(undefined);
}
}
The controller maps the PlaceOrderError union to HTTP status codes. No exception is doing double duty as control flow. The aggregate keeps throwing, because invariant violations should be loud, but the application layer translates them into values.
The reason I started writing this post. The Saturday I mentioned at the top.
The setting: a community and talent product I CTO on the side. Order placement was being kicked off by two paths, a checkout HTTP endpoint and a webhook from the payment provider confirming the charge. Both paths called OrderRepository.save. The webhook landed about 90ms after the HTTP call on the happy path. Both passed.
What went wrong: race. The webhook handler loaded the order, marked it paid, and saved. The HTTP handler had already loaded the order, marked it placed, and was about to save. Last-write-wins on the row, so placed overwrote paid. Order looked unpaid in the dashboard. Customer support pinged me on a Saturday.
First wrong fix: I added an in-memory Mutex keyed on orderId. Worked locally. Blew up the moment we ran two pods in production. The mutex was per-process.
Real fix: optimistic concurrency on the aggregate itself. Every aggregate carries a version integer. The repo writes with UPDATE ... WHERE id = $1 AND version = $2. If the row count is zero, the save throws a ConcurrencyError. The application layer maps it to a Result and the caller retries once. Boring solution. Has held up for over a year. The takeaway scar is shorter: aggregates need a version, repos need to check it, and “I’ll add locking later” is a lie.
Thanks for reading. If you’ve got thoughts, send them my way.