Why DDD: domain

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

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

Дальше — то, что внутри домена. На Go, файл за файлом.

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

    Второй порт: публикатор

    Факты кто-то должен отправить. Домен и здесь объявляет порт, а не берёт готовую шину: кому и через что — дело инфраструктуры.

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

  2. Шаг 11 из 11

    Что получилось в домене

    Домен целиком — шесть файлов. Дерево и содержимое теперь рядом: щёлкни по любому файлу и посмотри, что в нём.

    • invoice.go — агрегат: поля закрыты, Issue собирает счёт и считает итог из строк.
    • paid.go — смена состояния, файл на метод.
    • events/event.go — факты и их имена.
    • repository.go — порт хранения.
    • publisher.go — порт публикации.
    • vo/money.go — деньги: целые копейки и своя арифметика.

    Ни один из них не знает ни про базу, ни про HTTP, ни про шину. Снаружи — только context и два интерфейса, которые домен объявил сам.

    Поэтому следующая часть в домене ничего не меняет: она пишет то, что эти интерфейсы реализует, и то, что их зовёт.

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