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

Паттерны DDD — Entity, Aggregate, Bounded Context — отвечают на вопрос «что строить». Эта статья о другом: как думать при проектировании. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в один паттерн, но определяют качество всей архитектуры.

Обязательно

Знание должно жить в коде, а не в SQL

Когда команда начинает строить систему, бизнес-правила часто оседают там, где удобнее прямо сейчас: в SQL-запросах, в контроллерах, в скриптах. Через год никто не помнит, откуда берётся скидка для «золотого» клиента — это WHERE в запросе или условие в сервисе?

Эванс называет этот процесс Knowledge Crunching — перемалывание знаний. Суть: модель должна расти из диалога с людьми, которые понимают домен. И знание должно оставаться в доменном коде, а не утекать вовне.

Как выглядит утечка знаний:

# Знание спрятано в SQL — кто это прочитает через год?
class OrderDao:
    def risk_for(self, id: OrderId) -> RiskRating:
        # SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
        return RiskRating.LOW


# Копия структуры БД вместо модели — никакого поведения
class OrderRecord:
    id: int
    total_amount: Decimal
    status_code: str

Как выглядит явная доменная концепция:

class CustomerTierResolver:
    def resolve(self, customer: Customer) -> CustomerTier:
        if customer.orders_in_last_year() >= 20:
            return CustomerTier.GOLD
        if customer.orders_in_last_year() >= 5:
            return CustomerTier.SILVER
        return CustomerTier.BRONZE

Правило читается как правило — без SQL, без магических чисел в комментариях.

Главная ловушка: зафиксировать модель слишком рано или скопировать структуру базы данных как доменную модель. Ранние модели всегда наивны — и это нормально, их нужно активно пересматривать.

Модель должна жить в коде, а не только на вики

Представьте: архитектор нарисовал красивую диаграмму на доске. Разработчики посмотрели и пошли писать код по-своему. Модель и реализация расходятся, и через полгода диаграмма описывает воображаемую систему, а не реальную.

анемичная Order: только поля OrderService.confirm: проверка статуса правило в каждом сервисе rich domain Order.confirm() проверка внутри одно место правды

В анемичной модели правило «подтвердить можно только черновик» переписывают в каждом сервисе; в rich domain оно живёт в одном методе агрегата.

Эванс называет это Model-Driven Design: модель должна быть выражена прямо в коде. Если модель существует только в голове аналитика или на вики — это не Model-Driven Design.

Самая частая проблема — анемичная модель: объект только с полями и геттерами, вся логика в отдельном сервисе. Выглядит аккуратно, но бизнес-правила начинают утекать в сервисы, контроллеры, маппинги — и со временем непонятно, где искать правду.

# Анемичная модель: сущность без поведения
class Order:
    id: int
    status: OrderStatus
    # только данные, никакого поведения


# Вся логика в сервисе
class OrderService:
    def confirm(self, order: Order) -> None:
        if order.status is not OrderStatus.DRAFT:
            raise RuntimeError()
        order.status = OrderStatus.CONFIRMED

В Model-Driven Design поведение живёт там, где данные:

class Order:
    def confirm(self) -> None:
        if not self._can_be_confirmed():
            raise RuntimeError("Order cannot be confirmed")
        self._status = OrderStatus.CONFIRMED

    def mark_as_paid(self, payment_id: PaymentId) -> None:
        self._status = OrderStatus.PAID
        self._events.append(OrderPaidEvent(self._id, payment_id))

Теперь правило «нельзя подтвердить неподходящий заказ» живёт внутри Order и никуда не утекает.

Домен не должен зависеть от фреймворков

Классическая проблема: разработчик пишет доменный объект и сразу делает его SQLAlchemy-моделью — наследует от Base, вешает __tablename__ и колонки. Потом оказывается, что этот объект нельзя протестировать без базы данных, нельзя переиспользовать без SQLAlchemy, нельзя изменить схему без переписывания домена.

Решение — слоистая архитектура с чёткими правилами: UI → Application → Domain → Infrastructure. Домен никогда не импортирует то, что снаружи него.

# Application Service координирует сценарий, домен принимает решения
class OrderAppService:
    def __init__(self, repo: OrderRepository) -> None:
        self._repo = repo

    def confirm_order(self, id: OrderId) -> None:
        order = self._repo.by_id(id)
        order.confirm()  # решение — внутри домена
        self._repo.save(order)


# Интерфейс репозитория объявлен в домене
class OrderRepository(Protocol):
    def by_id(self, id: OrderId) -> Order: ...
    def save(self, order: Order) -> None: ...


