Why DDD: domain services

ПроблемаПравило пени считается по сроку, ставке и календарю рабочих дней. В агрегат оно не влезает — счёт начнёт знать про календарь; в сценарий его тоже нельзя — правило уедет из домена, и второй вызов посчитает иначе.

Домен из второй главы: агрегат, значения, факты и два порта. Здесь к нему добавляется правило, которое не принадлежит ни одному агрегату, — и место, где такому правилу положено жить.

  1. Шаг 1 из 6

    Правилу негде жить

    Справа дерево, и в нём до сих пор пустой overdue. Пора разобраться почему.

    Пеня за просрочку считается по сроку оплаты, ставке за день и календарю рабочих дней: выходные не считаются, праздники тоже. Это правило предметной области — его обсуждают с бухгалтерией, а не с разработчиками.

    Положить его в агрегат нельзя: счёт начнёт знать про календарь и ставку, а они ему не принадлежат — ставку меняют приказом, календарь приходит из справочника. Положить в сценарий тоже нельзя: правило уедет из домена в application, и второй вызов — из другого модуля — посчитает иначе.

    Значит, правилу нужно своё место в домене.

    Шаг 2 из 6

    Доменный сервис

    Доменный сервис — это правило, которому не место ни в одном агрегате. Но домен оно не покидает: пеня — правило про счёт, просто не про то, что счёт знает сам. Здесь оно функцией: счёт, время, ставка, календарь → сумма.

    Счёт приходит агрегатом, а не набором полей, и это не деталь подписи. Правило само спрашивает у счёта то, что ему нужно, — overdue.DueOn(), — и вызывающему не приходится знать, из чего оно считается. Передай вместо счёта дату, и правило станет калькулятором, а знание «пеня считается от срока» уедет к тому, кто его позвал.

    Тогда почему не метод счёта? Законный вопрос: агрегат у нас один, и ChargePenalty уже есть. Но методу пришлось бы принять ставку и календарь — то, чего у счёта нет и чего он знать не должен: ставка приходит из тарифа, календарь — из чужой службы с праздниками и переносами. Счёт начал бы зависеть от них обоих, чтобы посчитать одно число.

    Поводов для сервиса ровно два, и второй очевиднее первого: правило про несколько агрегатов, где методом не обойтись вовсе — ни один из них не вправе менять остальные. Здесь случай первый: правило про один счёт, но из данных, которых у счёта нет.

    Поэтому и лежит внутри домена счёта, а не сбоку от всех доменов: domains/invoice/services/penalty. Своим пакетом, а не файлом рядом с агрегатом — у правила свои входы и свой набор реализаций календаря, и складывать его в общий пакет домена — значит смешивать «что такое счёт» с «как считают пеню». Называется он по пакету: penalty.Amount(...).

    Отдельного services/ в корне доменов нет намеренно. Правило либо про конкретную модель — и тогда живёт в ней, — либо про несколько доменов сразу, и тогда это не доменный сервис, а координация: её место в application, где уже есть порядок операции и транзакция. Общий каталог для «правил вообще» заканчивается свалкой, в которой предметной области не видно.

    Чего у сервиса нет: context, репозитория, ошибок инфраструктуры. Поэтому его можно позвать одной строкой и получить ответ, не поднимая ни базы, ни шины.

    Calendar остался в домене, в calendar.go: календарём пользуется не только пеня — дальше по нему будет отвечать и правило просрочки. Это интерфейс, но не порт: у него нет ни ctx, ни ошибки, и говорит он о предметной области, а не о хранилище. Признак простой: порт описывает, откуда взять данные, а это — часть модели, у которой может быть несколько реализаций, потому что календарей у бизнеса несколько.

    Шаг 3 из 6

    Считает сервис, применяет агрегат

    Сумму посчитал сервис — по тому же счёту, который ему передали, — но приписать её может только сам счёт. Поэтому ChargePenalty — метод агрегата, и он держит свои инварианты: на оплаченный счёт пеня не начисляется, нулевая пеня не меняет ничего.

    Отсюда и граница между ними: сервис агрегат читает, менять его он не может — у него нет ни доступа к полям, ни права записать факт.

    Строка и итог меняются здесь же, одной операцией, и здесь же записывается факт — PenaltyCharged. Сервис фактов не публикует и состояние не меняет: он считает.

    Это и есть разделение труда внутри домена: правило в сервисе, состояние в агрегате. Перепутай их местами — и либо агрегат обрастёт справочниками, либо сервис начнёт писать в чужие поля.

    Шаг 4 из 6

    Кто это зовёт

    Первый из пустых модулей наполнился: applications/overdue — сценарий по расписанию. Он достаёт счёт, зовёт сервис, применяет результат методом, сохраняет и публикует факты.

    Сравни с сервисом рядом. У сценария есть ctx, два порта и порядок операции — и ни одного правила. У сервиса есть правило — и ни одного порта. Признак, по которому их не спутать: появился ctx или интерфейс к базе — это уже не доменный сервис, а application.

    Срок сценарий не трогает вовсе: он отдаёт правилу счёт, а срок правило возьмёт само. Счёт знает срок, потому что сам его и выставил; ставка и календарь у счёта не хранятся — их приносит сценарий. В этом и разница между полем агрегата и входом правила.

    Шаг 5 из 6

    Когда сервис не нужен

    Доменный сервис — самый злоупотребляемый инструмент в DDD, поэтому про обратную сторону честно.

    • Правило про один агрегат и только из его данных — метод агрегата. MarkPaid сервисом быть не должен: он меняет состояние одного счёта и ничего снаружи для этого не требует.
    • Правило про одно значение — метод value object. Сложение денег — дело Money, а не сервиса рядом с ним.
    • Правило, которому нужен репозиторий или шина, — не доменный сервис. Это сценарий, и он живёт в application.

    Если сервисы появляются по умолчанию для всего, получается анемичный домен: структуры с полями, процедуры вокруг них и слова DDD в названиях каталогов. От исходного «всё в одном файле» это отличается только количеством файлов.

  2. Шаг 6 из 6

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

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

    • domains/invoice/services/penalty/penalty.go — правило: срок, ставка, календарь → сумма.
    • domains/invoice/calendar.go — календарь рабочих дней: часть модели. Ни портов, ни ctx.
    • domains/invoice/charged.go — метод агрегата: применяет сумму, держит инварианты, записывает факт.
    • applications/overdue/charge.go — сценарий по расписанию: достать, посчитать, применить, сохранить, рассказать.

    Домен подрос на три файла и по-прежнему ничего не знает ни о базе, ни о расписании. Из трёх модулей с кодом пока один: issuing и payment ждут главы про инфраструктуру.

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