Domain-Driven Design Тактические блоки: сущности, объекты-значения, агрегаты, сервисы
0%

Тактические блоки: сущности, объекты-значения, агрегаты, сервисы

Тактические блоки: сущности, объекты-значения, агрегаты, сервисы

В стратегическом DDD мы решали, какие модели вообще нужно строить, а в ограниченных контекстах — где проходят их границы. Теперь мы внутри одного контекста, у нас есть единый язык, и надо ответить на приземлённый вопрос:

из каких деталей собирается модель, чтобы бизнес-правило имело ровно одно место жительства?

Ответ Эванса — небольшой набор «тактических» блоков: Value Object, Entity, Aggregate, Domain Service, Factory, Repository, Domain Event. Их часто зубрят как определения из глоссария, и это худший способ: определения тривиальны, а вся сложность — в выборе. В этой статье мы разберём каждый блок с трёх сторон: интуиция и «зачем», строгие правила, и цена решения (производительность, конкурентный доступ, тестируемость).

Репозитории мы здесь только обозначим — им целиком посвящена следующая статья; доменные события — статья 05.


1. Карта блоков: кто кому подчиняется

Прежде чем разбирать по одному — общая картина. Блоки не равноправны: между ними есть строгая иерархия владения и доступа.

Из этой диаграммы уже видны три правила, которые нарушают в 90 % проектов:

  1. Репозиторий бывает только у корня агрегата — не у каждой сущности.
  2. Один агрегат ссылается на другой только по идентификатору, не по объектной ссылке.
  3. 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 Object Address. Рефакторинг называется 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. Дерево решений: чем должно быть понятие

Это дерево стоит держать перед глазами первые полгода работы с DDD — оно снимает 80 % споров на код-ревью.


4. Aggregate: главная идея тактического DDD

Если из всего DDD оставить один блок, это будет агрегат. Всё остальное — реализуемо и без него.

4.1. Зачем

Проблема, которую решает агрегат, звучит так: какой набор объектов должен быть согласован в один и тот же момент времени? Без ответа система вырождается в клубок, где любой код может менять любой объект, и никто не гарантирует, что сумма строк заказа равна его итогу.

Агрегат — это:

  • кластер сущностей и значений, рассматриваемый как одно целое;
  • с единственной точкой входа — корнем (aggregate root);
  • который охраняет инварианты кластера;
  • и является границей транзакционной согласованности: агрегат целиком загружается, целиком проверяется и целиком сохраняется в одной транзакции.

Граница агрегата: транзакционная согласованность внутри, конечная — снаружи

4.2. Четыре правила Вернона

Вон Вернон в серии «Effective Aggregate Design» (три PDF, обязательное чтение) сформулировал правила, которые с тех пор считаются каноном:

  1. Моделируй истинные инварианты внутри границ согласованности. В агрегат попадает только то, что обязано быть согласовано немедленно. Всё, что терпит секунду задержки, — снаружи.
  2. Проектируй маленькие агрегаты. Идеал — корень плюс несколько значений. Большой агрегат означает большие транзакции, блокировки и конфликты записи.
  3. Ссылайся на другие агрегаты только по идентификатору. Никаких order.customer.balance.
  4. Обновляй другие агрегаты в конечном счёте (eventual consistency) — через доменные события и отдельные транзакции.

Правила 2 и 4 — прямое следствие правила 1, а правило 3 — техническая страховка от их нарушения: если у вас в руках нет объектной ссылки, вы физически не сможете изменить чужой агрегат.

4.3. Практический критерий границы

Задайте бизнес-эксперту вопрос в такой форме:

«Если правило X нарушится на 2 секунды, а потом само починится — это катастрофа или нормально?»

  • «Катастрофа, деньги уйдут» → внутри одной транзакции, один агрегат.
  • «Нормально, лишь бы к вечеру сошлось» → разные агрегаты, событие, компенсация.

