Why DDD: specification

ПроблемаПравило «счёт просрочен» живёт в трёх местах и нигде не названо: условие в `WHERE` у планировщика, поле в проекции чтения и отчёт для бухгалтерии. Первое же изменение правила поедет только в одно из трёх.

Правило, которое отвечает «да» или «нет». В прошлой главе домен научился считать то, что не принадлежит одному агрегату; здесь он научится о нём спрашивать.

  1. Шаг 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.

  2. Шаг 6 из 6

    Что получилось

    Щёлкни по файлу, чтобы прочитать.

    • domains/invoice/rules/overdue.go — предикаты: «просрочен», «крупный» и их сборка.
    • applications/overdue/charge.go — сценарий, который спрашивает, а не предполагает.

    Правило наконец названо и лежит в домене. Чтение по-прежнему отвечает на свой вопрос — «какие счета похожи на просроченные», — и это нормально: проекция сужает, а решает домен. Поэтому двух разных правил здесь и не появляется.

Листать шаги можно стрелками ← и →.