Domain-Driven Design

The domain/ layer is the heart 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.

Aggregates

An aggregate is 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:

// libs/common/src/general/domain/base.aggregate.ts
export abstract class SharedAggregate extends AggregateRoot<IEvent> {
  id: Id;
  timestamps: Timestamps;

  constructor(id?: Id, timestamps?: Timestamps) {
    super();
    this.id = id || Id.random();
    this.timestamps = timestamps || Timestamps.create();
  }

  protected touch(): void {
    this.timestamps = new Timestamps(this.timestamps.createdAt, DateVO.now());
  }

  equals(other?: SharedAggregate | null): boolean {
    return isDeepStrictEqual(this.toValue(), other?.toValue());
  }
}

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;
  }
}

Behaviour, not setters

State changes go through intention-revealing methods (user.canAuthenticate(), outbox.markAsProcessed()) — never raw property assignment. The no-direct-entity-mutation lint rule enforces this, so invariants always run.

Value objects

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

// bounded-contexts/auth/domain/value-objects/email.vo.ts
export class Email extends StringValueObject {
  constructor(value: string, options?: { skipDisposableCheck?: boolean }) {
    super(value.toLowerCase());
    this.validate(options?.skipDisposableCheck);
  }

  validate(skipDisposableCheck = false): void {
    super.validate();
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(this.value)) {
      throw new DomainValidationException('email', this.value, `Invalid email: ${this.value}`);
    }
    if (!skipDisposableCheck && 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 and the value-object-naming rule keeps that consistent.

Domain events

Domain events record that something meaningful happened, in the past tense. They're applied on the aggregate and published in-process after the state is persisted. Every one extends Base_DomainEvent:

// libs/common/src/cqrs/base.domain-event.ts
export abstract class Base_DomainEvent implements IEvent {
  public readonly occurredOn: Date;
  public readonly id: string;

  constructor(public readonly aggregateId: Id) {
    this.occurredOn = new Date();
    this.id = Id.random().toValue();
  }

  get name(): string {
    return this.constructor.name;
  }
}

A concrete event carries the data subscribers need:

// …/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',
    public readonly emailVerified: boolean = false,
  ) {
    super(userId);
  }
}

Domain events stay inside the context. When something needs to cross a context or service boundary, a domain-event handler translates it into an integration event — see Event-Driven Architecture.

How it fits together

controller ─▶ Command ─▶ CommandHandler
                              │
                              ├─ load / create Aggregate   (domain)
                              ├─ aggregate enforces rule    (domain)
                              ├─ repository.save(...)        (infrastructure)
                              └─ publish domain events       (application)

The aggregate is the only place the rule lives; the handler just orchestrates. Next: CQRS.