Опирается на правила: R-SPEC-1R-SPEC-2 и R-SPEC-X1R-SPEC-X2 из DDD Tactical Style Guide → раздел 8. Specification.

Важно знать

  • Specification — доменное правило, выделенное в отдельный объект. isSatisfiedBy(candidate) возвращает boolean: удовлетворяет ли объект правилу.
  • В Node нет библиотеки ddd-building-blocks — базовый класс пишется в core/shared/building-blocks.ts и даёт комбинаторы and, or, not в одном месте.
  • Specification вводится только когда правило применяется в двух и более местах либо требует комбинации через and/or/not. Для одного if — метод на агрегате.
  • Specification работает в памяти: принимает доменный объект, возвращает boolean. Не строит SQL-запросы.
  • Specification живёт в core/<bc>/specification/ без NestJS-декораторов и TypeORM-импортов — это доменный код (R-MOD-2).
  • Имя — утверждение, которое можно проверить: EligibleForDiscountSpec, VipCustomerSpec. Без суффиксов Validator, Checker, Rule, Helper.
  • R-SPEC-X1 Specification для генерации SQL — антипаттерн: Spec уходит из core/, domain-граница сломана, in-memory и SQL проверки расходятся.
  • R-SPEC-X2 Specification ради одного if в одном месте — преждевременная абстракция.

Specification — компактный паттерн для случаев, когда одно и то же правило нужно проверить в нескольких точках домена, либо когда правило естественно раскладывается в «A и B, но не C». В типичном сервисе таких объектов 2–5, а не 50. Раскрытие раздела 8 гайда.

Базовый класс и комбинаторы

R-SPEC-1: каждая Specification реализует isSatisfiedBy. Базовый класс в core/shared/building-blocks.ts даёт готовые комбинаторы — единожды:

// core/shared/building-blocks.ts
export abstract class Specification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;

  and(other: Specification<T>): Specification<T> {
    return { isSatisfiedBy: (c: T) => this.isSatisfiedBy(c) && other.isSatisfiedBy(c) } as Specification<T>;
  }

  or(other: Specification<T>): Specification<T> {
    return { isSatisfiedBy: (c: T) => this.isSatisfiedBy(c) || other.isSatisfiedBy(c) } as Specification<T>;
  }

  not(): Specification<T> {
    return { isSatisfiedBy: (c: T) => !this.isSatisfiedBy(c) } as Specification<T>;
  }
}

Альтернатива — реализовать and/or/not через дочерние классы-обёртки (AndSpecification, OrSpecification). Подходит, если нужна явная reflect-логика или сериализация дерева правил. Для большинства сервисов inline-реализация выше достаточна.

Когда вводим

R-SPEC-2: правило переиспользуется или требует комбинаций.

Сценарий — проверка права на возврат заказа. Правило используется в двух местах: маппер представления (нужно ли отображать кнопку «Вернуть») и command handler (можно ли принять команду возврата):

// core/order/specification/eligible-for-refund.ts
import { Specification } from '../../shared/building-blocks';
import { Order } from '../aggregate/order';
import { OrderStatus } from '../aggregate/order-status';

const REFUND_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;

export class EligibleForRefundSpec extends Specification<Order> {
  isSatisfiedBy(order: Order): boolean {
    if (order.status !== OrderStatus.DELIVERED) return false;
    return Date.now() - order.deliveredAt.getTime() <= REFUND_WINDOW_MS;
  }
}

Первое место — маппер представления для OrderSummary:

// core/order/usecases/list-orders/order-view-mapper.ts
import { EligibleForRefundSpec } from '../../specification/eligible-for-refund';
import { Order } from '../../aggregate/order';

const refundSpec = new EligibleForRefundSpec();

export function toOrderSummary(order: Order): OrderSummary {
  return {
    orderId: order.id,
    total: order.total().amount.toString(),
    canRefund: refundSpec.isSatisfiedBy(order),
  };
}

Второе место — command handler:

// core/order/usecases/issue-refund/issue-refund.handler.ts
import { EligibleForRefundSpec } from '../../specification/eligible-for-refund';
import { DomainError } from '../../../shared/domain-error';

