Inconsistent errors are the most common API complaint. A 500 here, a string message there, a validation error in a different shape from the auth error. Every variation means a client-side special case. This post shows the validation and error-handling structure we standardise across every Go API we ship.
code, message, details. Never leak internal errors to the client.The error response shape
Pick one shape and use it everywhere — validation errors, auth errors, not-found errors, rate limit errors, server errors. Clients write switch on code; make code stable and snake_case.
{
"code": "validation_error",
"message": "Request validation failed.",
"details": {
"email": "must be a valid email address",
"amount": "must be greater than 0"
}
}Domain errors vs infrastructure errors
Define sentinel errors in your domain package. Map them to HTTP status codes in the handler layer — not in the service, not in the store.
// domain errors — known, named, client-visible
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrEmailTaken = errors.New("email already in use")
ErrInvalidInput = errors.New("invalid input")
)
// handler layer — map domain errors to HTTP
func httpStatus(err error) int {
switch {
case errors.Is(err, domain.ErrNotFound): return http.StatusNotFound
case errors.Is(err, domain.ErrUnauthorized): return http.StatusUnauthorized
case errors.Is(err, domain.ErrEmailTaken): return http.StatusConflict
case errors.Is(err, domain.ErrInvalidInput): return http.StatusBadRequest
default: return http.StatusInternalServerError
}
}Request validation
Validate at the handler boundary before the request reaches the service. Keep validation rules close to the types they validate. We use github.com/go-playground/validator/v10 for struct validation and return field-level details:
type CreateInvoiceRequest struct {
CustomerID string `json:"customer_id" validate:"required,uuid"`
Amount float64 `json:"amount" validate:"required,gt=0"`
Currency string `json:"currency" validate:"required,len=3"`
}
func validateRequest(v *validator.Validate, req any) map[string]string {
errs := v.Struct(req)
if errs == nil {
return nil
}
details := make(map[string]string)
for _, e := range errs.(validator.ValidationErrors) {
details[strings.ToLower(e.Field())] = humanise(e.Tag())
}
return details
}The handler pattern
Every handler follows the same shape: decode, validate, call service, write response. Errors flow through a single writeError helper.
func (h *Handler) CreateInvoice(w http.ResponseWriter, r *http.Request) {
var req CreateInvoiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON.", nil)
return
}
if details := validateRequest(h.validate, req); details != nil {
writeError(w, http.StatusBadRequest, "validation_error", "Validation failed.", details)
return
}
inv, err := h.svc.CreateInvoice(r.Context(), req.CustomerID, req.Amount, req.Currency)
if err != nil {
writeError(w, httpStatus(err), errorCode(err), err.Error(), nil)
return
}
writeJSON(w, http.StatusCreated, inv)
}Problem JSON (RFC 7807)
If you're building a public API, consider RFC 7807 — the standard "Problem Details for HTTP APIs" format. It adds type (a URI), title, status, and detailto the error shape. Library clients and API gateways know how to parse it. The shape above is compatible with RFC 7807 with minor additions.
See the API design post for the broader contract decisions that surround error handling.
End of article · Thanks for reading