Опирается на правила:
R-HEX-AIN-1…R-HEX-AIN-4иR-HEX-AIN-X1…R-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(),
}
}
Маппер не несёт бизнес-логики. Если нужна конвертация Money → int64 — это вопрос к 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.OrderRepository | R-HEX-AIN-X2 | Handler зовёт usecase.Handle; репозиторий — в handler'е core/ |
json.NewEncoder(w).Encode(order) где order — aggregate.Order | R-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-X2 | NewRouter(...) возвращает 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 без адаптеров проще.