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

Важно знать

  • Entity — это объект с идентичностью. Два Order равны, если у них одинаковый id, а не одинаковые поля.
  • Наследуем Entity<ID> из ddd-building-blocks — там уже сделан final equals/hashCode по getId(). Переопределять нельзя.
  • Поле idfinal. 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>. MoneyValueObject, наследование не нужно. OrderStatus — enum, тоже без наследования.

Стабильный неизменяемый ID

R-ENT-2, R-ENT-3: ID присваивается в конструкторе и не меняется. Поле idfinal.

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 для idR-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 для геттеров.