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

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

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

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

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

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

// Знание спрятано в SQL — кто это прочитает через год?
class OrderDao {
  riskFor(id: OrderId): RiskRating {
    // SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
    return RiskRating.LOW;
  }
}

// Копия структуры БД вместо модели — никакого поведения
class OrderRecord {
  id: number;
  total_amount: number;
  status_code: string;
}

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

class CustomerTierResolver {
  resolve(customer: Customer): CustomerTier {
    if (customer.ordersInLastYear() >= 20) return CustomerTier.GOLD;
    if (customer.ordersInLastYear() >= 5)  return CustomerTier.SILVER;
    return CustomerTier.BRONZE;
  }
}

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

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

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

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

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

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

// Анемичная модель: сущность без поведения
class Order {
  id: number;
  status: OrderStatus;
  // только публичные поля
}

// Вся логика в сервисе
class OrderService {
  confirm(order: Order): void {
    if (order.status !== OrderStatus.DRAFT) throw new Error();
    order.status = OrderStatus.CONFIRMED;
  }
}

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

class Order {
  private status: OrderStatus;

  confirm(): void {
    if (!this.canBeConfirmed()) {
      throw new Error('Order cannot be confirmed');
    }
    this.status = OrderStatus.CONFIRMED;
  }

  markAsPaid(paymentId: PaymentId): void {
    this.status = OrderStatus.PAID;
    this.events.push(new OrderPaidEvent(this.id, paymentId));
  }
}

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

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

Классическая проблема: разработчик пишет доменный объект и сразу вешает на него @Entity, @Column, декораторы TypeORM. Потом оказывается, что этот объект нельзя протестировать без базы данных, нельзя переиспользовать без NestJS, нельзя изменить схему без переписывания домена.

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

// Application Service координирует сценарий, домен принимает решения
class OrderAppService {
  constructor(private readonly repo: OrderRepository) {}

  async confirmOrder(id: OrderId): Promise<void> {
    const order = await this.repo.byId(id);
    order.confirm();       // решение — внутри домена
    await this.repo.save(order);
  }
}

// Интерфейс репозитория объявлен в домене
interface OrderRepository {
  byId(id: OrderId): Promise<Order>;
  save(order: Order): Promise<void>;
}

// Реализация — в инфраструктурном слое, домен о ней не знает
class TypeOrmOrderRepository implements OrderRepository {
  async byId(id: OrderId): Promise<Order> { /* TypeORM */ }
  async save(order: Order): Promise<void> { /* TypeORM */ }
}

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

// Доменный объект с инфраструктурными декораторами
@Entity('orders')
class Order { /* ORM прямо в домене */ }

// Application Service работает напрямую с инфраструктурой
class OrderAppService {
  constructor(private readonly em: EntityManager) {} // инфраструктура в слое приложения
}

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

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

// Невыразительная логика — что это означает?
if (amount.compareTo(limit) <= 0 && !customer.isBlocked() && customer.age() >= 18) {
  // разрешить операцию
}

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

// Политика — именованное правило
class CreditApprovalPolicy {
  allows(customer: Customer, amount: Money): boolean {
    return !customer.isBlocked()
      && customer.isAdult()
      && customer.creditLimit().remaining().isGreaterThan(amount);
  }
}

// Период — доменная концепция вместо двух дат
class BillingPeriod {
  constructor(
    readonly from: Date,
    readonly to: Date,
  ) {}

  includes(date: Date): boolean {
    return date >= this.from && date <= this.to;
  }

  equals(other: BillingPeriod): boolean {
    return this.from.getTime() === other.from.getTime()
      && this.to.getTime() === other.to.getTime();
  }
}

// Событие — явный факт домена
class OrderExpired implements DomainEvent {
  constructor(
    readonly orderId: OrderId,
    readonly expiredAt: Date,
  ) {}
}

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

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

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

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

order.updateStatus(2);       // что такое 2?
order.process(true, false);  // что делают эти boolean?

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

order.confirm();
order.cancelByCustomer(reason);
order.markAsPaid(paymentId);

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

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

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

// Вычисление + побочный эффект — опасная смесь
class PricingService {
  calculateAndApplyDiscount(order: Order): Money {
    const discount = this.computeDiscount(order);
    order.total = order.total.subtract(discount); // мутирует заказ!
    return discount;
  }
}

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

// Чистое вычисление — ничего не меняет
class TaxCalculator {
  taxFor(order: Order): Money {
    return order.subtotal().multiply(TAX_RATE);
  }
}

// Команда — меняет состояние, ничего не возвращает
class Order {
  applyDiscount(discount: Discount): void {
    this.total = discount.applyTo(this.total);
    this.events.push(new DiscountAppliedEvent(this.id, discount));
  }
}

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

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

// Молча позволяет отрицательный баланс
class Account {
  withdraw(amount: Money): void {
    this.balance = this.balance.subtract(amount); // а если amount > balance?
  }
}

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

class Account {
  withdraw(amount: Money): void {
    if (amount.isNegative()) throw new Error('Сумма должна быть положительной');
    if (this.balance.isLessThan(amount)) throw new Error('Недостаточно средств');
    this.balance = this.balance.subtract(amount);
  }
}

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

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

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

// Strategy — для вариативного поведения
interface ShippingPolicy {
  costFor(shipment: Shipment): Money;
}

class ExpressShipping implements ShippingPolicy {
  costFor(shipment: Shipment): Money { /* ... */ }
}

// Factory — для создания с доменными правилами
class ShippingPolicyFactory {
  forOrder(order: Order): ShippingPolicy {
    return order.isExpress() ? new ExpressShipping() : new StandardShipping();
  }
}

// Specification — для переиспользуемых правил
class CanConfirmOrder implements Specification<Order> {
  isSatisfiedBy(order: Order): boolean {
    return order.isPaid() && order.hasItems();
  }
}

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

// Синглтон ConfigManager не имеет отношения к домену
class ConfigManager {
  static readonly INSTANCE = new ConfigManager();
}

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

Коротко

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

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