Классический пример: сумма позиций и итог заказа обязаны сходиться всегда (один агрегат), а «клиент не может иметь больше 5 активных заказов» вполне терпит проверку с лагом — и это правильно, потому что иначе агрегат «Клиент» превратится в бутылочное горлышко для всех заказов.

4.4. Жизненный цикл и защита переходов

Такая диаграмма — не украшение: это спецификация методов корня. Каждая стрелка = публичный метод, каждая подпись на стрелке = проверка в начале метода, отсутствие стрелки = исключение.

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. Как выбирать

  1. Начните с самого маленького агрегата, который защищает истинный инвариант.
  2. Расширяйте границу, только когда бизнес требует немедленной согласованности и вы это подтвердили формулировкой «нарушение на 2 секунды = катастрофа».
  3. Проверяйте гипотезу нагрузкой: если у агрегата больше нескольких команд в секунду — вы либо взяли границу слишком широко, либо вам нужен другой паттерн (см. конфликтующие стратегии в архитектурных паттернах).

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. Когда он действительно нужен

Три честных признака:

  1. Операция существенна для домена и звучит на едином языке (перевести деньги, подобрать тариф).
  2. Она затрагивает несколько агрегатов или требует доменного знания, не принадлежащего ни одному.
  3. Запихивание её в любой из агрегатов сделало бы модель хуже (например, 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. Как это выглядит в одном запросе

Две транзакции — не недостаток реализации, а прямое следствие правила «агрегат = граница согласованности». Надёжная доставка события между ними обеспечивается паттерном 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. Типичные ошибки

  1. Анемичная модель. Классы с геттерами/сеттерами, вся логика — в *Service. Формально DDD-папки есть, содержания нет. Диагностика: попробуйте написать юнит-тест бизнес-правила без моков. Не получается — модель анемична.
  2. Агрегат = таблица. Границу проводят по схеме БД, а не по инвариантам. Симптом: агрегаты идеально повторяют ER-диаграмму, а «Клиент» содержит всё на свете.
  3. Объектные ссылки между агрегатами. order.customer.tier вместо customer_id — приводит к ленивой подгрузке половины БД, к N+1 и к возможности изменить чужой агрегат в обход его правил.
  4. Публичные сеттеры на корне. Один order.set_status(SHIPPED) обнуляет всю машину состояний. Если поле нужно снаружи только для чтения — делайте property без сеттера.
  5. Сущность там, где нужно значение. Каждое понятие превращают в таблицу с id. Модель разбухает, появляется бессмысленное отслеживание жизненного цикла у Address.
  6. datetime.now() внутри домена. Делает правила непроверяемыми и зависимыми от окружения. Передавайте время параметром или инжектьте Clock.
  7. Domain Service как свалка. Всё, что «не влезло», сваливается в сервисы — и модель снова анемична. Domain Service — исключение, а не место по умолчанию.
  8. Валидация формата и бизнес-инварианты в одной куче. «Email похож на email» — это валидация на границе (DTO, схема запроса). «Клиент не может иметь два активных абонемента» — это инвариант домена. Смешение приводит к тому, что домен занимается парсингом строк.
  9. Агрегат публикует события, но их никто не забирает. pull_events() не вызывается в Unit of Work — события молча теряются. Это самый коварный баг, потому что тесты домена его не ловят.
  10. Тактика без стратегии. Агрегаты и значения внутри неправильно проведённых границ контекстов не спасают: вы получите красиво инкапсулированный, но неправильный домен. Подробнее — в типичных ошибках внедрения.

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 — надёжная публикация событий из агрегата.

Что дальше

Мы спроектировали агрегат, который защищает инварианты в памяти. Остался вопрос, который мы несколько раз откладывали: как загрузить и сохранить его целиком, не позволив базе данных продиктовать модели свою форму, и как удержать транзакцию ровно на границе агрегата.

Читайте: Репозитории, единица работы и персистентность без протечек.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов