← назад к разделу

В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.

Тактические паттерны 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, добавляй остальное по необходимости.

Что почитать дальше