Опирается на правила:
R-ENT-1…R-ENT-5иR-ENT-X1…R-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-X2 | Entity[ID] с identity-equality по ID |
Публичные сеттеры (customer.status = ...) | R-ENT-X3 | Бизнес-методы: customer.block(reason) |
| Объект другого агрегата как поле | R-ENT-X4 | ID-тип: 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/.