Etsub
Remote / Worldwide  
All writing
9 min readStructure That Survives · Part 2

DDD Building Blocks, On Disk

Bounded contexts, aggregates, repositories and services are usually explained as diagrams. This is what each one is supposed to be — quoted from Evans, Fowler and Vernon — and the NestJS folder it turns into.

By , Fullstack Software Engineer

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 logic

Two rules make that layout mean something:

  1. Contexts do not import each other's internals. ShippingModule may import BillingModule and use what it exports; nothing in shipping/ may import billing/domain/invoice. Nest's module system supports this directly — a provider is invisible outside its module unless listed in exports.
  2. The same word may exist twice. catalog/domain/product.ts and billing/domain/product.ts are allowed to be different classes with different fields. That duplication is the pattern working, not a DRY violation to refactor into shared/.

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.ts

Value 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 -> TypeOrmOrders

Layers 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 shipping

The 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 ours

Every 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

  1. Does src/ name business contexts or technical roles?
  2. Can you point at one file per aggregate that holds its invariants?
  3. Do aggregates reference each other by ID, or by ORM relation objects?
  4. Is there exactly one repository port per aggregate root — and does any use case inject Repository<T> directly?
  5. Does anything in domain/ import @nestjs/*, TypeORM, Prisma, or an SDK?
  6. 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