← Back to the blog

Validation and error handling in Go APIs

Inconsistent errors are a support ticket waiting to happen. Here's the validation and error-handling shape we standardise across every Go API we ship.

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.

Quick answerDefine one error type for your domain. Map it to HTTP status codes in a single place — the handler layer. Every error response has the same JSON shape: 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

Subscribe

More of this, once a month.