В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.
Тактические паттерны DDD дают структуру для организации этой логики. Каждый паттерн отвечает на конкретный вопрос: что является объектом с уникальной идентичностью, что — просто значением, где граница консистентности, как части системы общаются через события.
Entity — объект с идентичностью
Представьте пользователя в системе. У него есть имя и email — они могут меняться. Но пользователь остаётся тем же пользователем, даже если поменял всё. Его идентичность — это идентификатор, а не набор полей.
Entity — объект, у которого есть уникальный идентификатор. Два объекта с одинаковыми полями, но разными ID — разные сущности.
Ключевые свойства:
- Есть стабильный ID (не меняется в течение жизни объекта).
- Состояние может меняться — но только через методы, которые проверяют бизнес-правила.
- Равенство по ID, не по полям.
- Содержит поведение, а не только данные.
Частые примеры: User, Order, Invoice, Shipment, Contract.
Типичная ошибка — «анемичная» Entity: класс с геттерами и сеттерами без правил. Тогда бизнес-логика утекает в сервисы, а объект становится структурой данных.
class User:
def __init__(self, id: UUID, email: Email, name: str) -> None:
if id is None:
raise ValueError("id is required")
if email is None:
raise ValueError("email is required")
if not name or not name.strip():
raise ValueError("name is required")
self._id = id
self._email = email
self._name = name
self._active = True
def change_email(self, new_email: Email) -> None:
if not self._active:
raise RuntimeError("Inactive user cannot change email")
if new_email is None:
raise ValueError("email is required")
self._email = new_email
def deactivate(self) -> None:
self._active = False
def __eq__(self, other: object) -> bool:
if not isinstance(other, User):
return NotImplemented
return self._id == other._id
def __hash__(self) -> int:
return hash(self._id)
Метод change_email — не просто сеттер. Он проверяет бизнес-правило: неактивный пользователь не может менять email. Это ключевое отличие от простой записи.
Value Object — объект без идентичности
Деньги — хороший пример. Две купюры по 100 рублей одинаковы — нас не интересует, «та же» ли это купюра или другая. Главное — сумма и валюта.
Value Object — объект, у которого нет собственного ID. Равенство определяется по значениям полей. После создания не меняется (иммутабельный).
Ключевые свойства:
- Нет ID.
- Иммутабельный: вместо изменения создаётся новый объект.
- Равенство по всем значимым полям.
- Проверяет свои инварианты при создании.
Частые примеры: Money, Email, PhoneNumber, Address, DateRange.
Зачем нужны Value Object: они убирают «примитивную одержимость» — когда вместо типа Email везде ходит строка str. Правила формата, валидация и операции живут внутри типа, а не разбросаны по коду.
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def __post_init__(self) -> None:
if self.amount is None:
raise ValueError("amount is required")
if self.currency is None:
raise ValueError("currency is required")
rounded = self.amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
object.__setattr__(self, "amount", rounded)
def add(self, other: "Money") -> "Money":
if self.currency is not other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
Метод add возвращает новый объект — исходный не меняется. Проверка совместимости валют встроена в операцию, а не лежит где-то снаружи. Равенство по значениям полей frozen-датакласс даёт автоматически — отдельный __eq__ писать не нужно.
Aggregate — граница консистентности
Вот реальная проблема: у заказа есть строки. Можно ли изменить строку напрямую, минуя заказ? Формально — да. Но тогда заказ оказывается в некорректном состоянии: например, сумма не пересчитана, или добавили строку в уже подтверждённый заказ.
Aggregate — это кластер Entity и Value Object с единой границей консистентности. Снаружи виден только корень (Aggregate Root) — только через него можно менять состояние агрегата.
Правила:
- Все изменения внутри агрегата идут через корень.
- Нельзя хранить прямые ссылки на внутренние объекты другого агрегата — только ID.
- Агрегат должен быть небольшим. «Бог-агрегат» с десятками объектов внутри — антипаттерн.
class Order:
def __init__(self, id: OrderId, customer_id: CustomerId) -> None:
self._id = id
self._customer_id = customer_id
self._lines: list[OrderLine] = []
self._status = OrderStatus.DRAFT
def add_line(self, product_id: ProductId, qty: int, price: Money) -> None:
self._ensure_draft()
if qty <= 0:
raise ValueError("qty must be > 0")
self._lines.append(OrderLine(product_id, qty, price))
def confirm(self) -> None:
self._ensure_draft()
if not self._lines:
raise RuntimeError("Order has no lines")
self._status = OrderStatus.CONFIRMED
def total(self) -> Money:
return reduce(
Money.add,
(line.subtotal() for line in self._lines),
Money.zero(Currency.RUB),
)
def _ensure_draft(self) -> None:
if self._status is not OrderStatus.DRAFT:
raise RuntimeError("Order is not editable")
OrderLine — внутренний объект агрегата. Снаружи его напрямую не трогают — только через методы Order.
Domain Event — что произошло в домене
Когда заказ оплачен, нужно: отправить письмо, уведомить склад, начислить бонусы. Один способ — вызвать всё это прямо в методе pay(). Но тогда Order знает про email, склад и бонусы — это нарушает границы ответственности.
Domain Event — объект, который фиксирует факт: что-то значимое произошло в домене. Агрегат публикует событие, а другие части системы реагируют самостоятельно.
Характеристики:
- Иммутабельный — факт в прошлом нельзя изменить.
- Именование в прошедшем времени:
OrderPaid,UserRegistered, неPayOrder. - Несёт контекст:
orderId,amount,paidAt— не просто маркер. - Агрегат накапливает события и отдаёт их после сохранения.
@dataclass(frozen=True, kw_only=True)
class DomainEvent:
event_id: UUID = field(default_factory=uuid4)
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(frozen=True, kw_only=True)
class OrderPaid(DomainEvent):
order_id: UUID
amount: Decimal
class AggregateRoot:
def __init__(self) -> None:
self._domain_events: list[DomainEvent] = []
def _register_event(self, event: DomainEvent) -> None:
self._domain_events.append(event)
@property
def domain_events(self) -> tuple[DomainEvent, ...]:
return tuple(self._domain_events)
def clear_events(self) -> None:
self._domain_events.clear()
Агрегат вызывает _register_event(OrderPaid(...)) при изменении состояния. После сохранения агрегата репозиторий забирает накопленные события и публикует их — это гарантирует, что события уходят только после успешной записи.
Внутренние события живут внутри одного bounded context, часто в одной транзакции. Внешние пересекают границы через брокер сообщений (Kafka, RabbitMQ) и требуют сериализации.
Repository — доступ к агрегатам
Как правило, когда нужно сохранить заказ, пишут SQL прямо в сервисе. Запросы выходят за пределы бизнес-логики, привязываются к конкретной БД, и поменять хранилище становится дорого.
Repository — абстракция доступа к хранилищу агрегатов. Он создаёт иллюзию коллекции объектов в памяти: найти, сохранить. Интерфейс живёт в слое домена, реализация — в инфраструктуре.
Ключевые правила:
- Один репозиторий — один агрегат.
- Загружает и сохраняет агрегат целиком.
- Методы в терминах домена:
find_by_id,save— не SQL. - Интерфейс в домене, реализация в инфраструктуре (инверсия зависимостей).
# Интерфейс — в домене
class OrderRepository(Protocol):
def find_by_id(self, id: OrderId) -> Order | None: ...
def save(self, order: Order) -> None: ...
# Реализация — в инфраструктуре, публикует события после сохранения
class OrderRepositoryImpl:
def __init__(self, dao: OrderDao, event_publisher: EventPublisher) -> None:
self._dao = dao
self._event_publisher = event_publisher
def save(self, order: Order) -> None:
self._dao.save(order)
for event in order.domain_events:
self._event_publisher.publish(event)
order.clear_events()
Разница с DAO: Repository работает с агрегатом целиком и думает на языке домена. DAO работает с таблицей и думает на языке хранилища.
Domain Service — логика между агрегатами
Иногда операция не принадлежит ни одному агрегату. Перевод денег между счетами: нужно снять с одного и зачислить на другой. Ни Account-от-отправителя, ни Account-получателя не должны знать друг о друге.
Domain Service — место для бизнес-логики, которая координирует несколько агрегатов или объектов домена.
Правило: сначала попробуй поместить логику в Entity или Aggregate Root. Domain Service — если логика действительно не принадлежит ни одному из них.
class TransferService:
def transfer(self, source: Account, target: Account, amount: Money) -> None:
source.withdraw(amount)
target.deposit(amount)
Domain Service живёт в слое домена и знает только про доменные объекты. Не путать с Application Service — тот живёт в слое приложения и оркестрирует: загрузить агрегат, вызвать доменную логику, сохранить.
Factory — создание агрегатов
Иногда создание агрегата — это не просто вызов конструктора. Нужно проверить бизнес-правила, сгенерировать ID, собрать объект из нескольких частей.
Factory инкапсулирует логику создания агрегата. Если конструктор справляется — фабрика не нужна.
class OrderFactory:
def create_new(self, customer: Customer) -> Order:
if customer.is_blocked():
raise RuntimeError("Blocked customer cannot place orders")
return Order(OrderId.generate(), customer.id)
Здесь проверяется бизнес-правило («заблокированный клиент не может оформить заказ») до создания агрегата. Сам конструктор Order не знает про Customer — это разделение обязанностей.
Коротко
- Entity — объект с уникальным ID; равенство по ID; содержит поведение и проверяет инварианты.
- Value Object — нет ID, иммутабельный, равенство по значениям; убирает «примитивную одержимость».
- Aggregate — кластер объектов с единой границей консистентности; снаружи виден только корень.
- Domain Event — иммутабельный факт в прошедшем времени; агрегат накапливает события, репозиторий публикует их после сохранения.
- Repository — абстракция хранилища агрегатов; интерфейс в домене, реализация в инфраструктуре.
- Domain Service — бизнес-логика, которая не принадлежит ни одному агрегату; крайнее средство, не первый выбор.
- Factory — создание агрегата со сложной логикой; если конструктор справляется — не нужна.
- Не нужно применять все паттерны сразу. Начни с Entity и Value Object, добавляй остальное по необходимости.
Что почитать дальше
- Что такое DDD и зачем он нужен — стратегический уровень: bounded context, ubiquitous language.
- Стратегические паттерны DDD — как делить систему на контексты и выстраивать отношения между ними.
- Интеграционные паттерны DDD — как bounded contexts общаются между собой.