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

Важно знать

  • Specification — это доменное правило, выделенное в самостоятельный объект. isSatisfiedBy(T) отвечает на вопрос «удовлетворяет ли этот объект правилу».
  • Наследуем Specification<T> из ddd-building-blocks. Базовый класс даёт комбинаторы and(other), or(other), not().
  • Используем только когда правило применяется в двух и более местах либо требуется комбинация правил через and/or/not. Иначе — обычный if или метод на агрегате.
  • Specification работает в памяти: на вход — Entity / VO, на выходе — boolean. Не строит SQL.
  • Имя — описывает правило: EligibleForRefundSpec, VipCustomerSpec, OrderWithinDeliveryAreaSpec. Без суффикса Validator, Checker, RuleSpec достаточно.
  • R-SPEC-X1 Specification для генерации SQL — антипаттерн. Это путает Repository и Query Side. Для read-сценариев — отдельный FilterConditionBuilder.
  • R-SPEC-X2 Specification для одного if в одном месте — преждевременная абстракция. Сначала if, и только если правило кочует в несколько мест — выделяем.

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

Когда вводим

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

Сценарий «правило в двух местах» — eligibility для возврата:

public final class EligibleForRefundSpec extends Specification<Order> {

    private static final Duration REFUND_WINDOW = Duration.ofDays(14);

    @Override
    public boolean isSatisfiedBy(Order order) {
        if (order.status() == OrderStatus.CANCELLED) {
            return false;
        }
        if (order.status() != OrderStatus.DELIVERED) {
            return false;
        }
        return Duration.between(order.deliveredAt(), Instant.now()).compareTo(REFUND_WINDOW) <= 0;
    }
}

Где это правило применяется:

// 1. На UI — кнопка "Вернуть" появляется только для подходящих заказов
class OrderViewMapper {
    private final EligibleForRefundSpec refundSpec;
    public OrderView toView(Order o) {
        return new OrderView(o.id(), o.total(), refundSpec.isSatisfiedBy(o));
    }
}

// 2. В command-handler — при попытке оформить refund проверяется тот же критерий
class IssueRefundHandler implements UseCaseHandler<IssueRefund, RefundId> {
    private final EligibleForRefundSpec refundSpec;
    public RefundId handle(IssueRefund cmd) {
        Order order = orderRepository.findById(cmd.orderId(), FOR_UPDATE).orElseThrow();
        if (!refundSpec.isSatisfiedBy(order)) {
            throw new OrderNotEligibleForRefundException(order.id());
        }
        // ...
    }
}

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

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

R-SPEC-1: базовый класс Specification<T> даёт and, or, not. Это позволяет собирать сложные правила из примитивов:

public final class VipCustomerSpec extends Specification<Customer> {
    @Override public boolean isSatisfiedBy(Customer c) {
        return c.totalSpent().isGreaterThan(Money.of(100_000, RUB));
    }
}

public final class LongtimeCustomerSpec extends Specification<Customer> {
    @Override public boolean isSatisfiedBy(Customer c) {
        return ChronoUnit.YEARS.between(c.registeredAt(), Instant.now()) >= 3;
    }
}

public final class HasActiveSubscriptionSpec extends Specification<Customer> {
    @Override public boolean isSatisfiedBy(Customer c) {
        return c.activeSubscription().isPresent();
    }
}

Композиция:

Specification<Customer> premiumDiscountSpec = vipSpec
    .or(longtimeSpec.and(subscriptionSpec));

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

Что это даёт:

  • Правило читается как фраза. «VIP или (давний клиент и активная подписка)» — структура понятна без чтения тела методов.
  • Тестируется композиция. Unit-тест проверяет, что комбинатор даёт правильный результат для всех 8 комбинаций входных условий.
  • Меняется быстро. Появилось «или клиент сотрудник» — добавляем staffSpec, расширяем композицию. Не переписывая большой if-else.

