Паттерны 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 Interfaces —
confirm()вместоupdate_status(2); читая вызов, понимаешь смысл. - Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние.
- Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте.
- Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв.
Что почитать дальше
- Что такое DDD и зачем он нужен — отправная точка, если ещё не читали.
- Стратегические паттерны DDD — Bounded Context, Context Map, Ubiquitous Language.
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Repository.