Why DDD: domain services
Домен из второй главы: агрегат, значения, факты и два порта. Здесь к нему добавляется правило, которое не принадлежит ни одному агрегату, — и место, где такому правилу положено жить.
Шаг 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 в названиях каталогов. От исходного «всё в одном файле» это отличается только количеством файлов.
- Правило про один агрегат и только из его данных — метод агрегата.
Шаг 6 из 6
Что получилось
Щёлкни по файлу, чтобы прочитать.
domains/invoice/services/penalty/penalty.go— правило: срок, ставка, календарь → сумма.domains/invoice/calendar.go— календарь рабочих дней: часть модели. Ни портов, ниctx.domains/invoice/charged.go— метод агрегата: применяет сумму, держит инварианты, записывает факт.applications/overdue/charge.go— сценарий по расписанию: достать, посчитать, применить, сохранить, рассказать.
Домен подрос на три файла и по-прежнему ничего не знает ни о базе, ни о расписании. Из трёх модулей с кодом пока один:
issuingиpaymentждут главы про инфраструктуру.
Листать шаги можно стрелками ← и →.