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 Money → int64 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, ¬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")))
}
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/— theuser/package does not import fromadmin/.
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.