Why DDD: domain
Первая глава закончилась деревом с тремя пустыми каталогами: границы проведены, словарь лежит в корне контекста, домены названы.
Дальше — то, что внутри домена. На Go, файл за файлом.
Шаг 1 из 11
Домен на базовых типах
Разбор пойдёт через issuing: он создаёт счёт, а значит, обязан назвать всё, из чего счёт состоит.
Справа счёт в виде анемичной модели — чаще всего домен пишут именно так: структура из строк, чисел и времени. Читается сразу, ничего лишнего.
Лежит он в
domains/invoice/, рядом с остальным кодом домена. Модули —issuing,payment,overdue— им пользуются, но не владеют: каждому нужен один и тот же счёт, и копии у них быть не может.И это уже домен: у счёта есть номер, клиент, строки, итог, срок оплаты и состояние — слова те же, что в словаре. Главное здесь сделано правильно.
Чем такой код расплачивается — следующий шаг.
Шаг 2 из 11
Что с ним не так
Ни одного правила предметной области этот код не держит. Держат их вызывающие, каждый у себя.
- Итог приходит параметром.
Issueне считает его из строк, а принимает. Значит, существует счёт, у которого итог не равен сумме строк, и он спокойно доедет до базы. float64в деньгах.0.1 + 0.2не равно0.3, а счёт обязан сходиться до копейки. И ничто не мешает сложить цену с количеством: для компилятора это два обычных числа.- Состояние — строка.
"issued","issued ","pain"— опечатка компилируется и доезжает до продакшена. - Поля открыты. Любой код снаружи меняет строку и итог, не спрашивая счёт.
Обычно на это отвечают валидацией на входе. Но валидатор один, а поля правят откуда угодно: хватит одного места, где про него забыли. Правило должно стоять на самих данных.
Шаг 3 из 11
Value object: деньги
Деньги — value object. Идентичности у них нет: две суммы с одним значением — это одни и те же деньги, и сравнивают их по значению, а не по ссылке.
Лежат они в
voвнутри домена: значение — часть модели, и придумывает его домен. Пакет при этом не знает ни про агрегат, ни про модули — зависимость идёт только в одну сторону, к нему.Внутри — целые копейки. Дробных рублей в тип не попадёт: создать деньги можно только из
int64, и0.1 + 0.2больше не приедет выпиской.Сложить деньги можно только через
Add— значит, правило держится везде, где их складывают, а не там, где кто-то вспомнил его проверить. И сложить деньги с количеством больше нельзя: это разные типы, компилятор не даст.Шаг 4 из 11
Возвращаемся к счёту
Тот же файл, что три шага назад, — и в нём
vo.Moneyвместоfloat64. Цена и итог стали деньгами, и посчитать их чем-то другим уже нельзя.Остальные значения — по тому же образцу:
Number,CustomerIDиStatusтоже стали типами, а неstring. Объявлены они в своих файлах пакета, здесь ими просто пользуются — перепутать номер с клиентом в вызове больше не выйдет, а опечатка"pain"не соберётся.Так же заводят срок оплаты, адрес, ИНН — всё, у чего есть свои правила и нет идентичности.
Шаг 5 из 11
Агрегат: счёт
Второе, что изменилось в файле, — поля закрыты. Снаружи счёт держат целиком: поменять строку, минуя его, нельзя. Такой объект и называется агрегатом.
Issue— единственный способ, которым счёт появляется на свет, поэтому все его инварианты проверяются здесь. Строк не может быть ноль. Итог не передают параметром — его считают из строк.Строки копируются, а не берутся ссылкой. Иначе вызывающий оставит срез у себя и поменяет его после — счёт станет другим, не узнав об этом.
Статус меняют методы, а не присваивание снаружи. Какие именно — на следующих шагах: пока у счёта есть только
Issue.Шаг 6 из 11
Смена состояния
Счёт умеет не только появляться. Оплата — переход состояния, и делает его метод, а не присваивание снаружи.
Лежит он отдельным файлом: файл на метод. По каталогу тогда видно, что счёт умеет, — открывать структуру для этого не нужно.
Проверка внутри метода — это инвариант, а не валидация входа: оплаченный счёт не оплатить второй раз, и узнать об этом снаружи можно только по ошибке, которую вернул сам счёт.
Чего в файле нет: записи в базу, отправки события, транзакции. Метод меняет состояние — и всё. Кто сохранит и кто расскажет наружу, решают уровни выше.
Шаг 7 из 11
Порт: репозиторий
Счёт надо где-то держать. Интерфейс объявляет сам домен — он и решает, что ему нужно: взять счёт по номеру и сохранить целиком.
Реализации здесь нет, и
context— единственное, что этот файл знает про внешний мир. Ни SQL, ни драйвера, ни имени таблицы.В этом и разворот зависимости: инфраструктура знает про агрегат, агрегат про инфраструктуру не знает. Поменять Postgres на что угодно можно, не открывая файлов домена.
Сохраняют счёт целиком, а не отдельную строку: агрегат — граница транзакции, и половины у него не бывает.
Шаг 8 из 11
Доменные события
Счёт выставлен — об этом должен узнать кто-то ещё: доставка, бухгалтерия, уведомления. Кто именно, домену неизвестно и неинтересно.
Поэтому он никому не звонит, а называет факт: что случилось, в прошедшем времени.
Issued— счёт выставлен,Paid— оплачен. Внутри только то, чем факт описывается: ни ссылки на агрегат, ни поведения.Единственное, что факт обязан уметь, — назвать себя.
FactNameэто и делает: под этим именем сообщение увидят подписчики, поэтому имя — часть контракта, а не деталь реализации. ИнтерфейсEventровно из него и состоит.Лежат факты в своём пакете —
domains/invoice/events. Читает их не один агрегат: публикует инфраструктура, переводит наружу адаптер, — и отдельный пакет делает эту зависимость видимой. Поэтому в полях строки, а не типы счёта: пакет фактов про агрегат не знает, и по кругу они друг на друга не ссылаются.Прошедшее время — не стиль, а проверка. Не получается назвать так — значит, это не факт, а просьба, и приехать она должна была командой с другой стороны.
Шаг 9 из 11
Агрегат записывает факты
Факт записывает сам агрегат — там же, где меняет состояние. Отправить его он не может: ни шины, ни транзакции у него нет, и знать про них ему не положено.
Поэтому факты копятся внутри и отдаются наружу списком —
Events(). Забрать их и отправить — дело уровня выше.Смысл в том, что состояние и факт о нём меняются в одном месте и одной операцией. Отправь событие прямо из метода — и однажды получишь событие о том, чего в базе не оказалось.
Шаг 10 из 11
Второй порт: публикатор
Факты кто-то должен отправить. Домен и здесь объявляет порт, а не берёт готовую шину: кому и через что — дело инфраструктуры.
Метод один и принимает список: факты уезжают тем же пакетом, каким их записал агрегат. Отправить половину — значит соврать про операцию.
- Итог приходит параметром.
Шаг 11 из 11
Что получилось в домене
Домен целиком — шесть файлов. Дерево и содержимое теперь рядом: щёлкни по любому файлу и посмотри, что в нём.
invoice.go— агрегат: поля закрыты,Issueсобирает счёт и считает итог из строк.paid.go— смена состояния, файл на метод.events/event.go— факты и их имена.repository.go— порт хранения.publisher.go— порт публикации.vo/money.go— деньги: целые копейки и своя арифметика.
Ни один из них не знает ни про базу, ни про HTTP, ни про шину. Снаружи — только
contextи два интерфейса, которые домен объявил сам.Поэтому следующая часть в домене ничего не меняет: она пишет то, что эти интерфейсы реализует, и то, что их зовёт.
Листать шаги можно стрелками ← и →.