- Book: Hexagonal Architecture in Go
- Also by me: The Complete Guide to Go Programming — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go service you inherited and search for http.StatusNotFound. There are forty hits. Nine of them live in the domain package, next to the business rules. A UserService.Activate method returns fmt.Errorf("user not found: %w", err) and then, three lines later, decides the response should be 404.
Now the payments team wants to call that same method from a gRPC handler. There is no HTTP response there. The status code you welded into the domain has nowhere to go, and you either duplicate the rule or start passing an *http.ResponseWriter into a function that computes late fees.
The status code belongs at the edge. The domain should say what went wrong in its own words. One function at the HTTP adapter turns that into what the client sees. This post is the Go plumbing for that split.
The domain speaks in typed errors, not codes
Start by giving the domain a vocabulary. Not strings, not codes — types. The two shapes that cover almost everything are a sentinel error for a fixed condition and a struct error for a condition that carries data.
// domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
Those sentinels are for conditions with no payload. When the error needs to carry a field name or a reason, reach for a struct instead. A validation failure is the classic case.
// domain/errors.go
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
The domain code returns these and nothing about transport. A user service that activates an account looks like this:
// domain/user.go
package domain
import "fmt"
func (s *UserService) Activate(id string) error {
u, err := s.repo.ByID(id)
if err != nil {
return fmt.Errorf("load user %s: %w", id, err)
}
if u == nil {
return fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if u.Active {
return fmt.Errorf("user %s: %w", id, ErrConflict)
}
if u.Email == "" {
return &ValidationError{
Field: "email",
Reason: "required before activation",
}
}
u.Active = true
return s.repo.Save(u)
}
Every return wraps the sentinel with %w so the cause is still reachable, but the message stays human. There is not a single http import in this file. There never will be.
One translation function at the HTTP adapter
The whole mapping lives in one place: the adapter that owns the net/http dependency. It takes a domain error and returns a status code plus a client-safe message. errors.Is matches sentinels, errors.As unwraps struct errors and hands you the fields.
// adapter/http/errmap.go
package http
import (
"errors"
"net/http"
"yourapp/domain"
)
func statusFor(err error) (int, string) {
switch {
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound, "resource not found"
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict, "already exists"
case errors.Is(err, domain.ErrUnauthorized):
return http.StatusUnauthorized, "unauthorized"
case errors.Is(err, domain.ErrForbidden):
return http.StatusForbidden, "forbidden"
}
var ve *domain.ValidationError
if errors.As(err, &ve) {
return http.StatusUnprocessableEntity, ve.Error()
}
return http.StatusInternalServerError, "internal error"
}
Read the fallthrough at the bottom carefully. Anything the function does not recognize becomes a 500 with a generic message. That default is the safety net: a repository error, a nil-pointer panic recovered upstream, a driver timeout — none of them leak their internals to the client, and none of them accidentally map to a 400 that tells an attacker their input was almost right.
Wire it into the handler once
The handler calls the service, and on any error it defers to statusFor. No handler ever writes a status code by reasoning about the failure itself.
// adapter/http/user_handler.go
package http
import (
"encoding/json"
"net/http"
)
type errorBody struct {
Error string `json:"error"`
}
func writeErr(w http.ResponseWriter, err error) {
code, msg := statusFor(err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(errorBody{Error: msg})
}
func (h *UserHandler) Activate(
w http.ResponseWriter, r *http.Request,
) {
id := r.PathValue("id")
if err := h.svc.Activate(id); err != nil {
writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
r.PathValue is the routing helper that landed in the standard library net/http mux in Go 1.22, so this needs no third-party router. The handler is four lines of logic. Every error path routes through writeErr, which routes through statusFor. There is exactly one place to change if the mapping ever changes.
Log the cause, return the mask
The client gets "internal error". You still want the real cause in your logs. Split the two: the wrapped chain goes to the logger, the masked message goes on the wire. This version takes the request so it can log context, so make it the canonical writeErr and update the handler call to writeErr(w, r, err).
// adapter/http/user_handler.go
package http
import (
"encoding/json"
"log/slog"
"net/http"
)
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
code, msg := statusFor(err)
if code >= 500 {
slog.ErrorContext(r.Context(), "request failed",
"err", err,
"path", r.URL.Path,
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(errorBody{Error: msg})
}
Because the domain wrapped every error with %w, slog prints the full chain: activate user 42: load user 42: sql: no rows. The client saw 404 and one clean line. You get the stack of context; they get the mask. That is the payoff for wrapping instead of replacing the error at every layer.
Test the mapping in isolation
The mapping is a pure function from error to (int, string). That is the easiest thing in the world to table-test, and it means you never boot an HTTP server to prove a 409 is a 409.
// adapter/http/errmap_test.go
package http
import (
"errors"
"fmt"
"net/http"
"testing"
"yourapp/domain"
)
func TestStatusFor(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"not found",
fmt.Errorf("x: %w", domain.ErrNotFound),
http.StatusNotFound},
{"conflict",
fmt.Errorf("x: %w", domain.ErrConflict),
http.StatusConflict},
{"validation",
&domain.ValidationError{Field: "email"},
http.StatusUnprocessableEntity},
{"unknown",
errors.New("boom"),
http.StatusInternalServerError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, _ := statusFor(c.err)
if got != c.want {
t.Errorf("got %d, want %d", got, c.want)
}
})
}
}
Notice the not found case wraps the sentinel with %w first. That is deliberate: it proves errors.Is walks the chain, not just the top-level error. If someone later returns the bare sentinel or wraps it twice, this test still passes, because errors.Is does the walking for you.
Why the boundary is the only honest place for this
The reason this split holds up is that a status code is a fact about a protocol, not about your business. ErrConflict means the same thing whether the caller is HTTP, gRPC, a CLI, or a background job. 409 only means something to HTTP.
Put the mapping in the domain and you have coupled a payments rule to a web framework. Add a second transport and you copy the switch, or you thread a response writer where it does not belong. Keep the mapping at the adapter and the domain stays transport-agnostic: a gRPC adapter writes its own statusFor that returns codes.NotFound from the exact same domain.ErrNotFound, and the business code never learns that gRPC exists.
// adapter/grpc/errmap.go
func codeFor(err error) codes.Code {
switch {
case errors.Is(err, domain.ErrNotFound):
return codes.NotFound
case errors.Is(err, domain.ErrConflict):
return codes.AlreadyExists
}
return codes.Internal
}
Same sentinels, different edge. That is the shape you want: the domain owns the vocabulary, each adapter owns its own dictionary from that vocabulary to its protocol. One translation function per boundary, and not one line about protocols anywhere the business rules live.
The rule to keep: if you can grep http.Status and get a hit outside your HTTP adapter package, the boundary has a leak. Fix it before it spreads to forty hits.
If this was useful
Typed errors, errors.Is, errors.As, and %w wrapping are stdlib mechanics worth knowing cold — The Complete Guide to Go Programming covers the error interface, wrapping, and how slog reads a chain. Keeping the translation function pinned to the adapter, so the domain never imports a transport, is the boundary discipline that Hexagonal Architecture in Go is built around: ports, adapters, and the direction dependencies are allowed to point.

Top comments (0)