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

Паттерны 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, без магических чисел в комментариях.

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

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

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

Эванс называет это 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 — не техническое упражнение, а инструмент углубления модели. Каждое изменение должно приближать код к языку домена.

Коротко

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

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