Why DDD: transport && wiring

ПроблемаСценарии написаны, порты закрыты реализациями — а позвать сервис нельзя: снаружи приходит JSON, а сценарий ждёт доменные сущности. И собрать это в работающий процесс тоже некому: `cmd/` пуст.

Домен описан, сценарии написаны, порты закрыты реализациями. Не хватает двух вещей: входящего вызова и того, кто соберёт это в работающий процесс.

Одна ручка — POST /invoices — и сборка сервиса целиком.

  1. Шаг 1 из 8

    Позвать некому

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

    Снаружи приходит не это. Снаружи — JSON: номер строкой, суммы числами, дата в ISO. Между ними нужен перевод, и вопрос не в том, какой взять фреймворк, а в том, кто переводит и где этот перевод лежит.

    Второе видно в дереве: рядом появился пустой cmd/api. Сценарию нужны три реализации портов, реализациям — соединение с базой, соединению — строка из окружения. Сейчас этого не делает никто, и запустить сервис нельзя.

    Шаг 2 из 8

    Тело запроса

    DTO — структура из одних полей: то, в чём данные пересекают границу. Теги json — часть контракта ручки, и живут они здесь, а не в домене: домен про JSON не знает.

    Типы примитивные намеренно. invoice.Number снаружи не принимают: приехать может что угодно, и превратить строку в тип домена — работа транспорта.

    Validate проверяет форму, а не предметную область: номер не пустой, тело читается. «Счёт без строк» — правило домена, его проверит Issue; продублировать его здесь — значит держать два места, одно из которых однажды устареет.

    Лежит тело запроса в своём пакете — transport/http/dto. Так у ручки и у её контракта разные файлы и разные причины меняться: контракт правят, когда договорились с клиентом, хендлер — когда меняется порядок вызова. И имена в пакете читаются как есть: dto.IssueRequest.

    Там же и перевод — ToDomain(). Он отдаёт не агрегат, а то, из чего агрегат создают: номер, покупателя, строки и срок, уже в типах домена. Собирать счёт транспорт не вправе — это делает Issue в домене, и проверки остаются там. Зато хендлер не обрастает циклами: одна строка вместо шести.

    Дальше транспорта DTO не едет: внутрь уезжают аргументы сценария.

    Шаг 3 из 8

    Ручка: перевести и позвать

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

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

    Посмотри, чем она владеет: *application.UseCase и всё. Ни базы, ни транзакции, ни шины. Поэтому у ручки одна причина меняться — контракт запроса: база, транзакция и шина меняются, не задевая её.

    Ответ — 201 и пустое тело: номер клиент прислал сам, возвращать ему его же данные незачем.

    Шаг 4 из 8

    Ошибки домена в коды ответа

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

    Перевод ошибок — тоже перевод, и лежит он там же, на границе: правило домена нарушено — 422, состояние конфликтует — 409, остальное — 500 и запись в лог.

    Наружу не уезжает ни текст ошибки базы, ни стек: причина остаётся в логе, клиенту — код и короткая фраза.

    Неизвестная ошибка получает 500 намеренно. Список кодов — часть контракта ручки, и расширять его должен человек, а не err.Error().

    Шаг 5 из 8

    Слайс знает свой маршрут

    Адрес ручки объявляет модуль, а не общий роутер где-то в корне. Так вертикальный слайс остаётся целым: сценарий, DTO, хендлер и адрес лежат вместе, потому что меняются вместе.

    Мультиплексор — стандартный: с Go 1.22 он понимает и метод, и параметры в пути. Фреймворку здесь нечего добавить; когда понадобится — логирование, авторизация, лимиты, — его место в сборке, а не в модуле.

    Сборке достаётся один вызов Register: ни путей, ни методов она не знает.

    Шаг 6 из 8

    Собрать это некому

    cmd/api наконец с кодом, и сборка написана руками. Читается сверху вниз: соединение, три адаптера, сценарий, ручка, мультиплексор, сервер.

    Хорошая новость: это и есть весь DI. Зависимости приходят аргументами конструкторов, реализации выбираются в одном месте, и никакого контейнера для этого не нужно.

    Плохая начинается со второго приложения. Рядом встанут отправщик outbox и консьюмер платежей: им нужна та же база и те же адаптеры, но другая входная точка. Эти семь строк скопируют, копии разъедутся, и через полгода отправщик будет работать со своим пулом соединений.

    И порядок: чтобы узнать, что issuing.New требует единицу работы, надо открыть его подпись. Забыл — компилятор скажет про несовпадение типов в шестой строке, а не про то, чего не хватает.

    Шаг 7 из 8

    wire: та же сборка, но сгенерированная

    wire — не контейнер и не рефлексия. Это генератор: читает список конструкторов и пишет ровно тот код, который мы написали руками шагом раньше.

    wire.NewSet — набор адаптеров, общий для всех приложений сервиса. wire.Bind говорит, какая реализация стоит за портом: конструктор отдаёт *postgres.Invoices, а сценарию нужен invoice.Repository.

    initHandler не выполняется: файл собирается только по тегу wireinject, а его тело — заглушка. wire читает wire.Build и создаёт рядом wire_gen.go — тот коммитят, и в проде работает он.

    Что это даёт: забытую зависимость показывает генератор, с именем типа, до запуска; набор адаптеров один на все точки входа; сгенерированный код читается глазами, как свой.

    И без фанатизма. Одному приложению с пятью конструкторами wire не нужен — руками короче и понятнее. Смысл появляется, когда из одного набора собирают второе и третье приложение.

  2. Шаг 8 из 8

    Что получилось

    Щёлкни по файлу, чтобы прочитать.

    • applications/issuing/transport/http/dto/issue.go — тело запроса и проверка формы.
    • applications/issuing/transport/http/handler.go — перевод и вызов сценария.
    • applications/issuing/transport/http/errors.go — ошибки домена в коды ответа.
    • applications/issuing/transport/http/routes.go — адрес ручки.
    • cmd/api/main.go — точка входа.
    • cmd/api/wire.go — список провайдеров для генератора.

    Цепочка замкнулась: запрос приходит на POST /invoices, превращается в доменные сущности, сценарий собирает агрегат, сохраняет его и пишет факт — всё одной операцией.

    Домен за эту главу не изменился ни на строку. Вторая ручка добавляется одним файлом рядом, второе приложение — одной функцией в wire.go.

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