Когда приходит HTTP-запрос или Kafka-сообщение, кто-то должен принять его, разобрать и передать в бизнес-логику. Именно это делает входной адаптер (in-adapter): принимает внешний сигнал, переводит его в понятную для ядра форму и передаёт дальше. Никакой бизнес-логики — только трансформация и маршрутизация.
Зачем вообще нужен отдельный слой
Представьте handler, который одновременно парсит JSON, проверяет поля, делает запрос в базу, считает скидку и формирует ответ. Такой код трудно тестировать и невозможно переиспользовать: чтобы проверить расчёт скидки, нужно поднять HTTP-сервер.
Гексагональная архитектура решает это разделением: ядро (core) содержит всю бизнес-логику, адаптеры — только перевод между форматами. In-adapter — это тонкая обёртка над HTTP (или Kafka, или gRPC), которая не знает ничего о том, как устроена база данных или какой платёжный провайдер используется.
Один пакет на каждый тип входа
Первое правило: каждый тип входа живёт в отдельном пакете.
internal/
adapter/
in/
http/
user/ # роутеры для конечного пользователя (JWT user-audience)
admin/ # роутеры для администратора (другой middleware)
kafka/ # Kafka consumer как точка входа
Разделение user/ и admin/ — не косметика. Middleware аутентификации для пользователя и для администратора разные. Если оба роутера в одном пакете, случайно подключить чужую цепочку middleware не остановит компилятор. В разных пакетах это становится ошибкой компиляции: admin/router.go просто не видит структуры из user/.
Роутеры монтируются вместе только в точке сборки приложения:
// bootstrap/main.go
r := chi.NewRouter()
r.Mount("/api/v1", userHTTP.NewRouter(confirmHandler, getOrderHandler))
r.Mount("/admin/api", adminHTTP.NewRouter(adminHandler))
Как работает chi-handler
Handler принимает запрос, преобразует его в команду и вызывает UseCase. Репозиторий handler не получает — он работает только через UseCase Handler из ядра.
// adapter/in/http/user/order_handler.go
package user
type OrderHandler struct {
confirmOrder *usecase.ConfirmOrderHandler
getOrder *usecase.GetOrderHandler
mapper OrderRequestMapper
}
func NewOrderHandler(confirmOrder *usecase.ConfirmOrderHandler, getOrder *usecase.GetOrderHandler) *OrderHandler {
return &OrderHandler{confirmOrder: confirmOrder, getOrder: getOrder}
}
func (h *OrderHandler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
var req ConfirmOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid json"))
return
}
if err := validate.Struct(req); err != nil {
httperr.Write(w, r, mapValidationErrors(err))
return
}
cmd := h.mapper.ToConfirmCommand(r.Context(), req)
if err := h.confirmOrder.Handle(r.Context(), cmd); err != nil {
httperr.Write(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
orderID := aggregate.OrderID(chi.URLParam(r, "id"))
order, err := h.getOrder.Handle(r.Context(), usecase.GetOrderQuery{OrderID: orderID})
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.mapper.ToOrderResponse(order))
}
Маршруты handler не регистрирует сам — это делает bootstrap/. Handler отвечает только за HTTP-логику одного действия.
// bootstrap/main.go
r.Post("/orders/{id}/confirm", orderHandler.ConfirmOrder)
r.Get("/orders/{id}", orderHandler.GetOrder)
Маппер — отдельная структура для перевода форматов
Handler вызывает маппер, чтобы преобразовать request-DTO в команду для UseCase, и обратно — domain-объект в response-DTO. Маппер живёт в том же пакете, что и handler, но вынесен в отдельный файл.
// adapter/in/http/user/order_request_mapper.go
package user
type OrderRequestMapper struct{}
type ConfirmOrderRequest struct {
PaymentRef string `json:"payment_ref" validate:"required"`
}
type OrderResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Total int64 `json:"total_kopecks"`
}
func (OrderRequestMapper) ToConfirmCommand(ctx context.Context, req ConfirmOrderRequest) usecase.ConfirmOrderCommand {
return usecase.ConfirmOrderCommand{
PaymentRef: req.PaymentRef,
}
}
func (OrderRequestMapper) ToOrderResponse(o aggregate.Order) OrderResponse {
return OrderResponse{
ID: string(o.ID()),
Status: string(o.Status()),
Total: o.Total().Kopecks(),
}
}
Важное правило: в ответ клиенту никогда не отправляется сам domain-объект (aggregate.Order). Причина проста — domain-объект меняется со временем, а контракт API должен оставаться стабильным. Маппер создаёт OrderResponse с явными полями, и это единственное, что уходит в JSON.
Если конвертация Money → int64 выглядит как логика — она и есть логика, но она принадлежит value-объекту Money.Kopecks(), а не мапперу. Маппер только пакует поля.
Ошибки: как они превращаются в HTTP-статусы
В Go нет исключений. Ошибки возвращаются как значения, и in-adapter — то место, где ошибка из ядра получает HTTP-статус.
Функция httperr.Write читает тип ошибки и подбирает нужный код ответа:
// adapter/in/http/httperr/write.go
package httperr
func Write(w http.ResponseWriter, r *http.Request, err error) {
var notFound *out.OrderNotFoundError
if errors.As(err, ¬Found) {
writeJSON(w, http.StatusNotFound, errorBody(err))
return
}
var appErr interface{ Kind() apperr.Kind }
if errors.As(err, &appErr) {
switch apperr.KindOf(err) {
case apperr.Validation:
writeJSON(w, http.StatusUnprocessableEntity, errorBody(err))
return
case apperr.Domain:
writeJSON(w, http.StatusConflict, errorBody(err))
return
case apperr.Integration:
slog.ErrorContext(r.Context(), "integration error", "err", err)
writeJSON(w, http.StatusBadGateway, errorBody(err))
return
}
}
slog.ErrorContext(r.Context(), "unexpected error", "err", err)
writeJSON(w, http.StatusInternalServerError, errorBody(errors.New("internal error")))
}
Handler не занимается выбором HTTP-статуса — он просто передаёт ошибку в httperr.Write:
if err := h.confirmOrder.Handle(r.Context(), cmd); err != nil {
httperr.Write(w, r, err)
return
}
Это позволяет добавить новый тип ошибки в одном месте, не трогая каждый handler.
Что in-adapter знает и что не знает
In-adapter работает с web-стеком и UseCase-ами. Он не знает ничего об исходящих адаптерах.
Знает:
github.com/go-chi/chi/v5— маршрутизация и middleware.encoding/json— декодирование запроса и кодирование ответа.github.com/go-playground/validator/v10— валидация request-DTO.log/slog— структурированное логирование на краю системы.- Пакеты
core/<bc>/usecase/— UseCase Handler-ы и команды.
Не знает:
adapter/out/persistence/— in-adapter не импортирует sqlc-репозиторий напрямую.adapter/out/sber/— in-adapter не знает про конкретных платёжных провайдеров.- Другие
adapter/in/http/admin/— пакетuser/не импортирует изadmin/.
Эту изоляцию можно проверить архитектурным тестом:
// bootstrap/architecture_test.go
func TestInAdapterHasNoOutAdapterImports(t *testing.T) {
forbidden := modulePath(t) + "/internal/adapter/out"
pkgs := loadPackages(t, "./internal/adapter/in/...")
for _, pkg := range pkgs {
for imp := range pkg.Imports {
if strings.HasPrefix(imp, forbidden) {
t.Errorf("in-adapter %s imports out-adapter %s", pkg.PkgPath, imp)
}
}
}
}
Частые ошибки
Бизнес-логика в handler. Проверка if req.Amount > 100_000 прямо в handler — это бизнес-правило, которое принадлежит ядру. Handler только парсит и валидирует структуру запроса. Логика — в UseCase Handler или методе агрегата.
Репозиторий в handler. Если OrderHandler получает persistence.OrderRepository в конструктор, граница сломана. Handler обращается к UseCase, UseCase — к репозиторию.
Domain-объект в ответ. json.NewEncoder(w).Encode(order), где order — aggregate.Order, — частая ошибка. Domain-объект меняется вместе с бизнес-логикой; API-контракт должен меняться осознанно. Маппер создаёт явный OrderResponse.
Роутер монтируется в adapter. Handler не должен знать свой маршрут. Монтирование роутера — задача bootstrap/main.go, не пакета адаптера.
Коротко
- In-adapter — тонкая граница между внешним миром (HTTP, Kafka) и ядром: парсит, валидирует, маппит, передаёт команду.
- Каждый тип входа — отдельный пакет (
http/user/,http/admin/,kafka/). Смешивать их нельзя. - Цепочка: chi-handler → маппер → UseCase Handler. Репозиторий handler не получает.
- Маппер — отдельная структура: request-DTO → command и domain-объект → response-DTO. Отправлять domain-объект напрямую в JSON нельзя.
- Ошибки из ядра конвертируются в HTTP-статусы через
httperr.Write— handler статусы не назначает. - In-adapter импортирует только web-стек и пакеты ядра. Импорт исходящих адаптеров (
adapter/out/) запрещён.
Что почитать дальше
- Adapters out — симметричная сторона: sqlc/pgx и исходящие HTTP-клиенты.
- Core layer — rich aggregate, UseCase Handler, port/out interface.
- Bootstrap / composition root — где собирается chi.Router и как соединяются все части.
- Ports — контракт PaymentPort в
core/<bc>/port/out/. - Module structure — полная раскладка пакетов Go-сервиса.