Опирается на правила:
R-ENT-1…R-ENT-5иR-ENT-X1…R-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 структурно типизирован, два разных «типа» с одинаковой формой взаимозаменяемы.- Поле
id—readonly, без сеттера. Присваивается в конструкторе, не меняется.- Конструктор валидирует инварианты. Невалидная 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;
}
}
Поле id — readonly (через super(id) в базовом классе). Поле qty — private, меняется только через increaseQty(). Поле price — readonly, после создания не меняется.
Конструктор валидирует инварианты
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-X2 | a.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/.