Опирается на правила: R-HEX-AIN-1R-HEX-AIN-4 и R-HEX-AIN-X1R-HEX-AIN-X4 из Hexagonal Rules → раздел 5. Adapters in.

Важно знать

  • На каждый тип входа — отдельный пакет: adapter/in/http/user/, adapter/in/http/admin/, adapter/in/kafka/.
  • chi-handler маппит *http.Request → command → зовёт UseCase.Handle; репозиторий не инжектится в handler напрямую.
  • Маппер (OrderRequestMapper) — отдельная структура в пакете adapter/in/http/; domain entity не пишется в http.ResponseWriter напрямую.
  • In-adapter знает chi, encoding/json, go-playground/validator. Не знает adapter/out/persistence/ или adapter/out/sber/.
  • Бизнес-логика в handler запрещена. Логика — в ConfirmOrderHandler в core/.
  • Handler зовёт UseCase.Handle; ошибки прокидываются через httperr.Write на edge — in-adapter не решает, как их показать фронту.
  • Возвращай response-DTO (OrderResponse), не aggregate.Order; domain entity не сериализуется предсказуемо.
  • User- и admin-роутеры разделены в разные пакеты — R-HEX-MOD-X3 запрещает смешивать их в одном adapter/in/http/.

In-adapter — это точка, через которую внешний мир (HTTP-запрос, Kafka-сообщение) входит в сервис. Он принимает запрос, маппит его в UseCase command, передаёт в handler, получает результат, маппит обратно в HTTP-ответ. Никакой бизнес-логики — только трансформация и маршрутизация. Ниже — раскрытие правил R-HEX-AIN-* в идиомах Go.

Per-type пакеты

R-HEX-AIN-1: каждый тип входа — отдельный пакет.

internal/
  adapter/
    in/
      http/
        user/    # chi-роутеры для конечного пользователя (JWT user-audience)
        admin/   # chi-роутеры для админа (отдельный middleware auth)
      kafka/     # kafka-consumer как entry-point

Разделение user/ и admin/ — не косметика. Middleware аутентификации для пользователя и для админа разные. Если оба роутера в одном пакете, случайный импорт чужой middleware-цепочки не остановит компилятор. В отдельных пакетах — compile-time barrier: admin/router.go не видит структуры из user/.

bootstrap/main.go монтирует оба под разные префиксы:

// bootstrap/main.go
r := chi.NewRouter()
r.Mount("/api/v1", userHTTP.NewRouter(confirmHandler, getOrderHandler))
r.Mount("/admin/api", adminHTTP.NewRouter(adminHandler))

chi-handler → маппер → UseCase

R-HEX-AIN-2: handler маппит request → command → зовёт Handle; не зовёт репозиторий напрямую.

// adapter/in/http/user/order_handler.go
package user

type OrderHandler struct {
    confirmOrder *usecase.ConfirmOrderHandler
    mapper       OrderRequestMapper
}

func NewOrderHandler(confirmOrder *usecase.ConfirmOrderHandler) *OrderHandler {
    return &OrderHandler{confirmOrder: confirmOrder}
}

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))
}

Регистрация в роутере — только в bootstrap/:

// bootstrap/main.go
r.Post("/orders/{id}/confirm", orderHandler.ConfirmOrder)
r.Get("/orders/{id}", orderHandler.GetOrder)

OrderHandler не знает, какой маршрут к нему ведёт — это решает bootstrap/. Handler ответственен только за HTTP-слой одного действия.

Маппер

R-HEX-AIN-3: маппер — отдельная структура в пакете адаптера. Переводит request-DTO ↔ UseCase command и domain ↔ response-DTO.

// 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(),
    }
}

Маппер не несёт бизнес-логики. Если нужна конвертация Moneyint64 — это вопрос к value object Money.Kopecks(), не маппера. Маппер только пакует поля.

Для domain Product аналогично:

// adapter/in/http/user/product_request_mapper.go
package user

type CreateProductRequest struct {
    Name     string `json:"name"     validate:"required,min=2,max=200"`
    PriceSek int64  `json:"price_øre" validate:"required,min=1"`
    SKU      string `json:"sku"      validate:"required"`
}

type ProductResponse struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    PriceSek int64  `json:"price_øre"`
    InStock  bool   `json:"in_stock"`
}

func (ProductRequestMapper) ToCreateCommand(req CreateProductRequest) usecase.CreateProductCommand {
    return usecase.CreateProductCommand{
        Name:  req.Name,
        Price: value_object.Money{Amount: req.PriceSek, Currency: "SEK"},
        SKU:   aggregate.SKU(req.SKU),
    }
}

