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.