DEV Community

Cover image for Mapping Go Domain Errors to HTTP Status Codes at the Boundary
Gabriel Anhaia
Gabriel Anhaia

Posted on

Mapping Go Domain Errors to HTTP Status Codes at the Boundary


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")
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)