# Реализация — в инфраструктурном слое, домен о ней не знает
class SqlAlchemyOrderRepository:
    def by_id(self, id: OrderId) -> Order:
        ...  # SQLAlchemy

    def save(self, order: Order) -> None:
        ...  # SQLAlchemy

Антипаттерны:

# Доменный объект как ORM-модель
class Order(Base):
    __tablename__ = "orders"
    # ORM прямо в домене


# Application Service работает напрямую с инфраструктурой
class OrderAppService:
    def __init__(self, session: Session) -> None:
        self._session = session  # инфраструктура в слое приложения

Скрытые концепции стоит сделать видимыми

Когда в коде появляется длинное условие без имени — это признак, что в домене есть важная концепция, которую никто не назвал.

# Невыразительная логика — что это означает?
if amount <= limit and not customer.is_blocked() and customer.age() >= 18:
    ...  # разрешить операцию

Кандидаты на выделение — политики, роли, временные периоды, доменные события:

# Политика — именованное правило
class CreditApprovalPolicy:
    def allows(self, customer: Customer, amount: Money) -> bool:
        return (
            not customer.is_blocked()
            and customer.is_adult()
            and customer.credit_limit().remaining().is_greater_than(amount)
        )


# Период — доменная концепция вместо двух дат
@dataclass(frozen=True)
class BillingPeriod:
    start: date
    end: date

    def includes(self, day: date) -> bool:
        return self.start <= day <= self.end


# Событие — явный факт домена
@dataclass(frozen=True)
class OrderExpired(DomainEvent):
    order_id: OrderId
    expired_at: datetime

Явные типы упрощают тестирование и повторное использование. Правило с именем можно обсудить с экспертом, поменять, протестировать отдельно.

Иногда явная концепция переворачивает модель — Эванс называет это прорывом. До прорыва: скидка — это просто флаг has_discount: bool. После прорыва: скидка — это объект Discount с правилами применения. Код становится проще и выразительнее.

API должен рассказывать, что происходит

Плохой признак — метод, название которого не говорит ничего:

order.update_status(2)        # что такое 2?
order.process(True, False)    # что делают эти boolean?

Хороший публичный API говорит что происходит, а не как это сделано внутри:

order.confirm()
order.cancel_by_customer(reason)
order.mark_as_paid(payment_id)

Эванс называет этот принцип Intention-Revealing Interfaces — интерфейсы, раскрывающие намерение. Читая вызов метода, понимаешь смысл операции без заглядывания в реализацию.

Вычисления не должны менять состояние

Ещё один принцип из раздела Supple Design — Side-Effect-Free Functions. Если метод что-то вычисляет, он не должен одновременно что-то менять. Это делает код предсказуемым и простым для тестирования.

# Вычисление + побочный эффект — опасная смесь
class PricingService:
    def calculate_and_apply_discount(self, order: Order) -> Money:
        discount = self._compute_discount(order)
        order.total = order.total.subtract(discount)  # мутирует заказ!
        return discount

Лучше разделить:

# Чистое вычисление — ничего не меняет
class TaxCalculator:
    def tax_for(self, order: Order) -> Money:
        return order.subtotal().multiply(TAX_RATE)


# Команда — меняет состояние, ничего не возвращает
class Order:
    def apply_discount(self, discount: Discount) -> None:
        self._total = discount.apply_to(self._total)
        self._events.append(DiscountAppliedEvent(self._id, discount))

Инварианты проверяются там, где меняется состояние

Инвариант — это правило, которое всегда должно выполняться. Если объект может оказаться в недопустимом состоянии — это рано или поздно приведёт к ошибке, которую трудно поймать.

# Молча позволяет отрицательный баланс
class Account:
    def withdraw(self, amount: Money) -> None:
        self._balance = self._balance.subtract(amount)  # а если amount > balance?

Инварианты проверяются там, где происходит изменение:

class Account:
    def withdraw(self, amount: Money) -> None:
        if amount.is_negative():
            raise ValueError("Сумма должна быть положительной")
        if self._balance.is_less_than(amount):
            raise RuntimeError("Недостаточно средств")
        self._balance = self._balance.subtract(amount)

Эванс называет этот принцип Assertions — утверждения о состоянии. Ошибки ловятся близко к источнику, а не всплывают в неожиданном месте.

Паттерны GoF помогают выразить домен

Strategy, Factory, Specification — технические паттерны оправданы, когда они подчёркивают смысл домена, а не просто добавляют уровни абстракции.

# Strategy — для вариативного поведения
class ShippingPolicy(Protocol):
    def cost_for(self, shipment: Shipment) -> Money: ...


class ExpressShipping:
    def cost_for(self, shipment: Shipment) -> Money: ...


