Практика: аудитория рассылки (Mailing audience)
Одно правило отбора клиентов проверяет клиента в памяти и переводится в SQL
WHEREс параметрами, так что приложение и база отбирают одних и тех же людей.
Базовый пример: Спецификация. Там правило живёт только в памяти и отбирает список, который уже загружен. В жизни клиентов миллион и они в базе: отбор должен уйти в SQL, а правило при этом не должно раздвоиться на «код» и «запрос».
Задача
Маркетинг раз в неделю шлёт письмо с подборкой. Аудитория: клиент дал согласие на рассылку, покупал за последние 90 дней, потратил у нас от 5000 рублей или живёт в Казани (там открылся новый магазин), и он не из Москвы — у московских своя рассылка. Правило меняется почти каждую кампанию: то порог суммы, то город, то срок активности.
Сначала было два кода. Выгрузка аудитории — SQL-запрос в репозитории. Проверка перед отправкой (клиент мог отозвать согласие, пока письмо стояло в очереди) — if в обработчике очереди. Когда порог подняли до 5000, запрос поправили, а if нет, и часть писем ушла людям, которых в аудитории уже не было. Вторая беда — дата: «90 дней» считали от NOW() в базе и от часов сервера в коде, и на границе суток результаты расходились.
Что урезано
- Запрос не выполняется: перевод возвращает строку условия и список параметров. В проекте они ушли бы в
PDO/database/sqlвместе сSELECT id FROM customers WHERE …. - Одна таблица
customersс плоскими колонками, без соединений и диалектов SQL: имена колонок зашиты в переводчик. - Даты — дни без времени и часовых поясов; дата кампании передаётся явно, а не берётся из часов.
Решение
Specification — тот же один вопрос isSatisfiedBy(customer), что и в базовом примере. Комбинаторы другие: вместо бинарных and/or здесь AllOf и AnyOf от нескольких правил, потому что так правило читается как список условий кампании и так же переводится в SQL — одной строкой через AND, без лишних скобок.
Главное отличие — у правила два исполнителя. Метод isSatisfiedBy проверяет клиента в памяти. Переводчик toSql обходит то же дерево и строит SqlWhere: условие с плейсхолдерами ? и параметры в их порядке. Правило не знает про SQL, переводчик не знает про кампанию: он знает только, как выглядит каждое правило в колонках таблицы.
Структура
Правила хранят параметры открыто, потому что их читают двое: свой isSatisfiedBy и переводчик. ActiveSince считает границу cutoff один раз в конструкторе от переданной даты кампании, поэтому память и SQL сравнивают с одной и той же датой, а не каждый со своими часами. Custom — правило-замыкание для разовых условий вроде A/B-группы: в памяти работает, а перевода в SQL у него нет и быть не может.
Аудитория
MailingAudience собирает правило кампании из кирпичей: согласие, активность за 90 дней на дату кампании, «от 5000 рублей или Казань», не Москва. Это единственное место, где записаны условия рассылки. Выгрузка передаёт результат в переводчик, очередь отправки — в isSatisfiedBy, и правка порога меняет обе сразу.
Перевод в SQL
toSql разбирает правило по типу: простое правило даёт условие на одну колонку и один параметр, AllOf склеивает части через AND, AnyOf — через OR в скобках, Not оборачивает часть в NOT (…). Скобки ставятся только там, где без них изменится смысл: AND связывает сильнее OR, поэтому AllOf внутри AnyOf скобок не требует, а AnyOf внутри AllOf — требует.
Значение никогда не попадает в строку: условие всегда с ?, значение уходит в параметры. Правило, которое переводчик не знает, — ошибка UnsupportedRule с именем правила и подсказкой, что делать, а не молча пропущенное условие: пропуск расширил бы аудиторию рассылки.
Как это собирается
Правило кампании собирает еженедельная задача рассылки, и дату кампании она передаёт явно: MailingAudience на 12 октября, а не на часы сервера. Кампаний две: обычная и weekly-b, где к аудитории добавлено правило Custom для A/B-группы. Функция выгрузки export пытается перевести правило в SQL через toSql. У обычной кампании это получается, и WHERE уходит в базу целиком. У weekly-b перевод бросает UnsupportedRule, и вызывающий код не пропускает условие, а делит работу: в базу уходит переводимая часть, Custom доигрывается в памяти на полученных строках. Пропустить правило молча значило бы разослать письмо всей аудитории.
Второй потребитель - обработчик очереди отправки. Пока письма ждали, клиент 1 отозвал согласие. Обработчик заново загружает клиента и спрашивает у того же правила isSatisfiedBy: клиент 1 пропущен, 2 и 4 получают письмо. Путь вызова: задача -> правило -> переводчик -> база, а в очереди: обработчик -> репозиторий -> правило.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Комбинаторы AllOf и AnyOf принимают первое правило отдельно от остальных: пустая комбинация не собирается вовсе, а значит, переводчику не нужно решать, чем считать пустой AND. Хранят они список правил открыто — по нему идёт переводчик, — а Not держит одно правило.
| Язык | Особенность |
|---|---|
| PHP | interface Specification, правила final readonly с публичными параметрами; переводчик — match (true) с instanceof, AllOf проверяет детей через array_all из PHP 8.4 |
| Go | Правила — именованные типы (MinTotalSpent int, InCity string, AllOf []Specification); переводчик — switch rule.(type), ошибка — сентинел ErrUnsupportedRule |
| Python | Protocol вместо базового класса; переводчик — match с образцами классов (case InCity(city=city)), параметры — кортеж |
| TypeScript | Структурный interface Specification, правила-классы для instanceof; даты — строки YYYY-MM-DD, которые сравниваются как строки |
Запуск: make test T=patterns/enterprise/specification/practice
Что проверяют тесты
Тесты одинаковы во всех языках и построены на шести клиентах и дате кампании 1 октября 2026. Главная таблица для каждого правила проверяет сразу оба способа: точную строку SQL с параметрами и список клиентов, отобранных в памяти. Так видно, что city = ? с Kazan и отбор [2, 5] — одно и то же правило. Последняя строка таблицы — вся аудитория: пять параметров в порядке плейсхолдеров и клиенты 1, 2 и 6. Клиент 6 стоит ровно на границах — последняя покупка 3 июля (ровно 90 дней) и ровно 5000 рублей — и проходит, потому что обе границы включительные и в памяти, и в SQL.
Второй тест подставляет в город ' OR 1=1 --: строка запроса не меняется, значение уезжает параметром. Третий берёт правило A/B-группы: в памяти оно отбирает чётных клиентов, а перевод в SQL падает с именем правила, даже когда оно спрятано внутри AllOf.
Где граница
- Совпадение памяти и SQL держится на дисциплине: переводчик и
isSatisfiedByнаписаны отдельно, и тест с таблицей — то, что их связывает. Для каждого нового правила нужна строка в этой таблице. - Таблиц и соединений стало больше одной, понадобились сортировка, пагинация, проекции — это уже Query Object или критерии ORM (Doctrine Criteria, SQLAlchemy
select, query builder). Спецификация может переводиться в них, но строить SQL руками больше не стоит; для сборки запроса с параметрами см. практику Строителя. - Правила пишут маркетологи в админке — нужен язык условий и разбор текста в дерево, как в практике Интерпретатора; дерево спецификаций останется внутри.
- Отбор только в базе и проверка в памяти не нужна — спецификация избыточна, хватит запроса в репозитории.
Отличия между языками
- PHP —
match (true)сinstanceof— обычный способ разобрать иерархию без Посетителя. Веткаdefaultбросает исключение, так что новое правило без перевода не пройдёт незамеченным.AllOf(Specification $first, Specification ...$rest)не даёт создать пустую комбинацию: пустойANDдал бы сломанный SQL. - Go — правила без структуры:
MinTotalSpent— этоint,AllOf— срез. Переключатель типовswitch rule.(type)и есть переводчик; ошибка из глубины дерева протаскивается черезjoinи проверяетсяerrors.Is. - Python —
matchс образцами классов разбирает правило и сразу достаёт поля;AllOfиAnyOf— обычные классы с__init__(first, *rest), у них нет__match_args__, поэтому поля берутся по имени. - TypeScript — интерфейс структурный, поэтому переводчику нужны классы для
instanceof; без них пришлось бы вводить полеkindи размеченное объединение. Даты в ISO-формате сравниваются как строки, а граница считается черезDateв UTC, чтобы часовой пояс машины не сдвинул день.
Связанные темы
- Спецификация (базовый пример)
- Практика Строителя: запрос отчёта — тот же принцип «значения только в параметрах», но запрос собирается шагами, а не из правил
- Практика Интерпретатора: фильтры товаров — откуда берётся дерево правил, когда его пишут текстом
- Репозиторий (Repository) — место, где перевод спецификации в запрос обычно живёт