Без Specification это превращается в:

boolean eligible = customer.totalSpent().isGreaterThan(Money.of(100_000, RUB))
    || (ChronoUnit.YEARS.between(customer.registeredAt(), Instant.now()) >= 3
        && customer.activeSubscription().isPresent());

Работает, но: правило размазано по выражению, нельзя переиспользовать vipSpec отдельно, добавить четвёртое условие — переписать всё выражение.

Specification ≠ SQL builder

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

// ПЛОХО — Specification генерирует SQL
public abstract class JooqSpecification<T> extends Specification<T> {
    public abstract Condition toCondition();
}

public class EligibleForRefundSpec extends JooqSpecification<Order> {
    public Condition toCondition() {
        return ORDER.STATUS.eq("DELIVERED")
            .and(ORDER.DELIVERED_AT.greaterThan(LocalDateTime.now().minusDays(14)));
    }
}

Что не так:

  • Specification уехала из core/. Класс начинает зависеть от org.jooq.Condition, generated ORDER table. core/ без jOOQ-зависимости перестаёт компилироваться, нарушается R-MOD-2.
  • Read/write путаются. Repository начинает принимать Specification и через неё строить запросы. Это уже Query Side, а не Repository.
  • In-memory check и SQL check расходятся. Логика дублируется в isSatisfiedBy (in-memory) и toCondition (SQL). Меняем одно — забываем второе.

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

  • EligibleForRefundSpec — pure-domain, isSatisfiedBy(Order), in-memory. Используется на UI и в command-handler-е.
  • RefundEligibleOrderFilter или метод findEligibleForRefund(Instant now) на OrderQueryRepository — read-side, генерирует SQL. Отдельный класс/метод. Дублирование критериев — осознанное (см. filter builders в jOOQ).

В read-сценариях «найти все Order, удовлетворяющие правилу» — это Query Repository, не Specification. Если правило сложное и нужны и в-домене-проверка и SQL-фильтрация — ок, держим два класса, фиксируем синхронизацию через тест.

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

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

// ПЛОХО — Specification используется в одном месте, без комбинаций
class CancelOrderHandler implements UseCaseHandler<CancelOrder, Void> {
    private final OrderCancellableSpec spec;   // ← spec для одного if

    public Void handle(CancelOrder cmd) {
        Order order = orderRepository.findById(cmd.orderId(), FOR_UPDATE).orElseThrow();
        if (!spec.isSatisfiedBy(order)) {
            throw new OrderNotCancellableException(order.id());
        }
        // ...
    }
}

// и сам spec
public class OrderCancellableSpec extends Specification<Order> {
    public boolean isSatisfiedBy(Order order) {
        return order.status() != OrderStatus.SHIPPED;
    }
}

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

public final class Order extends AggregateRoot<OrderId> {
    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new OrderAlreadyShippedException(this.id);
        }
        // ...
    }
}

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

Имя — описывает правило

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

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

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

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

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

АнтипаттернПравилоЧто взамен
Specification, генерирующая SQL (toCondition())R-SPEC-X1Pure-domain Specification + отдельный FilterConditionBuilder для SQL
Specification для одного if в одном местеR-SPEC-X2Простой if или метод на агрегате
Specification, импортирующая Spring/jOOQR-SPEC-X1Pure Java + ddd-building-blocks
Имя OrderHelper, Checker, RuleR-SPEC-1Утверждение: EligibleForRefundSpec, VipCustomerSpec

Куда дальше

  • DDD Tactical → раздел 8. Specification — нормативные формулировки R-SPEC-*.
  • Filter builders в jOOQ — где строится SQL для фильтрации (это не Specification).
  • CQRS Style Guide — query-side, read-model и query-repositories.
  • Domain Service — соседний паттерн «правило в коде», когда правило не сводится к boolean-проверке.
  • Aggregate Root — простые проверки живут на агрегате, а не в Specification.