Domain-Driven Design gets adopted tactically and abandoned strategically: teams ship BaseEntity, ValueObject and IRepository<T> abstractions, skip bounded contexts entirely, and end up with one 400-table model that every team fights over. Evans' book is 560 pages and the strategic half is at the back, which is probably why.
This post takes the building blocks in the order that matters — context first, then aggregate, then the rest — and shows the NestJS folder each one produces.
Bounded Context is the load-bearing one
"Bounded Context is a central pattern in Domain-Driven Design. It is the focus of DDD's strategic design section which is all about dealing with large models and teams." — Martin Fowler, BoundedContext (15 January 2014)
Fowler's worked example is an electricity utility where "meter" means subtly different things in different departments. The DDD answer is not to negotiate one canonical Meter class. It is to let each context keep its own model and be explicit about translation at the seams — which is what a context map records.
The boundary follows language, not tables. Fowler again, on the sibling pattern:
Ubiquitous Language is the practice of building up a common, rigorous language between developers and users, grounded in the domain model. Quoting Evans: "By using the model-based language pervasively and not being satisfied until it flows, we approach a model that is complete and comprehensible." — UbiquitousLanguage (31 October 2006)
On disk, a bounded context is a top-level feature module — the widest unit in src/, above any layer folder:
src/
billing/ # context: invoices, payments, dunning
catalog/ # context: products, pricing, availability
identity/ # context: accounts, sessions, permissions
shipping/ # context: shipments, carriers, tracking
shared/ # technical capabilities only — config, logging, database
app.module.ts # imports the context modules; owns no business logicTwo rules make that layout mean something:
- Contexts do not import each other's internals.
ShippingModulemay importBillingModuleand use what itexports; nothing inshipping/may importbilling/domain/invoice. Nest's module system supports this directly — a provider is invisible outside its module unless listed inexports. - The same word may exist twice.
catalog/domain/product.tsandbilling/domain/product.tsare allowed to be different classes with different fields. That duplication is the pattern working, not a DRY violation to refactor intoshared/.
If src/ starts with entities/, services/, repositories/, you have no contexts — you have one model with folders.
Aggregates: the consistency boundary
Vaughn Vernon's Effective Aggregate Design (2011) is the most practical thing written on the subject, and its rules of thumb are short enough to quote:
"Model only the true invariants, the mandatory business rules, within the consistency boundary of the Aggregate. Push all other Rules to either other Aggregates or Applications Services."
"Reference other Aggregates only by their identity, not by holding a direct object reference to another Aggregate root."
Plus: keep aggregates small, and use eventual consistency to maintain relationships across aggregate boundaries. (Vernon, Effective Aggregate Design Part I, 2011)
The identity-reference rule has the biggest filesystem consequence in a NestJS codebase, because it kills the object graph that @ManyToOne / @OneToMany pushes you toward. In the domain model there is no customer: Customer property — there is a CustomerId:
// src/billing/domain/order.ts
export class Order {
private constructor(
readonly id: OrderId,
readonly customerId: CustomerId, // identity, not a Customer object
private readonly lines: OrderLine[], // inside the boundary: loaded and saved with the root
private readonly creditLimit: Money,
private status: OrderStatus,
) {}
// One transaction, one aggregate. The invariant lives here or nowhere.
addLine(sku: Sku, quantity: number, unitPrice: Money): void {
if (this.status !== OrderStatus.Draft) throw new OrderLockedError(this.id);
if (quantity <= 0) throw new InvalidQuantityError(quantity);
const projected = this.total().plus(unitPrice.times(quantity));
if (projected.greaterThan(this.creditLimit)) {
throw new CreditLimitExceededError(this.id, projected, this.creditLimit);
}
this.lines.push(new OrderLine(sku, quantity, unitPrice));
}
private total(): Money {
return this.lines.reduce((sum, line) => sum.plus(line.subtotal()), Money.zero());
}
}Practical test for whether something belongs inside the boundary: does the rule have to be true at the end of every transaction? Order total versus credit limit — yes, inside. "Customer's lifetime spend updates after an order is placed" — no; that is another aggregate, updated eventually, usually by handling a domain event.
On disk: an aggregate is a file (or a small folder) inside its context's domain/, named for the root. Not models/. The root, its child entities, its value objects and its errors sit together, because they change together.
src/billing/domain/
order.ts # Order aggregate root + invariants
order-line.ts # entity inside the boundary
order.spec.ts # unit test, no Nest, no database
money.ts # value object: immutable, no identity, equality by value
order-placed.event.ts
errors.tsValue objects deserve their own mention, because TypeScript makes them cheap and most codebases still use number for money:
// src/billing/domain/money.ts
export class Money {
private constructor(readonly cents: bigint, readonly currency: string) {}
static of(cents: bigint, currency: string): Money {
if (cents < 0n) throw new NegativeAmountError();
return new Money(cents, currency);
}
plus(other: Money): Money {
if (other.currency !== this.currency) throw new CurrencyMismatchError();
return new Money(this.cents + other.cents, this.currency);
}
equals(other: Money): boolean {
return this.cents === other.cents && this.currency === other.currency;
}
}Once currency mismatch throws in the constructor, that bug cannot reach production through any code path — controller, cron job, queue consumer or seed script.
Repositories: one per aggregate, interface owned by the domain
A repository is a collection-like façade for aggregate roots — not a generic DAO per table. One repository per aggregate root, returning fully-formed roots. If you have OrderLineRepository, the line has escaped its boundary. And if your service injects Repository<OrderOrmEntity> from TypeORM directly, you have no repository pattern at all — you have the ORM with extra words.
The interface and its token live with the domain; the implementation lives with the driver, for the reasons in Part 1:
// src/billing/domain/orders.port.ts
export interface Orders {
byId(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>; // persists the whole aggregate, lines included
nextId(): OrderId; // identity generation belongs to the collection
}
export const ORDERS = Symbol('Orders');src/billing/
domain/ # aggregates, value objects, ports, domain events, errors
application/ # use cases, event handlers, transaction boundaries
infrastructure/ # TypeORM entities + mappers, HTTP clients, queue adapters
api/ # controllers, DTOs, validation, presenters
billing.module.ts # composition root: binds ORDERS -> TypeOrmOrdersLayers inside the context; contexts at the top. That ordering is Fowler's advice from PresentationDomainDataLayering applied literally, and it is also how NestJS wants you to work — the docs describe a feature module as code that "organizes code that is relevant to a specific feature, helping to maintain clear boundaries and better organization" (NestJS, Modules).
Domain services vs application services
Two different things share the name, and mixing them up is how you get a 2,000-line OrderService.
- Domain service — a domain operation that genuinely does not belong to a single aggregate: transferring between two accounts, pricing that consults a policy object. Lives in
domain/, speaks only domain types, no transactions, no HTTP, no@Injectable()if you can avoid it. - Application service / use case — orchestration: load aggregate, call one method, save, publish. Lives in
application/. Evans, quoted by Fowler:
"It does not contain business rules or knowledge, but only coordinates tasks and delegates work to collaborations of domain objects." — Fowler, AnemicDomainModel (25 November 2003)
That article is also the standing warning about what happens when the split slips. Fowler's description of the anti-pattern:
"there is hardly any behavior on these objects, making them little more than bags of getters and setters." … "it's so contrary to the basic idea of object-oriented design; which is to combine data and process together."
And the diagnostic, which is the sentence to grep your own *.service.ts files against:
"The more behavior you find in the services, the more likely you are to be robbing yourself of the benefits of a domain model."
Nest makes anaemia the path of least resistance: nest g resource produces a controller, a service and entities with getters, setters and zero behaviour. A blunt heuristic — if every method on your entity is a property accessor and every if lives in a *.service.ts, you paid the full DDD tax and bought a transaction script. Transaction scripts are fine; Fowler catalogued them as a legitimate pattern. Just skip the ceremony and write them plainly.
Domain events keep aggregates small
Vernon's "eventual consistency outside the boundary" rule needs a mechanism. Nest ships one (@nestjs/cqrs, or EventEmitterModule), but the structural point holds whichever you pick: the aggregate records what happened, the application layer publishes it, and a handler in another context reacts.
// domain records
class Order {
place(at: Date): void {
/* invariants … */
this.record(new OrderPlaced(this.id, this.customerId, this.total(), at));
}
}
// application publishes after the transaction commits
await this.orders.save(order);
this.events.publishAll(order.pullEvents());src/billing/domain/order-placed.event.ts # the fact, owned by billing
src/shipping/application/on-order-placed.ts # reaction, owned by shippingThe event file is the contract between contexts. It should carry identities and value objects — never another context's aggregate.
Anticorruption layers are folders too
When a context talks to a legacy system or a third party whose model you would not choose, DDD's answer is an anticorruption layer: a translation shell that keeps their vocabulary out of your domain. It is also, usefully, a directory:
src/shipping/
domain/carrier.port.ts # our language: Shipment, TrackingCode, Status
infrastructure/dhl/ # ACL: their XML, their status codes, mapped to ours
infrastructure/ups/ # ACL: their REST, their tracking model, mapped to oursEvery field name from DHL's schema that appears outside infrastructure/dhl/ is a leak. That gives code review a mechanical check instead of an argument about taste.
A checklist you can run on a repo
- Does
src/name business contexts or technical roles? - Can you point at one file per aggregate that holds its invariants?
- Do aggregates reference each other by ID, or by ORM relation objects?
- Is there exactly one repository port per aggregate root — and does any use case inject
Repository<T>directly? - Does anything in
domain/import@nestjs/*, TypeORM, Prisma, or an SDK? - Do the class names match the words the business actually says out loud?
Five noes and you do not have a DDD codebase — you have entities/ with extra steps. That is a legitimate place to be; just do not carry the cost of the vocabulary without the benefits.
Sources
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003 (ISBN 978-0321125217).
- Eric Evans, Domain-Driven Design Reference: Definitions and Pattern Summaries, 2015 (CC BY 4.0) — https://www.domainlanguage.com/wp-content/uploads/2016/05/DDD_Reference_2015-03.pdf
- Vaughn Vernon, Effective Aggregate Design, Part I: Modeling a Single Aggregate, 2011 — https://www.dddcommunity.org/wp-content/uploads/files/pdf_articles/Vernon_2011_1.pdf
- Martin Fowler, BoundedContext (15 January 2014) — https://martinfowler.com/bliki/BoundedContext.html
- Martin Fowler, UbiquitousLanguage (31 October 2006) — https://martinfowler.com/bliki/UbiquitousLanguage.html
- Martin Fowler, AnemicDomainModel (25 November 2003) — https://martinfowler.com/bliki/AnemicDomainModel.html
- Martin Fowler, PresentationDomainDataLayering (26 August 2015) — https://martinfowler.com/bliki/PresentationDomainDataLayering.html
- NestJS documentation, Modules — https://docs.nestjs.com/modules