ПроблемаСценарий, база и входящий вызов расползаются по слоям, и кто из них что знает о домене — выясняется в проде: где началась транзакция, кто отправил факт и почему подписчик узнал о том, чего в базе нет.
Домен описан и ни о чём внешнем не знает: у него есть агрегат, значения,
факты и два порта.
Дальше — всё, что вокруг: сценарии, реализация портов, входящий вызов и сборка
приложения.
Шаг 1 из 10
Use case: выставить счёт
Модуль issuing наконец с кодом. Сценарий делает три шага: собрать агрегат, сохранить, опубликовать факты.
Правил предметной области здесь нет ни одного — они остались в Issue. Здесь только порядок, и в этом весь application: он раздаёт работу и держит ход операции.
Сценарий принимает то, что нужно домену, — обычными параметрами. Что приходит по HTTP или с шины, досюда не доезжает: это DTO транспорта, и переводит их транспорт.
Зависимости — два порта, и ни одной реализации. Поэтому у сценария одна причина меняться — порядок операции: сменится база или шина, а он останется таким же.
Публикация после сохранения, а не до: подписчик не должен узнать о том, чего в базе не оказалось. Атомарности между этими двумя шагами всё ещё нет — её добавляют транзакционным outbox, и это отдельный разговор.
Шаг 2 из 10
Use case: принять оплату
Второй модуль, тот же порядок, другая история: достать агрегат, вызвать метод, сохранить, опубликовать.
Проверку «уже оплачен» здесь не пишут — она в MarkPaid. Сценарий только передаёт ошибку наверх: проверяй он сам, правило жило бы в двух местах, и однажды в одном из них устарело бы.
Видно и зачем репозиторий отдаёт агрегат целиком: состояние меняют его методы, а для этого нужен он сам, а не строка из базы.
Шаг 3 из 10
Порты есть, реализации нет
Справа порт хранения из второй главы. Домен объявил, что ему нужно: взять счёт по номеру и сохранить целиком. Кто это делает — не его забота.
Пора сделать. И первый же вопрос реализации — не про SQL: как вернуть агрегат, у которого все поля закрыты? Issue для этого не годится, он создаёт новый счёт и записывает факт «счёт выставлен» — а мы достаём уже существующий.
Шаг 4 из 10
Как агрегат возвращается из базы
Load — второй способ появиться: не «создать», а «поднять то, что уже было». Фактов он не записывает, инвариантов не проверяет: данные уже лежали в базе, и проверять их заново — значит не доверять своей же записи.
Живёт он в домене, потому что только домен может собрать свой агрегат. Отдавать эту сборку инфраструктуре — значит открыть ей поля, а вместе с ними и возможность собрать счёт, которого предметная область не допускает.
Шаг 5 из 10
Реализация порта
Инфраструктура: database/sql, две таблицы, запросы. Домен про этот файл ничего не знает — знание идёт снаружи внутрь.
Save пишет счёт и его строки двумя запросами. Это уже требует транзакции: между ними может упасть что угодно, и тогда в базе останется счёт без строк — состояние, которого предметная область не допускает.
Соединение берётся не из поля, а функцией conn(ctx, db). Почему — следующие шаги.
Шаг 6 из 10
Сохранили и опубликовали
Вернёмся к сценарию. Он делает два вызова: сохранить и опубликовать. По отдельности оба правильные, вместе — нет.
Упал Publish — счёт в базе есть, а никто о нём не знает: доставка не начнёт собирать посылку, бухгалтерия не увидит начисления. Поменять вызовы местами — ещё хуже: упадёт Save после успешного Publish, и подписчики узнают о счёте, которого нет.
Оговорку про это я оставил ещё на первом шаге. Пора её закрыть: операция должна быть одна, а не две.
Шаг 7 из 10
Unit of work
Единица работы — это порт, который говорит одно: выполнить работу целиком или не выполнить вовсе. Ни Begin, ни Commit, ни слова «транзакция» в домене нет — есть функция, которую кто-то выполнит под своей ответственностью.
Поэтому порт и стоит рядом с остальными: домен не знает, что за ним транзакция Postgres, и с тем же интерфейсом работал бы на чём угодно. Единственное, что он требует, — чтобы «целиком» действительно означало целиком.
Шаг 8 из 10
Одна операция вместо двух
Сценарий изменился в одном месте: сохранение и публикация уехали внутрь Do. Порядок шагов тот же, изменилась граница — теперь это одна операция. Оплата и просрочка переехали в Do так же: в дереве они помечены.
Транзакцию реализация кладёт в ctx, а адаптеры достают её оттуда той самой функцией conn. Поэтому сценарий не передаёт транзакцию параметром и вообще её не видит: ему нужна граница, а не соединение.
Тут легко перестараться. Единица работы — не «транзакция на каждый запрос» и не способ менять два агрегата разом: агрегат по-прежнему граница согласованности, а Do держит границу операции.
Шаг 9 из 10
Шина в транзакцию не входит
Осталась последняя ложь. Шина — не база: её нельзя откатить вместе с транзакцией. Отправь факт внутри Do, и падение на коммите оставит подписчиков с фактом о счёте, которого нет.
Поэтому реализация публикации ничего не отправляет. Она пишет факт строкой в ту же транзакцию, рядом со счётом: либо есть оба, либо нет ни одного.
Отправляет факты отдельный процесс — читает таблицу и кладёт в шину, помечая отправленное. Он может отправить дважды, поэтому подписчик обязан быть идемпотентным, — но не может отправить то, чего не было.
Шаг 10 из 10
Что получилось вокруг домена
По дереву видна раскладка целиком: домен в domains/invoice, сценарии — в applications, по модулю на слайс. Щёлкни по файлу, чтобы прочитать.
applications/issuing/issue.go — выставить счёт, всё внутри одной единицы работы.
applications/payment/pay.go — принять оплату.
domains/invoice/load.go — как агрегат возвращается из базы.
infrastructure/postgres/uow.go — транзакция и conn(ctx, db).
infrastructure/postgres/outbox.go — факты строкой в той же транзакции.
Домен за всю главу подрос на два файла: load.go — про сборку своего же агрегата, и uow.go — ещё один порт. Всё остальное легло снаружи, за портами, которые домен объявил сам.
Чего здесь ещё нет: входящего вызова и сборки приложения. Дальше — они.