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

Важно знать

  • Entity имеет identity-based equality: два объекта равны, если у них одинаковый ID, независимо от остальных полей.
  • В Python нет библиотеки ddd-building-blocks — базовый класс Entity[ID] пишется вручную один раз в core/shared/building_blocks.py.
  • Не делать Entity через @dataclass без явного eq=False: дефолтный @dataclass(eq=True) генерирует equality по всем полям — это VO-семантика, нарушающая R-ENT-X2.
  • Не переопределять __eq__/__hash__ в наследниках (R-ENT-X1). Базовый Entity уже даёт правильную реализацию.
  • Идентификатор задаётся в конструкторе через self._id, наружу — только через @property id. Без сеттера (R-ENT-2, R-ENT-3).
  • Состояние меняется бизнес-методами: order.confirm(), product.reserve(qty). Публичные сеттеры (order.status = ...) — антипаттерн (R-ENT-X3).
  • Ссылки на другие агрегаты — только по ID (customer_id: CustomerId, не customer: Customer) (R-ENT-X4).
  • Анемичная модель (только геттеры/сеттеры, логика в сервисах) — антипаттерн R-ENT-X5.

Entity — объект с идентичностью: два экземпляра с одинаковым ID — это один и тот же объект домена, даже если их поля различаются. Обратное — Value Object: два Money(100, "RUB") всегда равны, идентичность не важна. Граница между Entity и VO — одна из ключевых в тактическом DDD. Раскрытие раздела 1 гайда.

Базовый класс Entity[ID]

R-ENT-1: сущность наследует Entity[ID]. Equality по id наследуется из базового класса.

# core/shared/building_blocks.py
from typing import Generic, TypeVar

ID = TypeVar("ID")


class Entity(Generic[ID]):
    def __init__(self, id_: ID) -> None:
        self._id = id_

    @property
    def id(self) -> ID:
        return self._id

    def __eq__(self, other: object) -> bool:
        return isinstance(other, type(self)) and self._id == other._id

    def __hash__(self) -> int:
        return hash((type(self).__name__, self._id))

Почему type(self).__name__ в __hash__:

  • Два объекта разных классов с одинаковым ID (OrderId(42) в Order и Invoice) не должны быть equal.
  • isinstance(other, type(self)) в __eq__ даёт строгое равенство по типу — без ложных совпадений между Entity разных агрегатов.

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

R-ENT-5: невалидная Entity не должна существовать. Конструктор проверяет все обязательные поля и бизнес-инварианты.

# core/order/entity/order_line.py
from uuid import UUID
from core.shared.building_blocks import Entity
from core.order.value_object.order_line_id import OrderLineId
from core.order.value_object.product_id import ProductId
from core.order.value_object.money import Money


class OrderLine(Entity[OrderLineId]):
    def __init__(
        self,
        id_: OrderLineId,
        product_id: ProductId,
        qty: int,
        unit_price: Money,
    ) -> None:
        if qty <= 0:
            raise ValueError(f"qty must be positive, got {qty}")
        if unit_price.amount <= 0:
            raise ValueError("unit_price must be positive")
        super().__init__(id_)
        self._product_id = product_id
        self._qty = qty
        self._unit_price = unit_price

    @property
    def product_id(self) -> ProductId:
        return self._product_id

    @property
    def qty(self) -> int:
        return self._qty

    def subtotal(self) -> Money:
        return self._unit_price.multiply(self._qty)

OrderLine — внутренняя Entity агрегата Order. Корень управляет её созданием через метод add_line(...), наружу выдаёт через tuple(self._lines).

Идентификатор неизменяем

R-ENT-2, R-ENT-3: ID задаётся один раз в конструкторе, _id приватный, только @property id без сеттера.

# ПЛОХО — изменяемый ID
class Product(Entity[ProductId]):
    def set_id(self, new_id: ProductId) -> None:
        self._id = new_id   # R-ENT-3: нарушение

