handbookНаписать

patterns/enterprise/specification/README.md · тема · 4 языка · 35 тестов

Спецификация (Specification) ​

Бизнес-правило становится отдельным объектом с одним вопросом «подходит ли кандидат», и одно и то же правило проверяет объект, отбирает список и собирается с другими через «и», «или», «не».

Проблема ​

Отдел взыскания передаёт коллекторам просроченные счета. Правило звучит так: просрочка больше 30 дней, и при этом либо сумма от 500 рублей, либо просрочка больше 90 дней, и счёт не оспорен клиентом. Это правило нужно в двух местах: кнопка «передать коллекторам» на карточке счёта проверяет один Invoice, а ночная выгрузка отбирает подходящие счета из всех.

Без приёма условие пишут дважды: if в обработчике кнопки и filter в выгрузке. Через месяц порог поднимают до 1000 рублей в одном месте и забывают в другом, и кнопка начинает пропускать счета, которых нет в выгрузке. Правило к тому же не имеет имени: в коде это строка из && и ||, которую нельзя ни протестировать отдельно, ни показать юристам.

Решение ​

Каждое правило — объект с одним методом isSatisfiedBy(candidate). Specification — общий тип правила, и у него сразу есть комбинаторы and, or, not: из маленьких правил собирается большое, и результат — тоже спецификация, с тем же методом.

Простые правила Overdue, MinAmount и Disputed знают про одно поле счёта и параметр, вынесенный в конструктор. Порог не зашит в класс: Overdue(30) и Overdue(90) — два правила из одного класса, это и делает их кирпичами.

Правило взыскания собирается один раз — Overdue(30) and (MinAmount(500) or Overdue(90)) and not Disputed — и передаётся туда, где оно нужно. Проверка и отбор больше не держат условие у себя: они спрашивают спецификацию.

Структура ​

checks

Invoice

+number: string

+amountCents: int

+daysOverdue: int

+disputed: bool

«abstract»

Specification

+isSatisfiedBy(invoice) : bool

+and(other) : Specification

+or(other) : Specification

+not() : Specification

Overdue

-days: int

MinAmount

-cents: int

Disputed

AndSpecification

-left: Specification

-right: Specification

OrSpecification

-left: Specification

-right: Specification

NotSpecification

-inner: Specification

CollectionPolicy

-rule: Specification

+check(invoice)

+select(invoices) : Invoice[]

Комбинаторы AndSpecification, OrSpecification и NotSpecification хранят другие спецификации и сводят их ответы. Им всё равно, что внутри: простое правило или ещё одна комбинация, поэтому дерево правил строится любой глубины. Это тот же Компоновщик, у которого общая операция — ответ «да или нет». Комбинаторы создаются методами базового типа, поэтому снаружи их имена почти не видны.

Как это собирается ​

Правило собирается один раз, в корне приложения, из настроек: пороги 30 дней, 500 рублей и 90 дней приходят значениями, и из них строится Overdue(30) and (MinAmount(500) or Overdue(90)) and not Disputed. Готовое правило кладётся в CollectionPolicy, а политику DI-контейнер отдаёт обоим потребителям. Поднять порог до 1000 рублей - значит поменять одно значение в настройках, и кнопка с выгрузкой поменяются вместе.

Обработчик кнопки sendToCollection получает номер счёта, находит счёт (в приложении через репозиторий) и спрашивает check: подходит - 200, InvoiceNotCollectable - 422, номера нет - 404. Ночная выгрузка зовёт select на всём списке и получает A-1 (сумма от 500) и A-4 (просрочка больше 90). Оспоренный A-3 не проходит ни там, ни там.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

CollectionPolicy — место, где окупается приём: check() бросает ошибку на одном счёте, select() фильтрует список, и обе работают от одного правила, переданного в конструктор. Поменять порог — значит собрать другое правило, политика не меняется.

ЯзыкОсобенность
PHPabstract readonly class Specification с готовыми and/or/not, правила final readonly; select() передаёт isSatisfiedBy(...) в array_filter как first-class callable
GoСпецификация — функциональный тип func(Invoice) bool с методами And/Or/Not; правила — функции-конструкторы, Disputed — просто значение
PythonКомбинаторы — операторы &, |, ~ через __and__, __or__, __invert__; правило читается как выражение
TypeScriptОбобщённый abstract class Specification<T>: комбинаторы не знают про счёт и подходят любому кандидату

Запуск: make test T=patterns/enterprise/specification

Плюсы и минусы ​

Тесты одинаковы во всех языках. Первый проходит по таблице: каждое простое правило и каждый комбинатор отвечают на одном счёте, так что правило проверяется отдельно от места, где его применят. Второй собирает правило взыскания и отдаёт его политике: отбор из пяти счетов даёт A-1 (крупная сумма) и A-3 (просрочка 120 дней), и каждый отобранный счёт проходит check(). Третий проверяет, что оспоренный A-4 отклоняется с номером счёта в тексте ошибки.

  • ✅ Правило записано один раз и названо: проверка и отбор не расходятся.
  • ✅ Маленькие правила тестируются отдельно, большое собирается из них без нового кода.
  • ✅ Правило — значение: его можно передать, сохранить в настройках политики, собрать по конфигу.
  • ❌ Каждая проверка поля превращается в класс; для двух условий в одном месте это церемония.
  • ❌ В памяти спецификация перебирает всё: на миллионе строк отбор нужен в базе, а isSatisfiedBy туда не перенести без перевода (это и есть практика).
  • ❌ Ответ только «да или нет»: почему правило не выполнено, спецификация сама не скажет, для этого нужен отдельный отчёт о нарушениях.

Когда не применять ​

  • Условие встречается в одном месте и не меняется: обычный if или функция-предикат понятнее.
  • Нужна валидация с перечнем ошибок для пользователя: это задача валидатора, а не булевой спецификации.
  • Отбор идёт только в базе и правило в памяти не нужно: хватит запроса или Query Object.

Отличия между языками ​

  • PHP — классический вид из книги Эванса: абстрактный класс с комбинаторами и наследники. readonly на абстрактном классе обязывает всех наследников быть readonly, так что дерево правил неизменяемо.
  • Go — классы не нужны: спецификация — это функция, а методы можно повесить на функциональный тип. Комбинатор возвращает замыкание, правило без параметров (Disputed) — переменная.
  • Python — перегрузка операторов делает правило выражением: Overdue(30) & (MinAmount(500_00) | Overdue(90)) & ~Disputed(). Скобки обязательны: & связывает сильнее |, как «и» сильнее «или».
  • TypeScript — дженерик Specification<T> отделяет комбинаторы от предметной области: те же and/or/not подойдут клиентам и товарам. Комбинаторы не экспортируются: снаружи правило собирается только через методы.

Практика ​

  • Учебная спецификация живёт в памяти, а в жизни список кандидатов лежит в базе, и выгружать миллион строк ради filter нельзя. Практика: аудитория рассылки использует одно правило отбора клиентов двумя способами: проверяет клиента в памяти и переводит то же дерево в SQL WHERE с параметрами, а правило без перевода в SQL падает с понятной ошибкой.
  • Учебная спецификация отвечает только "да или нет" и не говорит, почему отказала. Практика: доступ к заказу в админке собирает правило «менеджер-владелец, пока заказ не оплачен и в рабочие часы, или администратор всегда» из спецификаций над парой пользователь и заказ, а при отказе возвращает невыполненные условия для ответа 403.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS