If your API can return an error in fifty different JSON shapes, your clients handle none of them and everyone ends up parsing a message string with a regex. So pick a small fixed set of shapes, give every error a stable code, and never let an endpoint invent its own. This is the taxonomy I put on every API, and it is the other half of the lathos post from last time.
Three shapes, chosen by class
Every non-success response is one of exactly three shapes. A client that can read these three can handle anything the API returns.
Validation, 400. The request was the wrong shape, or a field was invalid. Errors are keyed by the field that was wrong, because that is what a form needs to put the message next to the right input:
{
"errors": {
"paddleId": [{ "code": "VAL001", "error": "value cannot be empty" }],
"ratingOverall": [{ "code": "VAL002", "error": "must be between 1 and 5" }]
}
}A field can carry more than one error, so each is a list. code is stable and switchable; error is a short line a UI can show as-is.
Application, 401 / 403 / 404 / 409 / 422. A known, handled condition: not found, not yours, already exists, breaks a rule.
{
"id": "a97b9a83-02db-47f7-855c-9a36071b0e46",
"code": "UP001",
"title": "Unprocessable",
"detail": "Your review must be at least 50 characters"
}id is a UUID for this one error instance, so a user can quote it to support and you can grep it in the logs. code is the catalogue code. title is fixed per class, detail is the human line.
Internal, 500. Something you did not handle. The client gets almost nothing:
{ "id": "…", "title": "Internal server error" }Just the id. The real error sits in your logs under the same id, and nothing about your internals reaches the caller.
The codes are constants, and they are frozen
Every code lives in one package as a Go constant, never a bare string at the call site. The prefix is the HTTP status family, so a code tells you the class before you look anything up:
// VAL 400 validation failed
// AUTH 401 not authenticated
// PERM 403 authenticated but not authorised
// NOT 404 resource not found
// CON 409 conflict
// UP 422 valid request, violates a business rule
package errcodes
const (
NotReview = "NOT002" // review not found
PermNotOwner = "PERM002" // caller is not the resource owner
ConReviewExists = "CON001" // user has already reviewed this paddle
)And the rule that makes them worth anything: once a code ships, its meaning is frozen. You never repurpose NOT002 for something else, because somewhere a client is already switching on it. New condition, new code, forever.
Making them constants buys the boring wins. The compiler catches a typo, jump-to-usage finds every place a code is emitted, and the package’s godoc becomes the public catalogue for nothing.
The global handler builds the response
You never assemble these bodies by hand. The service returns a lathos error carrying its code and detail:
return errs.NewErrNotFoundf(errcodes.NotReview, "tried review %q not found", id)
return errs.NewErrNotAuthorised(errcodes.PermNotOwner, "only the author may edit this review")The one global error handler does the rest. It reads the error’s behaviour (the lathos post is all about that switch) to choose the status and which of the three shapes to use, then fills the body straight from the error’s own fields. Three body structs sit behind the three shapes, and that is the whole surface:
type ClientErrorBody struct {
ID string `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Detail string `json:"detail"`
}
type InternalErrorBody struct {
ID string `json:"id"`
Title string `json:"title"`
}
type ValidationErrorBody struct {
Errors map[string][]validator.FieldError `json:"errors"`
}So the global handler is the only thing that turns a lathos error into one of these three, and no endpoint anywhere builds a body of its own. That is the whole job of a taxonomy: the shapes are decided once, the codes are frozen, and every error your API has ever returned or ever will is one of three things your clients already know how to read.