export class IssueRefundHandler {
  private readonly refundSpec = new EligibleForRefundSpec();

  async handle(cmd: IssueRefund): Promise<RefundId> {
    const order = await this.orderRepository.byId(cmd.orderId);
    if (!order) throw new DomainError('order not found');

    if (!this.refundSpec.isSatisfiedBy(order)) {
      throw new DomainError(`order ${cmd.orderId} is not eligible for refund`);
    }
    // ...
  }
}

Правило одно — реализация одна. UI и backend не разойдутся.

Комбинаторы and/or/not

R-SPEC-1: базовый класс даёт and, or, not. Пример — скидка для премиальных клиентов Сбера:

// core/customer/specification/vip-customer.ts
export class VipCustomerSpec extends Specification<Customer> {
  constructor(private readonly threshold: Money) { super(); }

  isSatisfiedBy(customer: Customer): boolean {
    return customer.totalSpent().amount.gte(this.threshold.amount);
  }
}

// core/customer/specification/longtime-customer.ts
export class LongtimeCustomerSpec extends Specification<Customer> {
  constructor(private readonly yearsThreshold: number) { super(); }

  isSatisfiedBy(customer: Customer): boolean {
    const ms = Date.now() - customer.registeredAt.getTime();
    return ms / (1000 * 60 * 60 * 24 * 365) >= this.yearsThreshold;
  }
}

// core/customer/specification/has-active-subscription.ts
export class HasActiveSubscriptionSpec extends Specification<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return customer.activeSubscription !== null;
  }
}

Композиция — в handler или domain service, не в самой Specification:

// core/customer/usecases/apply-discount/apply-discount.handler.ts
const vipSpec       = new VipCustomerSpec(Money.of(Big(100_000), 'RUB'));
const longtimeSpec  = new LongtimeCustomerSpec(3);
const subSpec       = new HasActiveSubscriptionSpec();

const premiumSpec = vipSpec.or(longtimeSpec.and(subSpec));

if (premiumSpec.isSatisfiedBy(customer)) {
  // применить 15% скидку
}

Что это даёт:

  • Правило читается как фраза. «VIP или (давний клиент и активная подписка)» — структура ясна без чтения тела методов.
  • Тестируется по частям. VipCustomerSpec тестируется отдельно. Комбинатор — отдельно. Не нужно гонять все 8 комбинаций в одном большом тесте.
  • Расширяется точечно. Появился «сотрудник» — добавляем StaffCustomerSpec, расширяем одну строку с or. Не переписываем if-else.

Без Specification это inline-выражение, которое нельзя переиспользовать и которое растёт вместе с бизнесом:

const eligible = customer.totalSpent().amount.gte(Big(100_000))
  || (ageYears >= 3 && customer.activeSubscription !== null);

Specification ≠ SQL builder

R-SPEC-X1: главный антипаттерн — Specification, генерирующая SQL или TypeORM FindOptionsWhere.

// ПЛОХО — Specification генерирует TypeORM-условие
export class EligibleForRefundSpec extends Specification<Order> {
  isSatisfiedBy(order: Order): boolean {
    return order.status === OrderStatus.DELIVERED
      && Date.now() - order.deliveredAt.getTime() <= REFUND_WINDOW_MS;
  }

  // ← этого не должно быть в core/
  toTypeOrmWhere(): FindOptionsWhere<OrderEntity> {
    const cutoff = new Date(Date.now() - REFUND_WINDOW_MS);
    return { status: OrderStatus.DELIVERED, deliveredAt: MoreThan(cutoff) };
  }
}

Что не так:

  • Specification уехала из core/. Импорт FindOptionsWhere, MoreThan из TypeORM нарушает R-MOD-2core/ начинает зависеть от инфраструктуры.
  • In-memory и SQL расходятся. isSatisfiedBy и toTypeOrmWhere — дубликаты. Меняем одно, забываем второе. Баг находится только в продакшне.
  • Read и write путаются. Repository начинает принимать Specification и строить через неё запросы — это уже Query Side, не Repository.

