Overview
REST in Go starts and often ends with the standard library: net/http is a full
production server, and since 1.22 its ServeMux handles methods and path wildcards, which
removed the last routine reason to import a router. The ecosystem's job is therefore not
to replace the stdlib but to fill the gaps around it — validation, OpenAPI, error shapes —
and the dominant choices there are small libraries that compose with http.Handler rather
than frameworks that own the request lifecycle.
Key points
- Base layer:
net/http+ the 1.22ServeMux(mux.HandleFunc("GET /items/{id}", h)) or chi when you want route groups and middleware mounting — chi is 100%net/http-compatible, so nothing else changes. - JSON:
encoding/jsonv1 does the job but has sharp edges — struct tags drive everything,omitemptycannot distinguish "absent" from "zero", and case-insensitive key matching surprises. Theencoding/json/v2effort (experimental behindGOEXPERIMENT=jsonv2) addresses exactly these. - Validation: go-playground/validator is the de-facto choice — struct tags
(
validate:"required,email,gte=18"), used under the hood by gin's binding. - OpenAPI, spec-first: oapi-codegen generates server interfaces + typed clients from an OpenAPI 3 document for stdlib/chi/echo/gin/fiber targets; the contract stays the source of truth.
- OpenAPI, code-first: swag generates a spec from handler comments (Swagger 2 legacy feel); huma and fuego are the OpenAPI-native newcomers — define handlers with typed request/response structs and the spec falls out correct by construction.
- Errors: no stdlib problem-details type; RFC 7807/9457
application/problem+jsonvia a small lib (e.g. moogar0880/problems) or a hand-rolled struct — the important part is one error shape across the API. - Clients:
http.Clientwith explicit timeouts is idiomatic; resty when you want retries, hooks and fluent request building without writing the plumbing. - Middleware: plain
func(http.Handler) http.Handlerdecorators — otelhttp for traces/metrics, rs/cors for CORS, chi's middleware set for request ID, recovery, timeouts.
Details
JSON in practice
encoding/json marshals by reflection over exported fields; struct tags control names,
omission and string-encoding (json:"id,string"). Pitfalls worth memorising: omitempty
omits zero values, so 0, false and "" vanish — use pointer fields or
json.RawMessage when "not provided" must differ from "zero"; unknown fields are silently
ignored unless you set Decoder.DisallowUnknownFields(); and json.NewDecoder(r.Body)
happily accepts trailing garbage unless you check for a second token. json/v2 fixes the
semantics (omitzero, case-sensitive matching, streaming-first API) and is the direction
of travel — one line worth knowing, not yet the default.
OpenAPI: two workflows
| Approach | Tool | Flow | Fit |
|---|---|---|---|
| Spec-first | oapi-codegen | .yaml → generated server interface + client; you implement the interface |
Teams where the contract is reviewed before code; multi-language consumers |
| Code-first (comments) | swag | Annotated handler comments → swagger.json | Retrofitting docs onto an existing service |
| Code-first (types) | huma, fuego | Typed handler signatures → OpenAPI 3.1 at runtime | Greenfield services wanting the contract for free, validation included |
huma sits on any router (chi, gin, stdlib) and validates requests against the schema it
generates; fuego is similar with a net/http-native slant. Both are young but growing —
the Go answer to Spring's springdoc, without annotations.
Error shape
Adopt RFC 7807 problem details early: {"type", "title", "status", "detail", "instance"}
plus extension fields. Map internal errors to it in one place (a top-level middleware that
recovers panics and converts sentinel/wrapped errors), never in individual handlers —
errors.Is/errors.As against domain error types keeps handler code clean.
Versioning and cross-cutting concerns
Path versioning (/v1/) is the pragmatic default and trivial with route mounting
(mux.Handle("/v1/", http.StripPrefix("/v1", v1mux))). Cross-cutting concerns stack as
middleware in one obvious place; otelhttp wraps both server handlers and client
transports, so traces cross service boundaries with no framework support needed.
Examples
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
item, err := store.Get(r.Context(), id)
if err != nil {
writeProblem(w, http.StatusNotFound, "item not found", id)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
})
srv := &http.Server{
Addr: ":8080",
Handler: otelhttp.NewHandler(mux, "api"),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
Spec-first codegen:
oapi-codegen -generate types,chi-server -package api openapi.yaml > api/gen.go
Related
- Backend protocols in Go — the map — parent map of the protocol landscape.
- net/http — the stdlib base layer and chi, in depth.
- API design — resource modelling and versioning principles this note implements.
- API documentation in Go — the OpenAPI workflows in the wider doc-tooling picture, next to AsyncAPI, proto and SDL.
- REST & HTTP APIs in Java — the JVM contrast: spec-driven (JAX-RS) where Go is stdlib-driven.