ПроблемаСценарии написаны, порты закрыты реализациями — а позвать сервис нельзя: снаружи приходит JSON, а сценарий ждёт доменные сущности. И собрать это в работающий процесс тоже некому: `cmd/` пуст.
Домен описан, сценарии написаны, порты закрыты реализациями. Не хватает двух
вещей: входящего вызова и того, кто соберёт это в работающий процесс.
Одна ручка — POST /invoices — и сборка сервиса целиком.
Шаг 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 не нужен — руками короче и понятнее. Смысл появляется, когда из одного набора собирают второе и третье приложение.
Шаг 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.