func (ProductRequestMapper) ToProductResponse(p aggregate.Product) ProductResponse {
    return ProductResponse{
        ID:      string(p.ID()),
        Name:    p.Name(),
        PriceSek: p.Price().Amount,
        InStock: p.IsInStock(),
    }
}

Ошибки-значения на edge

В Go нет исключений — ошибки возвращаются как значения. In-adapter — это edge, где ошибка из core/ конвертируется в HTTP-статус.

httperr.Write читает apperr.Kind и подбирает код ответа:

// adapter/in/http/httperr/write.go
package httperr

func Write(w http.ResponseWriter, r *http.Request, err error) {
    var appErr interface{ Kind() apperr.Kind }
    if errors.As(err, &appErr) {
        switch appErr.Kind() {
        case apperr.Validation:
            writeJSON(w, http.StatusUnprocessableEntity, errorBody(err))
            return
        case apperr.Domain:
            writeJSON(w, http.StatusConflict, errorBody(err))
            return
        case apperr.NotFound:
            writeJSON(w, http.StatusNotFound, 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. Handler только прокидывает ошибку:

if err := h.confirmOrder.Handle(r.Context(), cmd); err != nil {
    httperr.Write(w, r, err)
    return
}

Что in-adapter знает и не знает

R-HEX-AIN-4: in-adapter знает web-стек, не знает другие адаптеры.

Знает:

  • github.com/go-chi/chi/v5 — маршрутизация и middleware.
  • encoding/json — декодирование запроса и кодирование ответа.
  • github.com/go-playground/validator/v10 — валидация request-DTO.
  • log/slog — структурированное логирование на edge.
  • Пакеты core/<bc>/usecase/ — UseCase Handler'ы и команды.

Не знает:

  • adapter/out/persistence/ — in-adapter не импортирует sqlc-репозиторий.
  • adapter/out/sber/ — in-adapter не знает про Sber или ОднаКассу.
  • Другие adapter/in/http/admin/user/ не импортирует admin/.

Это верифицируется архитектурным тестом (R-HEX-TEST-1):

// bootstrap/architecture_test.go
func TestInAdapterHasNoOutAdapterImports(t *testing.T) {
    forbidden := "./internal/adapter/out/..."
    pkgs := listPackages(t, "./internal/adapter/in/...")
    for _, pkg := range pkgs {
        for _, imp := range pkg.Imports {
            if strings.HasPrefix(imp, strings.TrimSuffix(forbidden, "...")) {
                t.Errorf("in-adapter %s imports out-adapter %s", pkg.PkgPath, imp)
            }
        }
    }
}

Что запрещено

АнтипаттернПравилоЧто взамен
Бизнес-правило в handler (if req.Amount > 100_000)R-HEX-AIN-X1Метод агрегата order.Confirm() или ConfirmOrderHandler в core/
OrderHandler инжектит persistence.OrderRepositoryR-HEX-AIN-X2Handler зовёт usecase.Handle; репозиторий — в handler'е core/
json.NewEncoder(w).Encode(order) где orderaggregate.OrderR-HEX-AIN-X3Маппер возвращает OrderResponse; domain entity не сериализуется в ответ
adapter/in/http/user/ импортирует adapter/out/sber/R-HEX-AIN-X4Через порт PaymentPort в core/; Sber-адаптер подкладывается в bootstrap/
User- и admin-роутеры в одном пакете adapter/in/http/R-HEX-MOD-X3Разные пакеты http/user/ и http/admin/ с отдельными middleware-цепочками
init() регистрирует chi-роутер в adapter/in/http/R-HEX-BOOT-X2NewRouter(...) возвращает chi.Router; монтируется в bootstrap/main.go

Куда дальше

  • Adapters out — симметричная сторона для исходящих интеграций: sqlc/pgx и внешние HTTP-клиенты.
  • Bootstrap / composition root — где собирается chi.Router, как конструкторы адаптеров собираются в main.go.
  • Core layer — rich aggregate, UseCase Handler, port/out interface.
  • Ports — контракт PaymentPort в core/<bc>/port/out/; port-ошибки.
  • Architecture tests — packages.Load + forbidden-imports как CI-барьер.
  • Module structure — полная раскладка пакетов Go-сервиса.
  • When to use Hexagonal — когда вертикальный slice без адаптеров проще.