Why DDD: applications && infrastructure

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

Домен описан и ни о чём внешнем не знает: у него есть агрегат, значения, факты и два порта.

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

  1. Шаг 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, и падение на коммите оставит подписчиков с фактом о счёте, которого нет.

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

    Отправляет факты отдельный процесс — читает таблицу и кладёт в шину, помечая отправленное. Он может отправить дважды, поэтому подписчик обязан быть идемпотентным, — но не может отправить то, чего не было.

  2. Шаг 10 из 10

    Что получилось вокруг домена

    По дереву видна раскладка целиком: домен в domains/invoice, сценарии — в applications, по модулю на слайс. Щёлкни по файлу, чтобы прочитать.

    • applications/issuing/issue.go — выставить счёт, всё внутри одной единицы работы.
    • applications/payment/pay.go — принять оплату.
    • domains/invoice/load.go — как агрегат возвращается из базы.
    • domains/invoice/uow.go — порт единицы работы.
    • infrastructure/postgres/invoices.go — реализация хранения.
    • infrastructure/postgres/uow.go — транзакция и conn(ctx, db).
    • infrastructure/postgres/outbox.go — факты строкой в той же транзакции.

    Домен за всю главу подрос на два файла: load.go — про сборку своего же агрегата, и uow.go — ещё один порт. Всё остальное легло снаружи, за портами, которые домен объявил сам.

    Чего здесь ещё нет: входящего вызова и сборки приложения. Дальше — они.

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