Опирается на правила: R-ENT-1R-ENT-5 и R-ENT-X1R-ENT-X5 из DDD Tactical Style Guide → раздел 1. Entity.

Важно знать

  • Entity — объект с идентичностью. Два OrderLine равны, если у них одинаковый id, а не одинаковые поля.
  • В TypeScript нет перегрузки === — объекты всегда сравниваются по ссылке. a === b всегда false для двух разных загрузок одного агрегата. Обязателен явный метод equals().
  • Класс наследует Entity<ID> из core/shared/building-blocks.ts. Использовать plain-интерфейс или type нельзя — TypeScript структурно типизирован, два разных «типа» с одинаковой формой взаимозаменяемы.
  • Поле idreadonly, без сеттера. Присваивается в конструкторе, не меняется.
  • Конструктор валидирует инварианты. Невалидная Entity не должна существовать.
  • Изменение состояния — только через бизнес-методы (confirm(), deactivate()). Публичные мутабельные поля — антипаттерн.
  • Ссылки на другие агрегаты — по ID (CustomerId), не объектом.
  • Класс без поведения, из одних геттеров — это анемичная модель, не Entity.

Entity — основной строительный блок domain-слоя. От DTO его отличают три вещи: устойчивая идентичность (ID), инкапсулированное поведение (методы с бизнес-смыслом) и валидируемые инварианты (Entity не может существовать в невалидном состоянии). Если этого нет — это не Entity, это data object. Раскрытие раздела 1 гайда.

Базовый класс Entity

R-ENT-1: сущность с идентичностью наследует Entity<ID> из core/shared/building-blocks.ts. Файл один на сервис, тонкий, без фреймворков:

// core/shared/building-blocks.ts
export abstract class Entity<ID> {
  protected constructor(readonly id: ID) {}

  equals(other: Entity<ID>): boolean {
    return other instanceof this.constructor && this.idEquals(other.id);
  }

  private idEquals(otherId: ID): boolean {
    return this.id instanceof ValueObject
      ? this.id.equals(otherId as ValueObject)
      : this.id === otherId;
  }
}

R-ENT-4: equals() определён в базовом классе и не переопределяется в наследниках. Сравниваем через a.equals(b), не через ===.

Стабильный readonly ID

R-ENT-2, R-ENT-3: ID задаётся через super(id) в конструкторе наследника и хранится как readonly id. Setter — запрещён.

// core/order/entity/order-line.ts
import { Entity } from '../../shared/building-blocks';
import { OrderLineId } from '../value-object/ids';
import { ProductId } from '../../product/value-object/ids';
import { Money } from '../value-object/money';
import { DomainError } from '../../shared/domain-error';

export class OrderLine extends Entity<OrderLineId> {
  private qty: number;

  constructor(
    id: OrderLineId,
    readonly productId: ProductId,
    qty: number,
    private readonly price: Money,
  ) {
    if (qty <= 0) throw new DomainError('qty must be positive');
    super(id);
    this.qty = qty;
  }

  subtotal(): Money {
    return this.price.multiply(this.qty);
  }

  increaseQty(delta: number): void {
    if (delta <= 0) throw new DomainError('delta must be positive');
    this.qty += delta;
  }
}

Поле idreadonly (через super(id) в базовом классе). Поле qtyprivate, меняется только через increaseQty(). Поле pricereadonly, после создания не меняется.

Конструктор валидирует инварианты

R-ENT-5: невалидная Entity не должна существовать. Конструктор — единственная точка входа, поэтому там — все проверки.

// core/customer/entity/customer.ts
export class Customer extends Entity<CustomerId> {
  private email: Email;
  private status: CustomerStatus;

  constructor(
    id: CustomerId,
    email: Email,
    private readonly registeredAt: Date,
  ) {
    if (!email) throw new DomainError('email required');
    if (!registeredAt) throw new DomainError('registeredAt required');
    super(id);
    this.email = email;
    this.status = CustomerStatus.ACTIVE;
  }

