Domain-Driven Design

In most codebases, business rules have no single home. The rule "an email must be valid" gets checked in a controller, again in a service, and once more in a test helper. The copies drift, and one day they disagree. Domain-Driven Design is a way of building software where the business rules get one home: a dedicated layer that models the business, written in the business's own words.

That home is the domain/ layer of each bounded context. It contains the business rules and nothing else: no NestJS decorators, no database calls, no HTTP. Three building blocks make it up: aggregates, value objects and domain events.

Code in the business's words

DDD works best when the code uses the vocabulary of the business, so a sentence from a product discussion maps directly onto class and method names. When the business says "users must verify their email before logging in", the code answers with EmailVerification, user.verifyEmail() and user.canAuthenticate(). Nothing needs translating between how the team talks and how the code reads. DDD calls this shared vocabulary the ubiquitous language.

The naming conventions in this repo protect that mapping: aggregates, value objects and events are named after business concepts, and the file suffixes (.aggregate.ts, .vo.ts, .domain-event.ts) say what role each concept plays.

Aggregates

An aggregate is the unit you load, change and save as one piece: a cluster of objects treated as a single unit, with one entity as the root. It owns its invariants: the only way to change its state is through its own methods, which keep it valid.

Every aggregate extends SharedAggregate, which provides identity, timestamps, equality, and the domain-event machinery.

Here's the User aggregate. Note the static factory create(): it's the only entry point, it enforces the "email must be unique" rule via an injected port, and it records what happened by applying a domain event.

// bounded-contexts/auth/domain/aggregates/user/user.aggregate.ts
export class User extends SharedAggregate implements UserAttributes {
  email: Email;
  password: Password;
  status: UserStatus;
  role: UserRole;
  // …
 
  static async create(
    props: CreateUserProps,
    uniquenessChecker: IUserUniquenessChecker,
  ): Promise<User> {
    const isEmailUnique = await uniquenessChecker.isEmailUnique(props.email);
    if (!isEmailUnique) {
      throw new AlreadyExistsException('email', props.email.toValue());
    }
 
    const user = new User({ /* …id, email, password, active status… */ });
 
    user.apply(
      new UserRegistered_DomainEvent(user.id, user.email, user.role, 'email', true),
    );
 
    return user;
  }
}

State changes are methods

To change an aggregate, call a method that expresses the intent: user.deactivate(), user.changePassword(newPassword), user.verifyEmail(). Assigning properties from outside, like user.status = UserStatus.inactive(), is prohibited, because it would skip the rules and events that live inside the method. The no-direct-entity-mutation lint rule fails the build on it.

Value objects

Value objects model business concepts that are defined by their value, not an identity, like an email, a password, or a status. They are immutable and self-validating: constructing one that's invalid throws an error. This pushes validation to the edge and makes illegal states unrepresentable throughout the rest of the code.

Without them, the same check gets copied wherever the value appears:

// in a controller…
if (!emailRegex.test(body.email)) throw new BadRequestException('Invalid email');
// …and again in a service, and again in a test helper, each slightly different

With a value object, the check runs exactly once, at construction. Anywhere an Email exists, it is valid:

// bounded-contexts/auth/domain/value-objects/email.vo.ts
 
import { isDisposableEmailDomain } from './disposable-email-domains';
 
export class Email extends StringValueObject {
  constructor(value: string) {
    super(value.toLowerCase());
    this.validate();
  }
 
  validate(): void {
    super.validate();
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(this.value)) {
      throw new DomainValidationException('email', this.value, `Invalid email: ${this.value}`);
    }
    if (isDisposableEmailDomain(this.getDomain())) {
      throw new DomainValidationException('email', this.value, 'Disposable emails not allowed');
    }
  }
 
  getDomain(): string {
    return this.toValue().split('@')[1];
  }
}

Once you have an Email, you never have to wonder whether it's well-formed. The type itself is the guarantee. Value objects live in *.vo.ts files.

Domain events

Domain events record that something meaningful happened, in the past tense. The aggregate records the event at the moment of the change (the user.apply(...) call above).

Once the command handler has saved the aggregate, it dispatches the recorded events, and handlers in the same context react to them.

Every domain event must extend Base_DomainEvent:

// …/aggregates/user/events/user-registered.domain-event.ts
export class UserRegistered_DomainEvent extends Base_DomainEvent {
  constructor(
    public readonly userId: Id,
    public readonly email: Email,
    public readonly role: UserRole,
    public readonly authProvider: AuthProvider = 'email',
  ) {
    super(userId);
  }
}

Domain events stay inside the bounded context. When something needs to cross the boundaries of a bounded context, an integration event must be used instead. See Event-Driven Architecture.

Further reading

This page covers how DDD is applied in this codebase, not DDD itself. If the ideas are new to you, these are good places to go deeper, in rough reading order:

  • Domain-Driven Design Distilled — Vaughn Vernon. A thin book that covers the whole vocabulary (bounded contexts, aggregates, events) without the weight of the originals.
  • Learning Domain-Driven Design — Vlad Khononov. Practical and modern; maps business problems to the patterns used here.
  • Domain-Driven Design — Eric Evans. The original book that named the discipline; heavier, best read once the basics have clicked.
  • DDD Reference — Evans' free condensed definitions of every pattern.