# Factory — для создания с доменными правилами
class ShippingPolicyFactory:
    def for_order(self, order: Order) -> ShippingPolicy:
        return ExpressShipping() if order.is_express() else StandardShipping()


# Specification — для переиспользуемых правил
class CanConfirmOrder(Specification[Order]):
    def is_satisfied_by(self, order: Order) -> bool:
        return order.is_paid() and order.has_items()

Антипаттерн — паттерн ради паттерна, без доменного смысла:

# Синглтон ConfigManager не имеет отношения к домену
class ConfigManager:
    ...


CONFIG_MANAGER = ConfigManager()

Рефакторинг в DDD — не техническое упражнение, а инструмент углубления модели. Каждое изменение должно приближать код к языку домена.

Дополнительно: при первом чтении можно пропустить

Глубже: Specification: правило как объектрасширенное

CanConfirmOrder в листинге выше это спецификация, и стоит показать, зачем правило выносят в отдельный объект, если можно написать if. Причин три. Правило нужно в нескольких местах: «заказ можно подтвердить» проверяется при подтверждении, показывается кнопкой в интерфейсе и фильтрует список «готовые к подтверждению». Правило составное: «оплачен, есть позиции, адрес в зоне доставки» собирают из частей, и части переиспользуют. Правило меняется бизнесом чаще, чем код вокруг.

В Python это протокол с одним методом и композицией через операторы:

class Specification[T](Protocol):
    def is_satisfied_by(self, candidate: T) -> bool: ...

@dataclass(frozen=True)
class And[T]:
    left: Specification[T]
    right: Specification[T]
    def is_satisfied_by(self, candidate: T) -> bool:
        return self.left.is_satisfied_by(candidate) and self.right.is_satisfied_by(candidate)

CanConfirmOrder() & InDeliveryZone(zone) читается как правило и тестируется без базы. Обработчик зовёт spec.is_satisfied_by(order) и бросает доменную ошибку с именем нарушенного правила.

Ловушка в третьем применении, фильтре списка. Спецификация в памяти не превращается в WHERE сама; попытка «перевести» её в выражение SQLAlchemy даёт второй, параллельный код с теми же правилами, и они расходятся. Честный вариант: для списков писать запрос в репозитории чтения явно, а спецификацию использовать для решений над загруженным объектом. Если правило нужно и там и там, одна реализация идёт в обе стороны и тестом сверяется на одних данных.

Глубже: как удержать принципы: тесты инвариантов, событий и import-linterрасширенное

Принципы выше держатся на дисциплине, а дисциплина не переживает третьего разработчика. Держат их три вида проверок, и все три быстрые.

Тесты инвариантов без базы и без FastAPI. Агрегат это обычный объект, и его правила проверяются обычным pytest: собрать заказ без позиций, вызвать confirm(), ожидать EmptyOrderError. На такой тест уходят миллисекунды, поэтому их пишут на каждое правило, включая запрещённые переходы. Если для теста правила понадобилась сессия базы, правило лежит не там.

Тесты событий. Побочные эффекты домена выражены событиями, значит, проверяемо и это: после confirm() в order.pop_events() ровно одно OrderConfirmed с нужными полями, а после второго confirm() исключение и ни одного нового события. Так ловят и забытое событие, и лишнее.

Границы автоматически. Правило «домен не зависит от фреймворков» в Python никто не проверит за вас: import sqlalchemy в агрегате проходит незамеченным. import-linter с контрактом forbidden на пакет домена и список sqlalchemy, fastapi, pydantic, httpx падает в сборке на первом таком импорте; как это настраивают, разобрано в статье про архитектурные тесты.

Всё вместе занимает секунды в CI и убирает самый частый способ потерять модель: не злой умысел, а срочную правку в пятницу.

Коротко

  • Знание в коде — бизнес-правила не должны прятаться в SQL-запросах или вспомогательных объектах без поведения.
  • Модель в коде — анемичная модель (объект с полями + сервис со всей логикой) — антипаттерн; поведение живёт там, где данные.
  • Изоляция домена — домен не зависит от фреймворков; зависимости направлены внутрь: UI → Application → Domain ← Infrastructure.
  • Явные концепции — политики, роли, периоды и события заслуживают собственных типов, а не живут внутри if/else.
  • Intention-Revealing Interfaces — confirm() вместо update_status(2); читая вызов, понимаешь смысл.
  • Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние.
  • Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте.
  • Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв.
  • Спецификация — правило как объект с композицией, для решений над загруженным объектом, а не для перевода в WHERE; принципы держат тесты инвариантов и событий без базы и import-linter на пакете домена.

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