← назад к разделу

Когда приходит 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.

Если конвертация Moneyint64 выглядит как логика — она и есть логика, но она принадлежит 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, &notFound) {
        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), где orderaggregate.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-сервиса.