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

Паттерны 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 InterfacesConfirm() вместо UpdateStatus(2); читая вызов, понимаешь смысл.
  • Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние.
  • Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте.
  • Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв.

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