Опирается на правила:
R-SPEC-1…R-SPEC-2иR-SPEC-X1…R-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,Rule—Specдостаточно.R-SPEC-X1Specification для генерации SQL — антипаттерн. Это путает Repository и Query Side. Для read-сценариев — отдельныйFilterConditionBuilder.R-SPEC-X2Specification для одного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, generatedORDERtable.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-X1 | Pure-domain Specification + отдельный FilterConditionBuilder для SQL |
Specification для одного if в одном месте | R-SPEC-X2 | Простой if или метод на агрегате |
Specification, импортирующая Spring/jOOQ | R-SPEC-X1 | Pure Java + ddd-building-blocks |
Имя OrderHelper, Checker, Rule | R-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.