package api
import (
"encoding/json"
"net/http"
)
type Validator interface {
Valid() map[string]string
}
type ValidationError struct {
Problems map[string]string `json:"errors"`
}
func (e *ValidationError) Error() string {
return "request body failed validation"
}
func WriteJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
package api
import (
"strings"
"unicode/utf8"
)
type SignupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Age int `json:"age"`
}
func (r SignupRequest) Valid() map[string]string {
problems := map[string]string{}
if !strings.Contains(r.Email, "@") {
problems["email"] = "must be a valid email address"
}
if utf8.RuneCountInString(r.Password) < 8 {
problems["password"] = "must be at least 8 characters"
}
if r.Age < 13 {
problems["age"] = "must be 13 or older"
}
return problems
}
package api
import (
"encoding/json"
"fmt"
"net/http"
)
func DecodeValid[T Validator](r *http.Request) (T, map[string]string, error) {
var v T
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, nil, fmt.Errorf("decode json: %w", err)
}
if problems := v.Valid(); len(problems) > 0 {
return v, problems, &ValidationError{Problems: problems}
}
return v, nil, nil
}
func SignupHandler(w http.ResponseWriter, r *http.Request) {
body, problems, err := DecodeValid[SignupRequest](r)
if err != nil {
if problems != nil {
WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": problems})
return
}
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request body"})
return
}
WriteJSON(w, http.StatusCreated, map[string]string{"email": body.Email})
}
This snippet shows how a Go HTTP API can decode a JSON body, validate it against declared rules, and return a structured 422 response that names each offending field. The pattern separates three concerns across validate.go, signup.go, and decode.go: a reusable validation contract, a domain request type that implements it, and a generic decode-and-validate helper.
In validate.go, the Validator interface declares a single Valid method that returns a map[string]string keyed by field name. Returning a map rather than a flat list makes the failures addressable — a front end can attach problems["email"] directly to the email input. The ValidationError type wraps that map and implements error, and WriteJSON centralizes the response encoding so every handler emits the same shape. Using http.StatusUnprocessableEntity (422) rather than 400 signals that the JSON parsed fine but failed business rules, which is the semantically correct distinction.
In signup.go, SignupRequest carries json tags for decoding and implements Valid by accumulating problems into a map. Each rule appends only when it fails, so a single request surfaces every error at once instead of forcing the client through a slow correct-one-resubmit loop. The checks are deliberately ordinary Go — strings.Contains, len, utf8.RuneCountInString — because validation logic that reads like plain conditionals is easier to audit than an opaque tag DSL, and it keeps the rules in the same package as the type they guard.
In decode.go, DecodeValid is a generic function constrained to Validator. It uses DisallowUnknownFields so a typo like emial becomes a hard decode error rather than a silently ignored field, distinguishes malformed JSON from rule violations, and returns both the value and the problems map. SignupHandler shows the payoff: one call yields either a ready-to-use struct or a populated error map, which it hands to WriteJSON. The main trade-off is that hand-written Valid methods are more verbose than annotation libraries, but they carry no reflection cost, no hidden dependency, and no magic — the failure semantics are exactly what the code says. This approach fits services that want precise, client-friendly error payloads without pulling in a validation framework.
Share this code
Here's the card — post it anywhere.