Тактические блоки: сущности, объекты-значения, агрегаты, сервисы
В стратегическом DDD мы решали, какие модели вообще нужно строить, а в ограниченных контекстах — где проходят их границы. Теперь мы внутри одного контекста, у нас есть единый язык, и надо ответить на приземлённый вопрос:
из каких деталей собирается модель, чтобы бизнес-правило имело ровно одно место жительства?
Ответ Эванса — небольшой набор «тактических» блоков: Value Object, Entity, Aggregate, Domain Service, Factory, Repository, Domain Event. Их часто зубрят как определения из глоссария, и это худший способ: определения тривиальны, а вся сложность — в выборе. В этой статье мы разберём каждый блок с трёх сторон: интуиция и «зачем», строгие правила, и цена решения (производительность, конкурентный доступ, тестируемость).
Репозитории мы здесь только обозначим — им целиком посвящена следующая статья; доменные события — статья 05.
1. Карта блоков: кто кому подчиняется
Прежде чем разбирать по одному — общая картина. Блоки не равноправны: между ними есть строгая иерархия владения и доступа.
Из этой диаграммы уже видны три правила, которые нарушают в 90 % проектов:
- Репозиторий бывает только у корня агрегата — не у каждой сущности.
- Один агрегат ссылается на другой только по идентификатору, не по объектной ссылке.
DomainServiceпоявляется, когда операция не помещается ни в один корень, а не «для всей логики».
2. Value Object: самый недооценённый блок
2.1. Интуиция
Спросите себя: важно ли, который именно это экземпляр? Если купюра в 100 ₽ у вас в кошельке взаимозаменяема с любой другой сотней — это значение. Если бухгалтерия отслеживает конкретную купюру по серии и номеру — это сущность. Разница не в типе данных, а в том, нужно ли домену помнить судьбу экземпляра.
2.2. Три обязательных свойства
| Свойство | Что означает | Что ломается без него |
|---|---|---|
| Равенство по значению | Money(100,"RUB") == Money(100,"RUB") |
сравнения через is/ссылку, дубли в Set, баги в тестах |
| Неизменяемость | «изменение» = новый экземпляр | общая ссылка мутируется из чужого кода, гонки в многопоточке |
| Самовалидация | невалидный экземпляр нельзя создать | проверки размазываются по всем вызывающим |
Последнее — ключевое и называется «делай невалидные состояния непредставимыми»
(термин из типизированного FP, но в DDD работает так же). Если Email валидируется в конструкторе,
то любая функция, принимающая Email, уже не обязана проверять формат — это выражено в типе.
2.3. Код
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
@dataclass(frozen=True, slots=True) # frozen -> неизменяемость + __hash__ + __eq__ по полям
class Money:
"""Деньги: сумма всегда с фиксированной точностью и валютой."""
amount: Decimal
currency: str
def __post_init__(self) -> None:
# Самовалидация: невалидные деньги просто не могут существовать.
if len(self.currency) != 3 or not self.currency.isupper():
raise ValueError(f"валюта должна быть ISO-4217, получено: {self.currency!r}")
# object.__setattr__ — единственный способ нормализовать поле у frozen-датакласса
object.__setattr__(self, "amount",
Decimal(self.amount).quantize(Decimal("0.01"), ROUND_HALF_UP))
def __add__(self, other: "Money") -> "Money":
self._same_currency(other)
return Money(self.amount + other.amount, self.currency) # НОВЫЙ объект
def __sub__(self, other: "Money") -> "Money":
self._same_currency(other)
return Money(self.amount - other.amount, self.currency)
def __mul__(self, k: int | Decimal) -> "Money":
return Money(self.amount * Decimal(k), self.currency)
def is_negative(self) -> bool:
return self.amount < 0
def _same_currency(self, other: "Money") -> None:
# Смешение валют — классический продакшн-баг; ловим его типом, а не код-ревью.
if self.currency != other.currency:
raise ValueError(f"нельзя смешивать {self.currency} и {other.currency}")
def __str__(self) -> str:
return f"{self.amount} {self.currency}"
Обратите внимание: Money ничего не знает ни о заказе, ни о базе. Это чистая функция от данных,
её юнит-тесты выполняются за микросекунды и не требуют ни фикстур, ни моков.
В типизированных языках это дешевле: C# даёт равенство по значению из коробки через
record / readonly record struct
с валидацией в init-конструкторе, Java — через record с компактным конструктором,
Go — через структуру-значение с конструктором NewMoney, возвращающим (Money, error).
2.4. Почему это «недооценённый» блок
Практическое наблюдение из книг Вернона и Эванса: чем больше в модели объектов-значений и чем меньше сущностей, тем модель проще. Значения:
- не требуют идентификаторов и, следовательно, не требуют хранения истории;
- безопасно шарятся между потоками (иммутабельность = отсутствие гонок);
- свободно кэшируются и мемоизируются;
- позволяют выразить бизнес-правило в типе, а не в
if.
Стоимость: аллокации. Для 99 % бизнес-приложений это шум на фоне сетевых вызовов. Для горячих
петель — используйте struct/record struct в C#, slots=True в Python, значения по value в Go.
Практика: когда в сущности накапливается «облако» примитивных полей —
deliveryCountry,deliveryCity,deliveryStreet,deliveryZip— это почти всегда непроявленный Value ObjectAddress. Рефакторинг называется Replace Data Value with Object.
3. Entity: идентичность и жизненный цикл
Сущность нужна там, где домен обязан различать экземпляры несмотря на изменение всех атрибутов. Клиент, сменивший имя, фамилию, email и телефон, остался тем же клиентом.
3.1. Правила
- Равенство — строго по идентификатору, никогда по полям.
- Идентификатор неизменяем и, желательно, генерируется в домене (UUIDv7, ULID), а не базой — тогда объект валиден до сохранения и его можно тестировать без БД.
- Идентификатор сам полезно сделать типизированным значением (
OrderId, а не голыйUUID) — это ловит перепутанные аргументы на этапе компиляции/аннотаций.
import uuid
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class OrderId:
"""Типизированный идентификатор: OrderId нельзя случайно передать вместо CustomerId."""
value: uuid.UUID = field(default_factory=uuid.uuid4)
class Entity:
"""Базовый класс: равенство и хэш — исключительно по id."""
def __init__(self, entity_id) -> None:
self._id = entity_id
@property
def id(self):
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))
Частая ошибка ORM-мира: сравнение сущностей по всем полям (сгенерированный
equals) ломаетSet/Mapровно в момент, когда объект мутируется после добавления в коллекцию. Подробнее — Vlad Mihalcea, «How to implement equals and hashCode using the JPA entity identifier».
3.2. Дерево решений: чем должно быть понятие
из единого языка"] --> B{"Нужно ли различать
два экземпляра с
одинаковыми полями?"} B -- Нет --> C{"Есть ли поведение,
зависящее от данных?"} C -- Да --> VO["Value Object
Money, Address, DateRange"] C -- Нет --> VO2["Value Object
или просто тип-обёртка"] B -- Да --> D{"Отслеживаем ли
жизненный цикл
и историю?"} D -- Нет --> VO3["Скорее всего Value Object
с суррогатным ключом в БД"] D -- Да --> E{"Есть ли инварианты
между этим и
другими объектами?"} E -- Нет --> ENT["Entity внутри
чужого агрегата"] E -- Да --> F{"Должны ли эти инварианты
быть истинны СРАЗУ
после транзакции?"} F -- Да --> AGG["Aggregate Root
своя транзакционная граница"] F -- "Нет, допустим лаг" --> SEP["Отдельный агрегат
+ доменное событие"] G["Операция, а не понятие"] --> H{"Принадлежит ли она
естественно ОДНОМУ
объекту?"} H -- Да --> M["Метод сущности
или значения"] H -- Нет --> I{"Использует ли она
инфраструктуру
БД, HTTP, время?"} I -- Нет --> DS["Domain Service"] I -- Да --> AS["Application Service
оркестрация, не логика"]
Это дерево стоит держать перед глазами первые полгода работы с DDD — оно снимает 80 % споров на код-ревью.
4. Aggregate: главная идея тактического DDD
Если из всего DDD оставить один блок, это будет агрегат. Всё остальное — реализуемо и без него.
4.1. Зачем
Проблема, которую решает агрегат, звучит так: какой набор объектов должен быть согласован в один и тот же момент времени? Без ответа система вырождается в клубок, где любой код может менять любой объект, и никто не гарантирует, что сумма строк заказа равна его итогу.
Агрегат — это:
- кластер сущностей и значений, рассматриваемый как одно целое;
- с единственной точкой входа — корнем (aggregate root);
- который охраняет инварианты кластера;
- и является границей транзакционной согласованности: агрегат целиком загружается, целиком проверяется и целиком сохраняется в одной транзакции.
4.2. Четыре правила Вернона
Вон Вернон в серии «Effective Aggregate Design» (три PDF, обязательное чтение) сформулировал правила, которые с тех пор считаются каноном:
- Моделируй истинные инварианты внутри границ согласованности. В агрегат попадает только то, что обязано быть согласовано немедленно. Всё, что терпит секунду задержки, — снаружи.
- Проектируй маленькие агрегаты. Идеал — корень плюс несколько значений. Большой агрегат означает большие транзакции, блокировки и конфликты записи.
- Ссылайся на другие агрегаты только по идентификатору. Никаких
order.customer.balance. - Обновляй другие агрегаты в конечном счёте (eventual consistency) — через доменные события и отдельные транзакции.
Правила 2 и 4 — прямое следствие правила 1, а правило 3 — техническая страховка от их нарушения: если у вас в руках нет объектной ссылки, вы физически не сможете изменить чужой агрегат.
4.3. Практический критерий границы
Задайте бизнес-эксперту вопрос в такой форме:
«Если правило X нарушится на 2 секунды, а потом само починится — это катастрофа или нормально?»
- «Катастрофа, деньги уйдут» → внутри одной транзакции, один агрегат.
- «Нормально, лишь бы к вечеру сошлось» → разные агрегаты, событие, компенсация.
Классический пример: сумма позиций и итог заказа обязаны сходиться всегда (один агрегат), а «клиент не может иметь больше 5 активных заказов» вполне терпит проверку с лагом — и это правильно, потому что иначе агрегат «Клиент» превратится в бутылочное горлышко для всех заказов.
4.4. Жизненный цикл и защита переходов
инвариант: lines не пусты,
total > 0, адрес задан Placed --> Paid: markPaid(payment)
инвариант: payment.amount == total Placed --> Cancelled: cancel(reason) Paid --> Shipped: ship(tracking) Paid --> Cancelled: cancel(reason)
→ событие OrderCancelled
→ возврат средств отдельной транзакцией Shipped --> Delivered: confirmDelivery() Shipped --> Cancelled: cancel(reason)
ТОЛЬКО tier=PREMIUM
и прошло < 30 минут Delivered --> [*] Cancelled --> [*] note right of Draft В Draft разрешено менять состав. Во всех остальных состояниях addLine() бросает исключение. end note
Такая диаграмма — не украшение: это спецификация методов корня. Каждая стрелка = публичный метод, каждая подпись на стрелке = проверка в начале метода, отсутствие стрелки = исключение.
4.5. Код агрегата
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class OrderStatus(str, Enum):
DRAFT = "DRAFT"; PLACED = "PLACED"; PAID = "PAID"
SHIPPED = "SHIPPED"; DELIVERED = "DELIVERED"; CANCELLED = "CANCELLED"
class DomainError(Exception):
"""Нарушение бизнес-правила. Отличается от инфраструктурной ошибки: её показывают пользователю."""
@dataclass(frozen=True, slots=True)
class OrderLine: # Value Object внутри агрегата
sku: str
quantity: int
unit_price: Money
def __post_init__(self) -> None:
if self.quantity <= 0:
raise DomainError("количество должно быть положительным")
@property
def total(self) -> Money:
return self.unit_price * self.quantity
class Order(Entity):
"""Корень агрегата. ЕДИНСТВЕННОЕ место, где меняется состояние заказа."""
CANCEL_WINDOW = timedelta(minutes=30)
MAX_LINES = 100 # защита от неограниченного роста агрегата
def __init__(self, order_id: OrderId, customer_id: "CustomerId", currency: str) -> None:
super().__init__(order_id)
self._customer_id = customer_id # ССЫЛКА ПО ID, не объект Customer
self._currency = currency
self._lines: list[OrderLine] = []
self._status = OrderStatus.DRAFT
self._shipped_at: datetime | None = None
self._version = 0 # для оптимистичной блокировки
self._events: list["DomainEvent"] = []
# ---------- запросы ----------
@property
def total(self) -> Money:
# Инвариант «итог == сумма строк» поддерживается вычислением, а не хранением:
# это самый дешёвый способ сделать его невозможным для нарушения.
return sum((l.total for l in self._lines), Money(0, self._currency))
@property
def status(self) -> OrderStatus:
return self._status
# ---------- команды ----------
def add_line(self, sku: str, quantity: int, unit_price: Money) -> None:
self._require(self._status is OrderStatus.DRAFT,
"состав заказа меняется только в черновике")
if unit_price.currency != self._currency:
raise DomainError("валюта позиции не совпадает с валютой заказа")
if len(self._lines) >= self.MAX_LINES:
raise DomainError(f"в заказе не более {self.MAX_LINES} позиций")
existing = next((i for i, l in enumerate(self._lines) if l.sku == sku), None)
if existing is not None:
old = self._lines[existing]
self._lines[existing] = OrderLine(sku, old.quantity + quantity, unit_price)
else:
self._lines.append(OrderLine(sku, quantity, unit_price))
def place(self) -> None:
self._require(self._status is OrderStatus.DRAFT, "заказ уже оформлен")
self._require(bool(self._lines), "нельзя оформить пустой заказ")
self._require(not self.total.is_negative(), "итог не может быть отрицательным")
self._status = OrderStatus.PLACED
self._raise(OrderPlaced(order_id=self.id, customer_id=self._customer_id,
total=self.total))
def mark_paid(self, paid: Money) -> None:
self._require(self._status is OrderStatus.PLACED, "оплатить можно только оформленный заказ")
self._require(paid == self.total, "сумма платежа не совпадает с итогом заказа")
self._status = OrderStatus.PAID
def ship(self, tracking: str, now: datetime) -> None:
self._require(self._status is OrderStatus.PAID, "отгружается только оплаченный заказ")
self._status = OrderStatus.SHIPPED
self._shipped_at = now
def cancel(self, reason: str, customer_tier: str, now: datetime) -> None:
"""Отмена — то самое правило из вступления к треку, теперь в ОДНОМ месте."""
if self._status in (OrderStatus.CANCELLED, OrderStatus.DELIVERED):
raise DomainError(f"заказ в статусе {self._status} отменить нельзя")
if self._status is OrderStatus.SHIPPED:
# Исключение для премиум-клиентов в узком временном окне.
in_window = self._shipped_at is not None and now - self._shipped_at < self.CANCEL_WINDOW
self._require(customer_tier == "PREMIUM" and in_window,
"отгруженный заказ отменяет только PREMIUM в течение 30 минут")
self._status = OrderStatus.CANCELLED
# Возврат бонусов и снятие резерва — ДРУГИЕ агрегаты, поэтому событие, а не прямой вызов.
self._raise(OrderCancelled(order_id=self.id, customer_id=self._customer_id,
reason=reason, refund=self.total))
# ---------- служебное ----------
def pull_events(self) -> list["DomainEvent"]:
events, self._events = self._events, []
return events
def _raise(self, event: "DomainEvent") -> None:
self._events.append(event)
@staticmethod
def _require(condition: bool, message: str) -> None:
if not condition:
raise DomainError(message)
Ключевые детали, которые легко пропустить:
- Нет ни одного публичного сеттера. Единственный способ изменить заказ — вызвать команду
на языке домена (
place,ship,cancel). Это и есть «инкапсуляция инвариантов». nowпередаётся аргументом, а не берётся изdatetime.now(). Домен не должен зависеть от системных часов: иначе тест «отмена через 31 минуту» становится либо flaky, либо требует моков.totalвычисляется, а не хранится. Инвариант, который невозможно нарушить, лучше инварианта, который проверяется.MAX_LINES— не каприз, а осознанная защита границы: агрегат должен помещаться в память и в транзакцию.- Внешние эффекты — события, а не вызовы.
cancel()не трогает бонусы напрямую.
4.6. Тест как доказательство ценности
def test_premium_cancels_shipped_order_within_window():
order = Order(OrderId(), CustomerId(), "RUB")
order.add_line("SKU-1", 2, Money("150.00", "RUB"))
order.place()
order.mark_paid(Money("300.00", "RUB"))
t0 = datetime(2026, 7, 16, 12, 0, tzinfo=timezone.utc)
order.ship("TRK-1", now=t0)
order.cancel("передумал", customer_tier="PREMIUM", now=t0 + timedelta(minutes=29))
assert order.status is OrderStatus.CANCELLED
assert isinstance(order.pull_events()[0], OrderCancelled)
def test_regular_customer_cannot_cancel_shipped_order():
...
with pytest.raises(DomainError, match="PREMIUM"):
order.cancel("передумал", customer_tier="REGULAR", now=t0 + timedelta(minutes=1))
Ни базы, ни HTTP, ни контейнера, ни моков. Время выполнения — единицы миллисекунд. Именно ради
этого и городился огород: сложные правила тестируются как чистые функции. Если ваши тесты
доменной логики требуют @SpringBootTest или testcontainers — логика утекла из домена.
5. Размер агрегата: главный trade-off
Это место, где DDD перестаёт быть философией и становится инженерией производительности.
5.1. Что стоит большой агрегат
Пусть агрегат «Клиент» содержит коллекцию всех заказов. Тогда:
| Аспект | Стоимость |
|---|---|
| Загрузка | O(k) строк из БД на каждую операцию, где k — число заказов клиента (у оптовика — 50 000) |
| Память | весь граф в heap на каждый запрос |
| Транзакция | блокировка/версия строки клиента на время любой операции с любым его заказом |
| Конкуренция | два оператора, оформляющих разные заказы одного клиента, конфликтуют между собой |
Формально: при оптимистичной блокировке вероятность конфликта растёт примерно как 1 - e^(-λT),
где λ — частота команд к агрегату, T — длительность транзакции. Расширяя границу агрегата,
вы увеличиваете и λ (больше команд попадает в один агрегат), и T (больше данных грузим),
то есть конфликты растут быстрее, чем линейно. Практический предел для «горячих» агрегатов
в веб-нагрузке — единицы команд в секунду на экземпляр.
5.2. Что стоит маленький агрегат
Оборотная сторона: правила, пересекающие границу, теперь требуют оркестрации, событий и компенсаций. Ошибка становится не «исключением при коммите», а «расхождением, которое надо чинить бизнес-процессом». Появляются saga/process manager, идемпотентность, дедупликация.
5.3. Как выбирать
- Начните с самого маленького агрегата, который защищает истинный инвариант.
- Расширяйте границу, только когда бизнес требует немедленной согласованности и вы это подтвердили формулировкой «нарушение на 2 секунды = катастрофа».
- Проверяйте гипотезу нагрузкой: если у агрегата больше нескольких команд в секунду — вы либо взяли границу слишком широко, либо вам нужен другой паттерн (см. конфликтующие стратегии в архитектурных паттернах).
5.4. Оптимистичная блокировка — обязательный спутник агрегата
Граница транзакционной согласованности бессмысленна, если два процесса могут одновременно прочитать и записать один агрегат.
-- Классическая оптимистичная блокировка: версия — часть условия обновления.
UPDATE orders
SET status = :new_status,
version = version + 1
WHERE id = :id
AND version = :expected_version; -- 0 обновлённых строк => конкурентное изменение
if cursor.rowcount == 0:
raise ConcurrencyError("агрегат изменён параллельно, повторите команду")
Клиент (или шина команд) делает retry — и повторно проигрывает бизнес-команду на свежем состоянии, а не мержит поля. В этом и смысл: агрегат сам решит, допустим ли переход на новых данных. Подробный разбор — в статье про репозитории.
6. Domain Service: логика без естественного владельца
6.1. Когда он действительно нужен
Три честных признака:
- Операция существенна для домена и звучит на едином языке (
перевести деньги,подобрать тариф). - Она затрагивает несколько агрегатов или требует доменного знания, не принадлежащего ни одному.
- Запихивание её в любой из агрегатов сделало бы модель хуже (например,
Accountначал бы знать про другойAccount).
Канонический пример — перевод между счетами: он не принадлежит ни счёту-источнику, ни счёту-получателю.
class TransferService:
"""Доменный сервис: без состояния, без инфраструктуры, оперирует агрегатами."""
def transfer(self, source: Account, target: Account,
amount: Money, policy: "FeePolicy") -> None:
if source.id == target.id:
raise DomainError("перевод самому себе бессмысленен")
fee = policy.fee_for(source, amount) # доменное правило, тоже без инфраструктуры
source.withdraw(amount + fee) # инварианты счёта проверяет сам счёт
target.deposit(amount)
Заметьте: сервис не проверяет достаточность средств — это инвариант счёта, и он остаётся
в Account.withdraw. Доменный сервис только координирует, он не отбирает у агрегатов их правила.
6.2. Три разных «сервиса» — не путайте
Это источник большей части путаницы в проектах:
| Тип | Где живёт | Знает про БД/HTTP | Пример |
|---|---|---|---|
| Domain Service | слой домена | нет | TransferService, PricingService |
| Application Service (use case) | слой приложения | да — оркестрирует репозитории и транзакции | PlaceOrderHandler |
| Infrastructure Service | слой инфраструктуры | да — это и есть инфраструктура | SmtpMailer, S3Storage |
Application Service не содержит бизнес-правил. Его работа: загрузить агрегаты, вызвать один
доменный метод, сохранить, опубликовать события. Если в нём появился if про бизнес — правило
утекло из домена, и вы на пути к анемичной модели
(Fowler, «AnemicDomainModel»).
6.3. Как это выглядит в одном запросе
CancelOrderHandler participant OR as OrderRepository participant O as Order (агрегат) participant CR as CustomerRepository participant UoW as Unit of Work participant Bus as Event Dispatcher participant LH as LoyaltyHandler C->>AS: cancel(orderId, reason) AS->>UoW: begin() AS->>OR: find_by_id(orderId) OR-->>AS: Order (v=7) AS->>CR: find_tier(customerId) CR-->>AS: "PREMIUM" AS->>O: cancel(reason, "PREMIUM", now) Note over O: проверка ВСЕХ инвариантов
внутри агрегата O-->>AS: ok + [OrderCancelled] AS->>OR: save(order, expected_version=7) OR-->>AS: ok (v=8) AS->>UoW: commit() Note over AS,UoW: транзакция №1 закрыта:
заказ согласован AS->>Bus: publish(OrderCancelled) Bus->>LH: handle(OrderCancelled) LH->>LH: транзакция №2:
вернуть бонусы клиенту Note over LH: конечная согласованность
между агрегатами
Две транзакции — не недостаток реализации, а прямое следствие правила «агрегат = граница согласованности». Надёжная доставка события между ними обеспечивается паттерном Transactional Outbox — разбираем в статье про доменные события.
7. Factory и Specification: два вспомогательных блока
7.1. Factory
Нужна, когда сборка валидного агрегата сложна настолько, что конструктор перестаёт быть читаемым: многошаговая валидация, восстановление из истории событий, вычисление начального состояния из внешних данных.
class OrderFactory:
def __init__(self, catalog: "CatalogPort", pricing: "PricingService") -> None:
self._catalog, self._pricing = catalog, pricing
def from_cart(self, cart: "Cart", customer: "Customer") -> Order:
"""Собираем заказ так, чтобы он НИКОГДА не существовал в невалидном состоянии."""
order = Order(OrderId(), customer.id, currency=customer.currency)
for item in cart.items:
product = self._catalog.get(item.sku) # порт, не прямой SQL
price = self._pricing.price_for(product, customer, item.quantity)
order.add_line(item.sku, item.quantity, price)
return order
Правило: фабрика создаёт целиком или не создаёт вовсе. Полусобранный агрегат наружу не выходит. Если сборка тривиальна — конструктора достаточно, фабрика будет лишним слоем.
7.2. Specification
Спецификация — объект-предикат для правил отбора, которые нужны и в памяти, и в запросе к БД.
class Specification:
def is_satisfied_by(self, candidate) -> bool: raise NotImplementedError
def __and__(self, other): return AndSpec(self, other)
class OverdueInvoice(Specification):
def __init__(self, today): self._today = today
def is_satisfied_by(self, invoice) -> bool:
return invoice.due_date < self._today and not invoice.is_paid
Плюс: правило «просроченный счёт» существует в одном месте и переиспользуется в валидации, в фильтрации и в отчёте. Минус, о котором молчат учебники: чтобы спецификация работала на стороне БД, её приходится транслировать в SQL/ORM-выражение — и это либо дублирование логики, либо нетривиальный транслятор. Практический совет: заводите спецификации для правил, которые реально нужны в двух режимах; для остальных достаточно метода на агрегате или запроса в репозитории.
8. Типичные ошибки
- Анемичная модель. Классы с геттерами/сеттерами, вся логика — в
*Service. Формально DDD-папки есть, содержания нет. Диагностика: попробуйте написать юнит-тест бизнес-правила без моков. Не получается — модель анемична. - Агрегат = таблица. Границу проводят по схеме БД, а не по инвариантам. Симптом: агрегаты идеально повторяют ER-диаграмму, а «Клиент» содержит всё на свете.
- Объектные ссылки между агрегатами.
order.customer.tierвместоcustomer_id— приводит к ленивой подгрузке половины БД, к N+1 и к возможности изменить чужой агрегат в обход его правил. - Публичные сеттеры на корне. Один
order.set_status(SHIPPED)обнуляет всю машину состояний. Если поле нужно снаружи только для чтения — делайте property без сеттера. - Сущность там, где нужно значение. Каждое понятие превращают в таблицу с
id. Модель разбухает, появляется бессмысленное отслеживание жизненного цикла уAddress. datetime.now()внутри домена. Делает правила непроверяемыми и зависимыми от окружения. Передавайте время параметром или инжектьтеClock.- Domain Service как свалка. Всё, что «не влезло», сваливается в сервисы — и модель снова анемична. Domain Service — исключение, а не место по умолчанию.
- Валидация формата и бизнес-инварианты в одной куче. «Email похож на email» — это валидация на границе (DTO, схема запроса). «Клиент не может иметь два активных абонемента» — это инвариант домена. Смешение приводит к тому, что домен занимается парсингом строк.
- Агрегат публикует события, но их никто не забирает.
pull_events()не вызывается в Unit of Work — события молча теряются. Это самый коварный баг, потому что тесты домена его не ловят. - Тактика без стратегии. Агрегаты и значения внутри неправильно проведённых границ контекстов не спасают: вы получите красиво инкапсулированный, но неправильный домен. Подробнее — в типичных ошибках внедрения.
9. Как это применяют в проде
Не везде. Тактические блоки во всей строгости оправданы в ядровом поддомене — там, где живёт конкурентное преимущество. Для CRUD-подсистем (справочники, настройки, админка) агрегаты и значения — чистые накладные расходы; там честнее активная запись или прямой SQL. Смешение стилей в одном приложении — норма, а не грех.
Что обычно приживается даже в командах, не практикующих DDD целиком:
- Value Objects для денег, идентификаторов, диапазонов дат — почти безусловная победа, дешёвая и мгновенно окупающаяся.
- Отсутствие публичных сеттеров и методы-команды на языке домена.
- Оптимистичная блокировка по версии агрегата.
- Ссылки между агрегатами по id.
Что чаще всего вызывает боль:
- Маппинг агрегата на ORM. Инкапсулированные приватные поля плохо дружат с ленивой загрузкой и
прокси. Решения: EF Core с
backing fields и
owned types для значений;
Hibernate с
@Embeddableи доступом через поля; в Python — явный маппинг «домен ↔ ORM-модель» вручную. Подробности — в следующей статье. - Чтение. Агрегат оптимизирован под запись и инварианты, а не под отчёты. Попытка строить списочные экраны через репозитории агрегатов приводит к N+1 и деградации. Ответ — отдельная модель чтения (CQRS): запросы идут в БД напрямую или в проекции, минуя домен.
- Дисциплина команды. Границы агрегатов держатся не компилятором, а договорённостями. Помогают
архитектурные тесты (ArchUnit в Java, NetArchTest в .NET,
import-linterв Python), которые падают, если из домена появился импорт инфраструктуры.
Как выглядит зрелая раскладка кода:
src/ordering/
├── domain/ # ноль импортов инфраструктуры
│ ├── model/order.py # агрегат, значения, события
│ ├── services/pricing.py # доменные сервисы
│ └── ports/repository.py # ИНТЕРФЕЙС репозитория живёт в домене
├── application/ # use cases, транзакции, оркестрация
│ └── cancel_order.py
└── infrastructure/ # реализация портов
├── sqlalchemy_order_repo.py
└── outbox.py
Интерфейс репозитория в домене, реализация в инфраструктуре — это принцип инверсии зависимостей из SOLID, в архитектурном масштабе известный как «гексагональная архитектура» / «порты и адаптеры».
10. Мини-итог
- Value Object — равенство по значению, неизменяемость, самовалидация. Стремитесь к тому, чтобы значений было много, а сущностей мало: это самый дешёвый способ упростить модель.
- Entity — равенство по идентификатору, жизненный цикл, типизированный
id, никогда не сравнивать по полям. - Aggregate — граница транзакционной согласованности и единственный охранник инвариантов. Маленький, с ссылками по id, без публичных сеттеров, с версией для оптимистичной блокировки.
- Domain Service — редкое исключение для операций без естественного владельца; не путать с Application Service, который оркестрирует, но не решает.
- Factory — сборка «всё или ничего»; Specification — переиспользуемый предикат, если он действительно нужен в двух местах.
- Главный тест качества модели: можно ли покрыть бизнес-правило юнит-тестом без БД, HTTP и моков.
Источники
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003 — части II и III, первоисточник всех блоков. Бесплатная выжимка автора: Domain-Driven Design Reference.
- Vaughn Vernon. Implementing Domain-Driven Design. Addison-Wesley, 2013 — главы 5–12, самое практичное изложение тактики.
- Vaughn Vernon. Effective Aggregate Design (Part I–III) — четыре правила проектирования агрегатов, обязательно к прочтению.
- Scott Millett, Nick Tune. Patterns, Principles, and Practices of Domain-Driven Design. Wrox, 2015.
- Martin Fowler. AnemicDomainModel, ValueObject, DDD_Aggregate.
- Harry Percival, Bob Gregory. Architecture Patterns with Python — бесплатная онлайн-версия; главы 1–8 — ровно эти блоки на Python.
- Microsoft: Designing a DDD-oriented microservice — практическая реализация с EF Core.
- Chris Richardson. Transactional Outbox — надёжная публикация событий из агрегата.
Что дальше
Мы спроектировали агрегат, который защищает инварианты в памяти. Остался вопрос, который мы несколько раз откладывали: как загрузить и сохранить его целиком, не позволив базе данных продиктовать модели свою форму, и как удержать транзакцию ровно на границе агрегата.
Читайте: Репозитории, единица работы и персистентность без протечек.