Спецификация (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 — и передаётся туда, где оно нужно. Проверка и отбор больше не держат условие у себя: они спрашивают спецификацию.
Структура
Комбинаторы 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() фильтрует список, и обе работают от одного правила, переданного в конструктор. Поменять порог — значит собрать другое правило, политика не меняется.
| Язык | Особенность |
|---|---|
| PHP | abstract 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нельзя. Практика: аудитория рассылки использует одно правило отбора клиентов двумя способами: проверяет клиента в памяти и переводит то же дерево в SQLWHEREс параметрами, а правило без перевода в SQL падает с понятной ошибкой. - Учебная спецификация отвечает только "да или нет" и не говорит, почему отказала. Практика: доступ к заказу в админке собирает правило «менеджер-владелец, пока заказ не оплачен и в рабочие часы, или администратор всегда» из спецификаций над парой пользователь и заказ, а при отказе возвращает невыполненные условия для ответа
403.
Связанные темы
- Практика: аудитория рассылки — одна спецификация для памяти и для SQL
WHEREс параметрами - Практика: доступ к заказу в админке - правила доступа из and/or/not с объяснением отказа
- Компоновщик (Composite) — комбинаторы спецификации и есть компоновщик правил.
- Интерпретатор (Interpreter) — дерево спецификаций похоже на дерево выражений; интерпретатор нужен, когда правило пишут текстом.
- Репозиторий (Repository) — частый потребитель спецификаций:
matching(spec)вместо десятка методовfindBy….