Паттерны DDD — Entity, Aggregate, Bounded Context — отвечают на вопрос «что строить». Эта статья о другом: как думать при проектировании. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в один паттерн, но определяют качество всей архитектуры.
Знание должно жить в коде, а не в SQL
Когда команда начинает строить систему, бизнес-правила часто оседают там, где удобнее прямо сейчас: в SQL-запросах, в контроллерах, в скриптах. Через год никто не помнит, откуда берётся скидка для «золотого» клиента — это WHERE в запросе или условие в сервисе?
Эванс называет этот процесс Knowledge Crunching — перемалывание знаний. Суть: модель должна расти из диалога с людьми, которые понимают домен. И знание должно оставаться в доменном коде, а не утекать вовне.
Как выглядит утечка знаний:
// Знание спрятано в SQL — кто это прочитает через год?
type OrderDao struct {
pool *pgxpool.Pool
}
func (d *OrderDao) RiskFor(ctx context.Context, id OrderID) (RiskRating, error) {
// SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
return RiskLow, nil
}
// Копия структуры БД вместо модели — никакого поведения
type OrderRecord struct {
ID int64
TotalAmount decimal.Decimal
StatusCode string
}
Как выглядит явная доменная концепция:
type CustomerTierResolver struct{}
func (r CustomerTierResolver) Resolve(customer Customer) CustomerTier {
switch {
case customer.OrdersInLastYear() >= 20:
return TierGold
case customer.OrdersInLastYear() >= 5:
return TierSilver
default:
return TierBronze
}
}
Правило читается как правило — без SQL, без магических чисел в комментариях.
Главная ловушка: зафиксировать модель слишком рано или скопировать структуру базы данных как доменную модель. Ранние модели всегда наивны — и это нормально, их нужно активно пересматривать.
Модель должна жить в коде, а не только на вики
Представьте: архитектор нарисовал красивую диаграмму на доске. Разработчики посмотрели и пошли писать код по-своему. Модель и реализация расходятся, и через полгода диаграмма описывает воображаемую систему, а не реальную.
Эванс называет это Model-Driven Design: модель должна быть выражена прямо в коде. Если модель существует только в голове аналитика или на вики — это не Model-Driven Design.
Самая частая проблема — анемичная модель: объект только с полями и геттерами, вся логика в отдельном сервисе. Выглядит аккуратно, но бизнес-правила начинают утекать в сервисы, контроллеры, маппинги — и со временем непонятно, где искать правду.
// Анемичная модель: сущность без поведения
type Order struct {
ID int64
Status OrderStatus
// только открытые поля, никаких правил
}
// Вся логика в сервисе
type OrderService struct{}
func (s *OrderService) Confirm(order *Order) error {
if order.Status != OrderStatusDraft {
return errors.New("cannot confirm")
}
order.Status = OrderStatusConfirmed
return nil
}
В Model-Driven Design поведение живёт там, где данные:
var ErrOrderCannotBeConfirmed = errors.New("order cannot be confirmed")
type Order struct {
id OrderID
status OrderStatus
events []DomainEvent
}
func (o *Order) Confirm() error {
if !o.canBeConfirmed() {
return ErrOrderCannotBeConfirmed
}
o.status = OrderStatusConfirmed
return nil
}
func (o *Order) MarkAsPaid(paymentID PaymentID) {
o.status = OrderStatusPaid
o.events = append(o.events, NewOrderPaidEvent(o.id, paymentID))
}
Теперь правило «нельзя подтвердить неподходящий заказ» живёт внутри Order и никуда не утекает.
Домен не должен зависеть от инфраструктуры
Классическая проблема: разработчик пишет доменную структуру и сразу вешает на неё db-теги, а методы завязывает на пул соединений. Потом оказывается, что эту структуру нельзя протестировать без базы данных, нельзя переиспользовать без драйвера, нельзя изменить схему без переписывания домена.
Решение — слоистая архитектура с чёткими правилами: UI → Application → Domain → Infrastructure. Домен никогда не импортирует то, что снаружи него.
// Application Service координирует сценарий, домен принимает решения
type OrderAppService struct {
repo OrderRepository
}
func (s *OrderAppService) ConfirmOrder(ctx context.Context, id OrderID) error {
order, err := s.repo.ByID(ctx, id)
if err != nil {
return err
}
if err := order.Confirm(); err != nil { // решение — внутри домена
return err
}
return s.repo.Save(ctx, order)
}
// Интерфейс репозитория объявлен в домене
type OrderRepository interface {
ByID(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, order *Order) error
}
// Реализация — в инфраструктурном слое, домен о ней не знает
type PgxOrderRepository struct {
pool *pgxpool.Pool
}
func (r *PgxOrderRepository) ByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) { /* pgx */ }
func (r *PgxOrderRepository) Save(ctx context.Context, order *domain.Order) error { /* pgx */ }
Антипаттерны:
// Доменная структура со знанием о схеме БД
type Order struct {
ID int64 `db:"id"` // теги хранилища прямо в домене
Status string `db:"status_code"`
}
// Application Service работает напрямую с инфраструктурой
type OrderAppService struct {
pool *pgxpool.Pool // инфраструктура в слое приложения
}
Скрытые концепции стоит сделать видимыми
Когда в коде появляется длинное условие без имени — это признак, что в домене есть важная концепция, которую никто не назвал.
// Невыразительная логика — что это означает?
if amount.LessThanOrEqual(limit) && !customer.IsBlocked() && customer.Age() >= 18 {
// разрешить операцию
}
Кандидаты на выделение — политики, роли, временные периоды, доменные события:
// Политика — именованное правило
type CreditApprovalPolicy struct{}
func (p CreditApprovalPolicy) Allows(customer Customer, amount Money) bool {
return !customer.IsBlocked() &&
customer.IsAdult() &&
customer.CreditLimit().Remaining().GreaterThan(amount)
}
// Период — доменная концепция вместо двух дат
type BillingPeriod struct {
from time.Time
to time.Time
}
func (p BillingPeriod) Includes(date time.Time) bool {
return !date.Before(p.from) && !date.After(p.to)
}
// Событие — явный факт домена
type OrderExpired struct {
OrderID OrderID
ExpiredAt time.Time
}
Явные типы упрощают тестирование и повторное использование. Правило с именем можно обсудить с экспертом, поменять, протестировать отдельно.
Иногда явная концепция переворачивает модель — Эванс называет это прорывом. До прорыва: скидка — это просто флаг hasDiscount bool. После прорыва: скидка — это объект Discount с правилами применения. Код становится проще и выразительнее.
API должен рассказывать, что происходит
Плохой признак — метод, название которого не говорит ничего:
order.UpdateStatus(2) // что такое 2?
order.Process(true, false) // что делают эти bool?
Хороший публичный API говорит что происходит, а не как это сделано внутри:
order.Confirm()
order.CancelByCustomer(reason)
order.MarkAsPaid(paymentID)
Эванс называет этот принцип Intention-Revealing Interfaces — интерфейсы, раскрывающие намерение. Читая вызов метода, понимаешь смысл операции без заглядывания в реализацию.
Вычисления не должны менять состояние
Ещё один принцип из раздела Supple Design — Side-Effect-Free Functions. Если метод что-то вычисляет, он не должен одновременно что-то менять. Это делает код предсказуемым и простым для тестирования.
// Вычисление + побочный эффект — опасная смесь
type PricingService struct{}
func (s *PricingService) CalculateAndApplyDiscount(order *Order) Money {
discount := s.computeDiscount(order)
order.SetTotal(order.Total().Subtract(discount)) // мутирует заказ!
return discount
}
Лучше разделить:
// Чистое вычисление — ничего не меняет
type TaxCalculator struct{}
func (c TaxCalculator) TaxFor(order Order) Money {
return order.Subtotal().Multiply(taxRate)
}
// Команда — меняет состояние, ничего не возвращает
func (o *Order) ApplyDiscount(discount Discount) {
o.total = discount.ApplyTo(o.total)
o.events = append(o.events, NewDiscountAppliedEvent(o.id, discount))
}
Инварианты проверяются там, где меняется состояние
Инвариант — это правило, которое всегда должно выполняться. Если объект может оказаться в недопустимом состоянии — это рано или поздно приведёт к ошибке, которую трудно поймать.
// Молча позволяет отрицательный баланс
func (a *Account) Withdraw(amount Money) {
a.balance = a.balance.Subtract(amount) // а если amount > balance?
}
Инварианты проверяются там, где происходит изменение:
var (
ErrNegativeAmount = errors.New("amount must be positive")
ErrInsufficientFunds = errors.New("insufficient funds")
)
func (a *Account) Withdraw(amount Money) error {
if amount.IsNegative() {
return ErrNegativeAmount
}
if a.balance.LessThan(amount) {
return ErrInsufficientFunds
}
a.balance = a.balance.Subtract(amount)
return nil
}
Эванс называет этот принцип Assertions — утверждения о состоянии. Ошибки ловятся близко к источнику, а не всплывают в неожиданном месте.
Паттерны GoF помогают выразить домен
Strategy, Factory, Specification — технические паттерны оправданы, когда они подчёркивают смысл домена, а не просто добавляют уровни абстракции.
// Strategy — для вариативного поведения
type ShippingPolicy interface {
CostFor(shipment Shipment) Money
}
type ExpressShipping struct{}
func (s ExpressShipping) CostFor(shipment Shipment) Money { /* ... */ }
// Factory — для создания с доменными правилами
func ShippingPolicyForOrder(order Order) ShippingPolicy {
if order.IsExpress() {
return ExpressShipping{}
}
return StandardShipping{}
}
// Specification — для переиспользуемых правил
type CanConfirmOrder struct{}
func (s CanConfirmOrder) IsSatisfiedBy(order Order) bool {
return order.IsPaid() && order.HasItems()
}
Антипаттерн — паттерн ради паттерна, без доменного смысла:
// Синглтон ConfigManager не имеет отношения к домену
type ConfigManager struct{}
var configManagerInstance = &ConfigManager{}
Рефакторинг в DDD — не техническое упражнение, а инструмент углубления модели. Каждое изменение должно приближать код к языку домена.
Коротко
- Знание в коде — бизнес-правила не должны прятаться в SQL-запросах или вспомогательных объектах без поведения.
- Модель в коде — анемичная модель (объект с полями + сервис со всей логикой) — антипаттерн; поведение живёт там, где данные.
- Изоляция домена — домен не зависит от инфраструктуры; зависимости направлены внутрь: UI → Application → Domain ← Infrastructure.
- Явные концепции — политики, роли, периоды и события заслуживают собственных типов, а не живут внутри
if/else. - Intention-Revealing Interfaces —
Confirm()вместоUpdateStatus(2); читая вызов, понимаешь смысл. - Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние.
- Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте.
- Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв.
Что почитать дальше
- Что такое DDD и зачем он нужен — отправная точка, если ещё не читали.
- Стратегические паттерны DDD — Bounded Context, Context Map, Ubiquitous Language.
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Repository.