# ХОРОШО — ID неизменяем
class Product(Entity[ProductId]):
    def __init__(self, id_: ProductId, name: str, price: Money) -> None:
        super().__init__(id_)
        self._name = name
        self._price = price

    @property
    def name(self) -> str:
        return self._name

    @property
    def price(self) -> Money:
        return self._price

    def update_price(self, new_price: Money) -> None:
        if new_price.amount <= 0:
            raise ValueError("price must be positive")
        self._price = new_price

Состояние меняется бизнес-методами

R-ENT-X3: публичные сеттеры — антипаттерн. Состояние меняется бизнес-методами с явной семантикой.

# ПЛОХО — анемичная модель с публичными сеттерами
class Customer(Entity[CustomerId]):
    def set_status(self, status: str) -> None:
        self._status = status   # нет валидации, нет событий

# ХОРОШО — бизнес-методы
class Customer(Entity[CustomerId]):
    def __init__(self, id_: CustomerId, email: Email) -> None:
        super().__init__(id_)
        self._email = email
        self._status = CustomerStatus.ACTIVE
        self._blocked_reason: str | None = None

    def block(self, reason: str) -> None:
        if self._status == CustomerStatus.BLOCKED:
            return
        if not reason:
            raise ValueError("block reason required")
        self._status = CustomerStatus.BLOCKED
        self._blocked_reason = reason

    def activate(self) -> None:
        self._status = CustomerStatus.ACTIVE
        self._blocked_reason = None

Бизнес-метод block(reason):

  • Валидирует входные данные.
  • Проверяет текущее состояние (идемпотентность).
  • Меняет состояние атомарно.
  • Может зарегистрировать событие (если это корень агрегата).

Не делать Entity через @dataclass без eq=False

R-ENT-X2: @dataclass(eq=True) генерирует equality по всем полям. Для Entity это VO-семантика — нарушение.

# ПЛОХО — @dataclass с eq=True (дефолт) на Entity
@dataclass
class Order:
    id: OrderId
    status: OrderStatus
    # equality по всем полям — это VO-семантика

# ПЛОХО — явный eq=True, тот же эффект
@dataclass(eq=True)
class Order:
    id: OrderId
    status: OrderStatus

# ХОРОШО — базовый класс Entity[ID]
class Order(AggregateRoot[OrderId]):
    def __init__(self, id_: OrderId, ...) -> None:
        super().__init__(id_)
        ...

Если нужен @dataclass для удобного __repr__ — использовать @dataclass(eq=False) и положиться на __eq__ из базового Entity. Но в большинстве случаев обычный класс проще.

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

R-ENT-X4: хранить объект другого агрегата нельзя.

# ПЛОХО — ссылка на объект другого агрегата
class OrderLine(Entity[OrderLineId]):
    def __init__(self, ..., product: Product) -> None:
        self._product = product   # объект другого агрегата

# ХОРОШО — только ID
class OrderLine(Entity[OrderLineId]):
    def __init__(self, ..., product_id: ProductId, unit_price: Money) -> None:
        self._product_id = product_id
        self._unit_price = unit_price

ProductId + Money unit_price — достаточно для расчёта subtotal. Если нужны название или описание продукта на read-side — это read-model, не объект Product внутри OrderLine.

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

АнтипаттернПравилоЧто взамен
Переопределять __eq__/__hash__ в наследникахR-ENT-X1Использовать реализацию из базового Entity[ID]
Сравнивать сущности по полям (@dataclass(eq=True))R-ENT-X2Entity[ID] с identity-equality по ID
Публичные сеттеры (customer.status = ...)R-ENT-X3Бизнес-методы: customer.block(reason)
Объект другого агрегата как полеR-ENT-X4ID-тип: product_id: ProductId
Анемичная модель (только геттеры/сеттеры)R-ENT-X5Логика в методах Entity/Aggregate

Куда дальше

  • DDD Tactical → раздел 1. Entity — нормативные формулировки R-ENT-*.
  • python/value-object.md — граница Entity/VO, @dataclass(frozen=True) для VO.
  • python/aggregate-root.md — Entity как внутренний объект агрегата.
  • python/module-structure.md — где живут Entity в структуре core/<bc>/entity/.