  changeEmail(newEmail: Email): void {
    if (this.status !== CustomerStatus.ACTIVE) {
      throw new DomainError('cannot change email for inactive customer');
    }
    this.email = newEmail;
  }

  deactivate(): void {
    if (this.status === CustomerStatus.INACTIVE) return;
    this.status = CustomerStatus.INACTIVE;
  }

  isActive(): boolean {
    return this.status === CustomerStatus.ACTIVE;
  }
}

Что валидируется в конструкторе: обязательные поля, технические инварианты. Что не валидируется: бизнес-правила, требующие других агрегатов — это уровень UseCase или Domain Service.

Бизнес-методы вместо публичных полей

R-ENT-X3, R-ENT-X5: публичные мутабельные поля — антипаттерн. Изменение состояния — через методы с бизнес-смыслом.

// ПЛОХО — анемичная модель
class Product {
  status: ProductStatus;
  price: Money;
}

// где-то в сервисе
product.status = ProductStatus.DISCONTINUED;
product.price = Money.zero('RUB');

Что не так: бизнес-правило «как именно перевести продукт в DISCONTINUED» (обнулить цену, зафиксировать момент) растеклось по сервису.

// ХОРОШО — rich domain
export class Product extends Entity<ProductId> {
  private status: ProductStatus;
  private price: Money;
  private discontinuedAt?: Date;

  discontinue(now: Date): void {
    if (this.status === ProductStatus.DISCONTINUED) return;
    this.status = ProductStatus.DISCONTINUED;
    this.price = Money.zero(this.price.currency);
    this.discontinuedAt = now;
  }

  currentPrice(): Money {
    return this.price;
  }
}

Метод discontinue() инкапсулирует правило целиком. Сервис вызывает product.discontinue(now) — деталь правила в одном месте.

Ссылки между агрегатами — по ID

R-ENT-X4: внутри агрегата — ссылки на внутренние Entity. Между агрегатами — только по ID.

// ПЛОХО — Order хранит объект другого агрегата
class Order extends Entity<OrderId> {
  customer: Customer;
}

// ХОРОШО
class Order extends Entity<OrderId> {
  readonly customerId: CustomerId;
}

Зачем: одна транзакция — один агрегат. Если Order держит Customer — соблазн «обновлю заодно customer.lastOrderAt» — растягивает транзакцию на два агрегата.

Почему не plain-интерфейс или type

R-ENT-X1, R-ENT-X2: TypeScript структурно типизирован. Если OrderLine — это интерфейс { id: string; productId: string }, то любой объект с такими полями — OrderLine. Два разных «Entity» с одинаковой формой взаимозаменяемы для компилятора, equals нельзя определить консистентно.

Класс + Entity<ID> фиксируют семантику: OrderLine.equals(other) проверяет instanceof this.constructor — объект другого класса с теми же полями не пройдёт.

// Сравнение — всегда через equals(), не ===
const line1 = repo.findLine(id);  // первая загрузка
const line2 = repo.findLine(id);  // вторая загрузка

line1 === line2        // false — разные объекты в памяти
line1.equals(line2)    // true — один и тот же ID

Что запрещено

АнтипаттернПравилоЧто взамен
type / interface вместо классаR-ENT-1Класс, наследующий Entity<ID>
Переопределять equals в наследникахR-ENT-X1Не пишем equals — наследуется из базы
Сравнивать Entity по полям или через ===R-ENT-X2a.equals(b)
Публичные мутабельные поляR-ENT-X3Поля private, изменение через бизнес-методы
Ссылка на другой агрегат объектомR-ENT-X4Только по ID (CustomerId)
Класс без поведения, из одних геттеровR-ENT-X5Перенести логику в Entity, удалить лишние accessor'ы

Куда дальше

  • DDD Tactical → раздел 1. Entity — нормативные формулировки R-ENT-*.
  • node/value-object.md — что использовать вместо примитивов внутри Entity.
  • node/aggregate-root.md — Entity, которая является корнем агрегата.
  • node/domain-event.md — как registerEvent работает из корня.
  • node/repository.md — как Entity сохраняется и поднимается.
  • node/module-structure.md — куда класть файлы Entity в core/.