Опирается на правила:
R-SPEC-1…R-SPEC-2иR-SPEC-X1…R-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-X1Specification для генерации SQL — антипаттерн: Spec уходит изcore/, domain-граница сломана, in-memory и SQL проверки расходятся.R-SPEC-X2Specification ради одного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-2—core/начинает зависеть от инфраструктуры. - 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 или FindOptionsWhere | R-SPEC-X1 | Pure-domain Specification + отдельный метод на QueryRepository |
Specification для одного if в одном месте | R-SPEC-X2 | Простой if или метод на агрегате |
Specification с импортом TypeORM / @nestjs/* | R-SPEC-X1 + R-MOD-2 | Pure TypeScript, без фреймворковых зависимостей в core/ |
Имя OrderHelper, Checker, Validator, Rule | R-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 для фильтрации.