Опирается на правила:
R-ENT-1…R-ENT-5иR-ENT-X1…R-ENT-X5из DDD Tactical Style Guide → раздел 1. Entity.
Важно знать
- Entity — это объект с идентичностью. Два
Orderравны, если у них одинаковыйid, а не одинаковые поля.- Наследуем
Entity<ID>изddd-building-blocks— там уже сделанfinal equals/hashCodeпоgetId(). Переопределять нельзя.- Поле
id—final. Setter дляidзапрещён; ID присваивается в конструкторе и не меняется до конца жизни объекта.- Конструктор валидирует все инварианты. Невалидной Entity не должно существовать в принципе —
Objects.requireNonNull, явныеIllegalArgumentExceptionдля domain-инвариантов нижнего уровня.- Изменение состояния — только через бизнес-методы (
changeEmail,deactivate,markPaid). Публичные сеттеры — antipattern.- Ссылки на другие агрегаты — по ID (
CustomerId,OrderId). Не объект.- Класс без поведения, из одних геттеров/сеттеров — это анемичная модель, не Entity.
Entity — основной строительный блок domain-слоя. От обычного DTO её отличают три вещи: устойчивая идентичность (ID), инкапсулированное поведение (методы с бизнес-смыслом) и валидируемые инварианты (Entity не может существовать в невалидном состоянии). Если этих трёх свойств нет — это не Entity, это data class. Раскрытие раздела 1 гайда.
Наследование Entity
R-ENT-1: любая сущность с идентичностью наследует Entity<ID> из ddd-building-blocks. Если объект — ValueObject (см. соседнюю статью) или примитив (число, строка с конкретным значением) — наследование не нужно.
public final class Order extends Entity<OrderId> {
private final OrderId id;
private OrderStatus status;
private final List<OrderItem> items;
public Order(OrderId id, List<OrderItem> items) {
this.id = Objects.requireNonNull(id, "OrderId required");
this.items = List.copyOf(items);
this.status = OrderStatus.NEW;
}
@Override
public OrderId getId() {
return id;
}
}
OrderItem — внутренняя Entity того же агрегата, тоже наследует Entity<OrderItemId>. Money — ValueObject, наследование не нужно. OrderStatus — enum, тоже без наследования.
Стабильный неизменяемый ID
R-ENT-2, R-ENT-3: ID присваивается в конструкторе и не меняется. Поле id — final.
public final class Customer extends Entity<CustomerId> {
private final CustomerId id;
// ...
public Customer(CustomerId id, ...) {
this.id = Objects.requireNonNull(id);
// ...
}
@Override
public CustomerId getId() {
return id;
}
}
Почему final:
- Identity = жизнь объекта. Если ID может измениться, два «одинаковых» Customer-а будут разными для
equalsдо и после смены ID. Нарушается базовое свойство identity-based equality. - Compile-time гарантия. Без
finalвсегда найдётся reflection-based map'ер, который заполнитidповерх существующего значения. Сfinal— невозможно. - Repository.save знает, что
idстабилен. Не нужно сравнивать «старый id» с «новым id» при upsert.
Setter для id — R-ENT-X3 напрямую: запрещён.
Equals/hashCode — из базового класса, не переопределяем
R-ENT-4: Entity<ID> уже реализует final equals(Object) и final hashCode() по getId(). Любая попытка переопределить — ошибка компиляции, и это правильно.
public final class Product extends Entity<ProductId> {
// ...
// equals/hashCode не пишем — наследуются от Entity<ID>
}
Почему identity-based equality:
- Бизнес-смысл. Два
Customer-а с одинаковым именем и email — это всё ещё два разных клиента, если у них разные ID. Эквивалентность по полям — этоValueObject, не Entity. - JPA / jOOQ совместимость. Hibernate, jOOQ-маппинг, set'ы Entity в коллекциях — всё опирается на identity-based equality. Equals по полям ломает работу со связями (см. classic пример «
Set<Order>мутирует hashCode при изменении статуса»). - Меньше bugs. Если разработчик решает «сравнивать по email» — он зашивает бизнес-предположение в
equals. Бизнес меняется, equals остаётся, начинают дублироваться объекты в коллекциях.
Антипаттерн R-ENT-X2: «сравнивать сущности по полям». Не делаем.
Конструктор валидирует инварианты
R-ENT-5: невалидная Entity не должна существовать. Конструктор — единственное место, через которое объект приходит в мир, поэтому именно там — все проверки.
public final class Order extends Entity<OrderId> {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = Objects.requireNonNull(id, "OrderId required");
this.customerId = Objects.requireNonNull(customerId, "CustomerId required");
Objects.requireNonNull(items, "items required");
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
this.items = List.copyOf(items);
}
}
Что валидируется в конструкторе:
- Обязательные ссылки и поля —
Objects.requireNonNull. - Технические инварианты —
items.isEmpty(), длина строки, диапазон числа.IllegalArgumentExceptionили narrower runtime exception. - Защитное копирование —
List.copyOf(items). Иначе клиент мутирует переданный список и обходит инкапсуляцию.
Что не валидируется в конструкторе:
- Бизнес-правила, требующие других агрегатов — это уровень use-case или Domain Service. Например, «нельзя создать Order для деактивированного Customer» — это про взаимодействие двух агрегатов, конструктор
Orderпро этого не знает.
Бизнес-методы вместо сеттеров
R-ENT-X3: публичные сеттеры — антипаттерн. Изменение состояния — через методы с бизнес-смыслом.
// ПЛОХО — анемичная модель
public class Product {
public void setStatus(ProductStatus status) { this.status = status; }
public void setPrice(BigDecimal price) { this.price = price; }
}
// service где-то ещё
product.setStatus(ProductStatus.DISCONTINUED);
product.setPrice(BigDecimal.ZERO);
productRepository.save(product);
Что здесь не так: бизнес-правило «как именно перевести продукт в DISCONTINUED» (например, обнулить цену и зафиксировать момент) растеклось по сервису. Завтра появится второе место, где «снимают с продажи», — правило придётся скопировать.
// ХОРОШО — rich domain
public final class Product extends Entity<ProductId> {
private ProductStatus status;
private Money price;
private Instant discontinuedAt;
public void discontinue() {
if (this.status == ProductStatus.DISCONTINUED) {
return;
}
this.status = ProductStatus.DISCONTINUED;
this.price = Money.ZERO;
this.discontinuedAt = Instant.now();
registerEvent(new ProductDiscontinued(this.getId(), this.discontinuedAt));
}
}
Метод discontinue() инкапсулирует правило целиком: смена статуса, обнуление цены, фиксация времени, регистрация события. Сервис вызывает product.discontinue() — деталь правила лежит в одном месте, рядом с состоянием, на которое она влияет.
Ссылки между агрегатами — по ID
R-ENT-X4: внутри агрегата можно держать ссылки на внутренние Entity. Между агрегатами — только ID.
// ПЛОХО — Order держит ссылку на Customer (другой агрегат)
public final class Order extends Entity<OrderId> {
private final Customer customer; // ← объект другого агрегата
}
// ХОРОШО
public final class Order extends Entity<OrderId> {
private final CustomerId customerId; // ← только ID
}
Зачем это:
- Транзакционные границы. Один use-case изменяет один агрегат. Если
OrderдержитCustomer— соблазн «обновлю заодно customer.lastOrderAt» — растягивает транзакцию на два агрегата, ломает локальность инвариантов. - Загрузка из persistence.
OrderRepositoryгрузитOrderцеликом (со всеми внутреннимиOrderItem), но не должен грузитьCustomer. Это делаетCustomerRepositoryотдельным запросом, в read-сценарии. - Граница согласованности. Если нужно показать «Order + имя клиента» — это read-model или join на query-side. В command-side через ID — достаточно.
Анемичная модель — это не Entity
R-ENT-X5: класс с одними геттерами и сеттерами без поведения — не Entity. Это data class, у которого случайно есть ID. Бизнес-логика, которая работает с этим классом, неизбежно растеклась по сервисам.
Признаки анемичной модели:
- 10+ полей, 20 пар
getX/setX, ноль методов с бизнес-смыслом. - Логика «как изменить state» живёт в
OrderService,OrderManager,OrderHelper. - В коде в разных местах копипаст «
order.setStatus(...); order.setUpdatedAt(...)».
Как исправить: каждое состояние, которое можно изменить, должно меняться через метод с бизнес-смыслом. Метод инкапсулирует правило целиком — валидацию, изменение состояния, регистрацию события. Сервис только координирует (загрузить агрегат → вызвать метод → сохранить).
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Переопределять equals/hashCode в наследниках Entity<ID> | R-ENT-X1 | Не пишем equals — наследуется как final из базы |
| Сравнивать Entity по полям | R-ENT-X2 | Только по ID, через equals |
| Публичные сеттеры на все поля | R-ENT-X3 | Бизнес-методы (changeEmail, deactivate, markPaid) |
Ссылка на другой агрегат как объект (Customer customer) | R-ENT-X4 | По ID (CustomerId customerId) |
| Класс из одних геттеров/сеттеров без поведения | R-ENT-X5 | Перенести логику с сервисов в Entity, удалить ненужные сеттеры |
Куда дальше
- DDD Tactical → раздел 1. Entity — нормативные формулировки
R-ENT-*. - Value Object — что использовать вместо примитивов внутри Entity.
- Aggregate Root — Entity, которая является корнем агрегата и регистрирует события.
- Domain Event — как
registerEventработает изнутри. - Repository — как Entity сохраняется и поднимается из БД.
- Java Style Guide → JS-6.3 — Lombok
@Getterдля геттеров.