Day 55 — validation & API errors

Updated

July 30, 2026

Day 55 — validation & API errors

Stage VI · ~3h
Goal: Centralize input validation and emit consistent problem/error JSON—field-level details, stable error codes, and clean mapping from domain errors to HTTP.

Why this day exists

APIs rot when every handler invents its own error shape:

{"err":"bad"}
{"message":"Name missing"}
{"error":"not found"}

Clients need predictability. Operators need codes. Security needs to avoid leaking internals. Today turns Day 53/54 handlers into a single error language.


Theory 1 — Error envelope

Adopt one envelope for the service:

{
  "error": {
    "code": "validation_failed",
    "message": "request validation failed",
    "details": [
      {"field": "name", "issue": "required"},
      {"field": "email", "issue": "invalid_format"}
    ]
  }
}
Field Purpose
code Stable machine token (snake_case)
message Human summary
details Optional field issues

Optional RFC 9457 Problem Details (application/problem+json)—same idea, standard fields (type, title, status, detail, instance). Lab may use either; document choice.

{
  "type": "about:blank",
  "title": "validation_failed",
  "status": 400,
  "detail": "request validation failed",
  "errors": [{"field":"name","issue":"required"}]
}

Theory 2 — Validation layers

  1. Transport: JSON syntax, Content-Type, body size
  2. Schema: required fields, types, ranges, formats
  3. Domain: unique email, inventory rules, authz

Do not conflate layers—malformed JSON is 400; “item not found” is 404; “not owner” is 403.

Small validator without frameworks

type FieldError struct {
    Field string `json:"field"`
    Issue string `json:"issue"`
}

type ValidationError struct {
    Fields []FieldError
}

func (e *ValidationError) Error() string { return "validation failed" }

func (e *ValidationError) Add(field, issue string) {
    e.Fields = append(e.Fields, FieldError{Field: field, Issue: issue})
}

func (e *ValidationError) Err() error {
    if len(e.Fields) == 0 {
        return nil
    }
    return e
}
func validateCreateItem(name string) error {
    var v ValidationError
    name = strings.TrimSpace(name)
    if name == "" {
        v.Add("name", "required")
    } else if len(name) > 100 {
        v.Add("name", "too_long")
    }
    return v.Err()
}

Accumulate all field errors when cheap—clients fix multiple issues in one round-trip.


Theory 3 — Mapping domain → HTTP

func writeDomainErr(w http.ResponseWriter, log *slog.Logger, err error) {
    var ve *ValidationError
    switch {
    case errors.As(err, &ve):
        writeError(w, http.StatusBadRequest, "validation_failed", "request validation failed", ve.Fields)
    case errors.Is(err, store.ErrNotFound):
        writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)
    case errors.Is(err, store.ErrConflict):
        writeError(w, http.StatusConflict, "conflict", "resource already exists", nil)
    case errors.Is(err, ErrUnauthorized):
        writeError(w, http.StatusUnauthorized, "unauthorized", "login required", nil)
    case errors.Is(err, ErrForbidden):
        writeError(w, http.StatusForbidden, "forbidden", "forbidden", nil)
    default:
        log.Error("handler", "err", err)
        writeError(w, http.StatusInternalServerError, "internal", "internal error", nil)
    }
}

Never return raw err.Error() for unknown 500s to clients. Log the real error server-side with request id.


Theory 4 — Content negotiation lite

  • Require Content-Type: application/json on bodies that need JSON
  • Respond 415 if wrong type
  • Always set response Content-Type to your error/success type
  • Limit body size before decode
func readJSON(r *http.Request, dst any) error {
    ct := r.Header.Get("Content-Type")
    if ct != "" && !strings.HasPrefix(ct, "application/json") {
        return errUnsupportedMedia
    }
    defer r.Body.Close()
    dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("%w: %v", errBadJSON, err)
    }
    return nil
}

Theory 5 — Idempotent validation messages

Stable issue tokens (required, too_long, invalid_format) beat free-form sentences for i18n and client logic. Message field can be English for humans.

Regexp from Day 48 for email/slug validation—compile once at package init:

var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)

func validateEmail(s string) error {
    var v ValidationError
    s = strings.TrimSpace(s)
    if s == "" {
        v.Add("email", "required")
    } else if !emailRe.MatchString(s) {
        v.Add("email", "invalid_format")
    }
    return v.Err()
}

Theory 6 — Sentinel errors and wrapping

var (
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden    = errors.New("forbidden")
)

// store package
var ErrNotFound = errors.New("not found")
var ErrConflict  = errors.New("conflict")

Handlers and services return wrapped sentinels (fmt.Errorf("get item: %w", store.ErrNotFound)). Mapping uses errors.Is / errors.As only—string matching error text is fragile.


Worked example — writeError helper

type errorBody struct {
    Error errorPayload `json:"error"`
}

type errorPayload struct {
    Code    string       `json:"code"`
    Message string       `json:"message"`
    Details []FieldError `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, code, msg string, details []FieldError) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{
        Code: code, Message: msg, Details: details,
    }})
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

Lab 1 — Integrate into service

mkdir -p ~/lab/90daysofx/01-go/day55
cd ~/lab/90daysofx/01-go/day55
go mod init example.com/day55

Upgrade Day 53/54 handlers to use central writeDomainErr. Delete ad-hoc http.Error and one-off JSON maps for failures.


Lab 2 — Validation matrix tests

Case Expect
Missing name 400 validation_failed + field detail
Name too long 400
Bad email on register 400
Unknown JSON field 400 (if DisallowUnknownFields)
Not found 404 not_found no details
No auth 401
Duplicate email 409

Assert JSON paths with encoding/json into structs—not only substrings.

var body struct {
    Error struct {
        Code    string `json:"code"`
        Details []struct {
            Field string `json:"field"`
            Issue string `json:"issue"`
        } `json:"details"`
    } `json:"error"`
}

Lab 3 — Internal error hygiene

Force a repo error (inject fake repo in test) → client body must not contain SQL text; server logs must contain the real error (slog with "err" key).


Lab 4 — Optional problem+json

Implement writeProblem with Content-Type: application/problem+json for one endpoint; compare DX to your envelope. Pick a standard for the rest of Stage VI and document it in README.


Lab 5 — OpenAPI lite note

Write a short markdown table of error codes used by the service—proto-catalog for clients:

code HTTP meaning
validation_failed 400 field details present
unauthorized 401 missing/invalid credentials
forbidden 403 authenticated but not allowed
not_found 404 resource missing
conflict 409 unique constraint / duplicate
internal 500 unexpected

Common gotchas

Gotcha Fix
Different shapes per handler One helper
Leaking internals on 500 Generic message + slog
Validation only client-side Server always validates
Stringly field paths Nested field like address.zip
Using 500 for bad input 400/422
Ignoring multiple field errors Accumulate in ValidationError
Matching error strings errors.Is / As
Forgetting body limit io.LimitReader

Checkpoint

  • Single error envelope everywhere
  • Field-level validation details
  • Domain mapping switch complete
  • Tests cover 400/401/404/409
  • 500 does not leak internals
  • Error code catalog documented

Commit

git add .
git commit -m "day55: validation and consistent API error envelope"

Write three personal gotchas before continuing.


Tomorrow

Day 56 — elective branch A: either a gRPC unary intro or deepen REST (OpenAPI notes, ETags, bulk endpoints)—choose deliberately.