ПроблемаПравило «счёт просрочен» живёт в трёх местах и нигде не названо: условие в `WHERE` у планировщика, поле в проекции чтения и отчёт для бухгалтерии. Первое же изменение правила поедет только в одно из трёх.
Правило, которое отвечает «да» или «нет». В прошлой главе домен научился
считать то, что не принадлежит одному агрегату; здесь он научится о нём
спрашивать.
Шаг 1 из 6
Предикат, которого нет в коде
Справа сценарий просрочки из прошлой главы. Он начисляет пеню — и нигде не спрашивает, просрочен ли счёт: предполагает, что тот, кто его позвал, уже это выяснил.
А выясняют это в трёх местах. Планировщик выбирает счета условием в WHERE. Проекция чтения держит поле Overdue. Отчёт для бухгалтерии считает по-своему, потому что про первые два не знал.
Правило одно, записей три, и ни в одной не написано слово «просрочен». Добавь в него праздники — и поедет только та запись, которую вспомнили.
Шаг 2 из 6
Спецификация
Спецификация — это предикат предметной области, вынесенный в объект. Здесь он назван: Overdue, и отвечает на один вопрос — этот счёт просрочен?
Внутри то, из чего правило состоит: статус счёта, срок и календарь рабочих дней. Ни ctx, ни репозитория — как у доменного сервиса, только на выходе не сумма, а «да» или «нет».
Лежит правило в domains/invoice/rules — своим пакетом, рядом с services. Причина та же, что у пени: правило про счёт, но не из его данных, — и отдельный пакет не даёт ему расползтись по домену файлами вида *_spec.go. Зовут его по пакету: rules.NewOverdue(...).
Что это уже даёт: правило стало отдельной вещью, которую можно позвать. Чтобы узнать, как оно ведёт себя на праздниках, достаточно вызова — ни базы, ни планировщика для этого не нужно.
Шаг 3 из 6
Сценарий спрашивает, а не предполагает
Тот же файл сценария, и в нём одна новая проверка. Теперь он не верит вызывающему на слово: спрашивает у спецификации и выходит без изменений, если счёт не просрочен.
Стоит это одну строку, а закрывает целый класс ошибок: планировщик выбрал лишний счёт, ретрай приехал через сутки после оплаты, руками дёрнули не тот номер — во всех трёх случаях пеня не начислится.
Обрати внимание, что сценарий по-прежнему не содержит правила. Он его зовёт: правило в домене, порядок операции в application.
Шаг 4 из 6
Комбинирование — и когда оно лишнее
Спецификации складываются: AllOf принимает любые предикаты и требует все. Появился интерфейс Specification — он и есть весь контракт: один метод, отвечающий про один агрегат.
Рядом второй предикат — OverBudget, про счёт больше лимита. Вместе с первым он даёт «просрочен и крупный» — правило, по которому дежурный звонит клиенту, а не ждёт.
И сразу про обратную сторону: комбинаторы нужны не всегда. Если два предиката всегда ходят парой, честнее назвать их третьим именем — RequiresCall, — чем собирать AllOf в четырёх местах и выяснять потом, какая из сборок правильная. Or я здесь не завёл намеренно: он почти всегда значит, что правил на самом деле два.
Шаг 5 из 6
Чего это стоит
Теперь честно про цену. Спецификацией нельзя выбрать просроченные счета из базы: она работает с загруженным агрегатом, а грузить всё, чтобы отфильтровать сотню, никто не станет. Значит, выбирать всё равно будет запрос. Из этого есть два выхода.
Спецификация строит запрос. Правило записано один раз, но домен начинает знать про SQL: у объекта появляется метод, отдающий условие, и вместе с ним — зависимость от того, как устроено хранилище.
Запрос выбирает кандидатов, решает спецификация. Условие в запросе нарочно грубое — по сроку и статусу, без календаря и праздников. Оно сужает выборку, а «просрочен ли» спрашивают у правила, когда счёт уже загружен.
Второй выход чаще дешевле, и вот почему: у запроса и у правила разные задачи. Запросу позволено ошибаться в одну сторону — привезти лишние счета; правило потом их отсеет. Дублирования нет, потому что запрос не пытается быть правилом.
Цена второго выхода — лишние загрузки: чем грубее условие, тем больше счетов приедет впустую. Это вопрос объёма, и решается он сужением условия, а не переносом правила в SQL.
Шаг 6 из 6
Что получилось
Щёлкни по файлу, чтобы прочитать.
domains/invoice/rules/overdue.go — предикаты: «просрочен», «крупный» и их сборка.
applications/overdue/charge.go — сценарий, который спрашивает, а не предполагает.
Правило наконец названо и лежит в домене. Чтение по-прежнему отвечает на свой вопрос — «какие счета похожи на просроченные», — и это нормально: проекция сужает, а решает домен. Поэтому двух разных правил здесь и не появляется.