Правильное разделение:

  • EligibleForRefundSpec — pure domain, isSatisfiedBy(Order), in-memory. Используется на UI и в command handler.
  • findEligibleForRefund(cutoff: Date) — метод на OrderQueryRepository, TypeORM-запрос в adapters/out/persistence/. Отдельный метод, отдельная граница.
// adapters/out/persistence/order-query.repository.ts  (не в core/)
async findEligibleForRefund(cutoff: Date): Promise<OrderView[]> {
  return this.dataSource
    .getRepository(OrderEntity)
    .createQueryBuilder('o')
    .where('o.status = :status', { status: OrderStatus.DELIVERED })
    .andWhere('o.delivered_at > :cutoff', { cutoff })
    .getMany()
    .then(rows => rows.map(toOrderView));
}

Если требуется и in-memory-проверка, и SQL-фильтрация одного правила — оба класса существуют, синхронизацию фиксирует тест.

Specification для одного места — не нужен

R-SPEC-X2: преждевременная абстракция.

// ПЛОХО — Specification для одного if
export class OrderCancellableSpec extends Specification<Order> {
  isSatisfiedBy(order: Order): boolean {
    return order.status !== OrderStatus.SHIPPED;
  }
}

// handler использует spec только в одном месте
export class CancelOrderHandler {
  private readonly spec = new OrderCancellableSpec();

  async handle(cmd: CancelOrder): Promise<void> {
    const order = await this.orderRepository.byId(cmd.orderId);
    if (!this.spec.isSatisfiedBy(order!)) {
      throw new DomainError('order cannot be cancelled');
    }
    // ...
  }
}

Простое правило в одном месте — метод на агрегате:

// core/order/aggregate/order.ts
cancel(now: Date): void {
  if (this.status === OrderStatus.SHIPPED) {
    throw new DomainError(`order ${this.id} cannot be cancelled after shipment`);
  }
  this.status = OrderStatus.CANCELLED;
  this.registerEvent(new OrderCancelled(uuidv7(), now, this.id));
}

Specification вводится, когда правило начинает переиспользоваться. Не «когда может пригодиться».

Имя — утверждение

R-SPEC-1 подразумевает: имя — это утверждение, которое можно проверить через isSatisfiedBy.

// ХОРОШО
EligibleForRefundSpec
VipCustomerSpec
ProductInStockSpec
OrderWithinDeliveryAreaSpec

// ПЛОХО
OrderValidator      // ← validator — для входных данных, не доменных правил
OrderChecker        // ← непонятно, что именно проверяет
RefundRule          // ← «rule» слишком общо
EligibilityHelper   // ← Helper — категорически нет

Суффикс Spec короче Specification и сигнализирует паттерн. Если в команде принято полное Specification — тоже ок, главное единообразие в проекте.

Что запрещено

АнтипаттернПравилоЧто взамен
Specification, генерирующая SQL или FindOptionsWhereR-SPEC-X1Pure-domain Specification + отдельный метод на QueryRepository
Specification для одного if в одном местеR-SPEC-X2Простой if или метод на агрегате
Specification с импортом TypeORM / @nestjs/*R-SPEC-X1 + R-MOD-2Pure TypeScript, без фреймворковых зависимостей в core/
Имя OrderHelper, Checker, Validator, RuleR-SPEC-1Утверждение: EligibleForRefundSpec, VipCustomerSpec

Куда дальше

  • node/aggregate-root.md — простые проверки живут на агрегате, не в Specification.
  • node/domain-service.md — когда правило касается нескольких агрегатов и не сводится к boolean.
  • node/entity.md — базовые блоки домена: Entity, ValueObject, AggregateRoot.
  • node/value-object.md — VO как аргументы для isSatisfiedBy (деньги, идентификаторы).
  • node/repository.md — где живёт SQL-фильтрация (это не Specification).
  • node/factory.md — сборка валидного агрегата, когда конструктор не справляется.
  • node/domain-event.md — события от доменных операций, которые Specification проверяет.
  • node/module-structure.md — папка specification/ в структуре core/<bc>/.
  • CQRS Style Guide — query-side и read-model, где строится SQL для фильтрации.