← Back to the section

When an HTTP request or a Kafka message arrives, someone has to receive it, parse it, and pass it into the business logic. That is exactly what an inbound adapter (in-adapter) does: it takes an external signal, translates it into a form the core understands, and passes it on. No business logic — only transformation and routing.

Why you need a separate layer at all

Imagine a handler that at the same time parses JSON, validates fields, queries the database, computes a discount, and builds a response. Such code is hard to test and impossible to reuse: to check the discount calculation, you have to spin up an HTTP server.

Hexagonal architecture solves this through separation: the core contains all the business logic, adapters only translate between formats. The in-adapter is a thin wrapper over HTTP (or Kafka, or gRPC) that knows nothing about how the database is structured or which payment provider is used.

One package per input type

The first rule: each input type lives in its own package.

internal/
  adapter/
    in/
      http/
        user/    # routers for the end user (JWT user-audience)
        admin/   # routers for the administrator (different middleware)
      kafka/     # Kafka consumer as an entry point

Splitting user/ and admin/ is not cosmetic. The authentication middleware for the user and for the administrator are different. If both routers live in one package, accidentally wiring up someone else's middleware chain will not stop the compiler. In separate packages it becomes a compile error: admin/router.go simply cannot see the structs from user/.

Routers are mounted together only at the application assembly point:

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

How a chi handler works

The handler receives a request, converts it into a command, and calls the UseCase. The handler does not get a repository — it works only through the UseCase Handler from the core.

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

The handler does not register routes itself — that is done by bootstrap/. The handler is responsible only for the HTTP logic of a single action.

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

The mapper — a separate struct for translating formats

The handler calls the mapper to convert the request DTO into a command for the UseCase, and back — a domain object into a response DTO. The mapper lives in the same package as the handler but is moved into a separate file.

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

An important rule: the domain object itself (aggregate.Order) is never sent to the client. The reason is simple — a domain object changes over time, while the API contract must stay stable. The mapper builds an OrderResponse with explicit fields, and that is the only thing that goes into JSON.

If the conversion Moneyint64 looks like logic — it is logic, but it belongs to the Money.Kopecks() value object, not to the mapper. The mapper only packs fields.

Errors: how they turn into HTTP statuses

Go has no exceptions. Errors are returned as values, and the in-adapter is the place where an error from the core gets an HTTP status.

The httperr.Write function reads the error type and picks the appropriate response code:

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

The handler does not deal with choosing the HTTP status — it simply passes the error to httperr.Write:

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

This lets you add a new error type in one place without touching every handler.

What the in-adapter knows and what it does not

The in-adapter works with the web stack and the UseCases. It knows nothing about the outbound adapters.

Knows:

  • github.com/go-chi/chi/v5 — routing and middleware.
  • encoding/json — decoding the request and encoding the response.
  • github.com/go-playground/validator/v10 — validating the request DTO.
  • log/slog — structured logging at the edge of the system.
  • The core/<bc>/usecase/ packages — UseCase Handlers and commands.

Does not know:

  • adapter/out/persistence/ — the in-adapter does not import the sqlc repository directly.
  • adapter/out/sber/ — the in-adapter knows nothing about specific payment providers.
  • Other adapter/in/http/admin/ — the user/ package does not import from admin/.

This isolation can be verified with an architecture test:

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

Common mistakes

Business logic in the handler. A check like if req.Amount > 100_000 right in the handler is a business rule that belongs to the core. The handler only parses and validates the structure of the request. The logic goes into the UseCase Handler or an aggregate method.

The repository in the handler. If OrderHandler receives a persistence.OrderRepository in its constructor, the boundary is broken. The handler talks to the UseCase, and the UseCase talks to the repository.

A domain object in the response. json.NewEncoder(w).Encode(order), where order is an aggregate.Order, is a common mistake. A domain object changes together with the business logic; the API contract should change deliberately. The mapper builds an explicit OrderResponse.

The router mounted in the adapter. The handler must not know its own route. Mounting the router is the job of bootstrap/main.go, not of the adapter package.

In short

  • The in-adapter is a thin boundary between the outside world (HTTP, Kafka) and the core: it parses, validates, maps, and passes the command on.
  • Each input type is a separate package (http/user/, http/admin/, kafka/). Mixing them is not allowed.
  • The chain: chi handler → mapper → UseCase Handler. The handler does not get a repository.
  • The mapper is a separate struct: request DTO → command and domain object → response DTO. Sending a domain object directly into JSON is not allowed.
  • Errors from the core are converted into HTTP statuses via httperr.Write — the handler does not assign statuses.
  • The in-adapter imports only the web stack and the core packages. Importing outbound adapters (adapter/out/) is forbidden.

Further reading

  • Adapters out — the symmetric side: sqlc/pgx and outbound HTTP clients.
  • Core layer — rich aggregate, UseCase Handler, port/out interface.
  • Bootstrap / composition root — where the chi.Router is assembled and how all the parts are connected.
  • Ports — the PaymentPort contract in core/<bc>/port/out/.
  • Module structure — the full package layout of a Go service.