When I started building HTTP services in Go, my error handling was a mess — and I didn’t notice until the project got big enough to hurt.
Every handler decided its own HTTP status. One endpoint returned 400 for a missing record; another returned 404 for the same thing. Validation errors came back in the raw shape of the validator library. My repository returned plain errors.New("not found") strings that the handler had to pattern-match. And one memorable afternoon I found a raw Postgres error — table names and all — sitting in a JSON response I’d shipped to the frontend.
The real problem wasn’t any single bug. It was the lack of a consistent answer to one question: when something goes wrong anywhere in the request — validation, the database, a business rule — how does it reliably become the right HTTP status and a clean, predictable JSON body?
This post is the pattern I landed on. The core idea is small: every layer of the app returns one shared error type, and one place turns that type into an HTTP response. Add a new kind of error once, and the whole stack — and the client-facing response — understands it. I’ll use Echo and go-playground/validator, but the idea is framework-agnostic.
Here’s the shape I was reaching for:
HTTP handler ─→ turns an AppError into an HTTP status + JSON body
↑
Service layer ─→ business rules, returns AppError
↑
Repository ─→ storage, returns AppError (NOT_FOUND, CONFLICT, ...)
↑
Validation ─→ input errors, returns AppError (VALIDATION_FAILED)
Every layer returns the same type. The handler at the top does exactly one thing: given any AppError, write the right status and body. It never has to know which layer the error came from. That single constraint is what finally made my error handling consistent.
Where I started (and why it didn’t scale)
Most of us begin here:
func main() {
user := User{Name: "John", Email: "", Age: 150}
if err := validator.New().Struct(user); err != nil {
for _, e := range err.(validator.ValidationErrors) {
fmt.Printf("Field '%s' failed: %s\n", e.Field(), e.Tag())
}
}
}
Fine for a script. But three problems are baked in, and they’re exactly the ones that bit me later:
- The error format is the validator library’s format. Every caller now has to import and understand
validator.ValidationErrors. - There’s no HTTP status anywhere — so that decision gets made, and re-made inconsistently, in every handler.
- The repository and service layers will each invent their own error style, and the handler turns into a pile of
if/switchper call site.
I fixed it from the bottom up.
Step 1: A stable error vocabulary
I started by naming the categories of error my system could produce. Each is a stable string (part of the API contract) paired with the HTTP status it maps to.
package apperr
import "net/http"
// Code is a stable, machine-readable identifier for a category of error.
// The string value is part of your API contract — clients, logs, and tests
// match on it, so never change one once shipped.
type Code string
const (
CodeValidation Code = "VALIDATION_FAILED"
CodeNotFound Code = "NOT_FOUND"
CodeConflict Code = "CONFLICT"
CodeUnauthorized Code = "UNAUTHORIZED"
CodeForbidden Code = "FORBIDDEN"
CodeInternal Code = "INTERNAL"
)
// HTTPStatus maps a Code to its HTTP status. Keeping the mapping next to the
// codes means the handler never hard-codes status numbers — it asks the code.
// Unknown codes default to 500 so a forgotten mapping fails safe.
func (c Code) HTTPStatus() int {
switch c {
case CodeValidation:
return http.StatusBadRequest // 400
case CodeNotFound:
return http.StatusNotFound // 404
case CodeConflict:
return http.StatusConflict // 409
case CodeUnauthorized:
return http.StatusUnauthorized // 401
case CodeForbidden:
return http.StatusForbidden // 403
default:
return http.StatusInternalServerError // 500
}
}
Two choices that pay off everywhere downstream:
Codeis its own type, not a barestring. The compiler now stops me from passing arbitrary strings, and I get a closed, autocompletable vocabulary.- The status lives with the code. This is the move that makes consistency automatic. When the repo returns
CodeNotFoundand validation returnsCodeValidation, the handler maps both to HTTP the same way:code.HTTPStatus(). There is no layer-specific HTTP logic anywhere — which is precisely the inconsistency I was trying to kill.
Adding a new category later (say, rate limiting) is a two-line change in this one file — a constant and a case — and the entire stack understands it.
Step 2: One error type — AppError
This is the shared currency. Every layer returns it; the handler consumes it.
package apperr
// FieldError describes one field that failed validation.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// AppError is the single error type every layer returns.
type AppError struct {
Code Code `json:"code"`
Message string `json:"message"`
Fields []FieldError `json:"fields,omitempty"` // validation fills this; others leave it nil
Err error `json:"-"` // wrapped cause — for LOGS only, never serialized
}
func (e *AppError) Error() string {
return string(e.Code) + ": " + e.Message
}
// Unwrap lets errors.Is / errors.As reach the wrapped cause.
func (e *AppError) Unwrap() error {
return e.Err
}
func New(code Code, message string) *AppError {
return &AppError{Code: code, Message: message}
}
func Wrap(code Code, message string, cause error) *AppError {
return &AppError{Code: code, Message: message, Err: cause}
}
The details that matter:
Error()andUnwrap()make this satisfy Go’serrorinterface and play nicely witherrors.Is/errors.As. Soerrors.Is(err, sql.ErrNoRows)still works even whenerris an*AppErrorwrapping that database error.Err errorwithjson:"-"is the fix for my leaked-Postgres-error embarrassment.Errholds the raw underlying cause — for logs — andjson:"-"guarantees it never reaches a client. The rule I now follow: what you log (Err) is not what you return (Message).Fieldswith,omitemptycarries per-field validation detail. A “not found” error leaves it nil, andomitemptykeeps it out of the JSON entirely.
Step 3: Quarantine the validator
A single request can fail on several fields at once, but it should produce one AppError (one request, one HTTP status) whose Fields slice lists every problem. This translator is the only place in the codebase that knows about validator.ValidationErrors — the library’s types stop right here, which is what fixed problem #1 from the start.
package validation
import (
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"yourmodule/apperr"
)
var validate = validator.New()
func init() {
// Report the JSON field name ("email") instead of the Go field name ("Email").
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
}
// Struct validates s and returns nil, or a single VALIDATION_FAILED AppError
// whose Fields describe every failure.
func Struct(s any) *apperr.AppError {
err := validate.Struct(s)
if err == nil {
return nil
}
var fields []apperr.FieldError
for _, e := range err.(validator.ValidationErrors) {
fields = append(fields, apperr.FieldError{
Field: e.Field(),
Message: messageForError(e),
})
}
return &apperr.AppError{
Code: apperr.CodeValidation,
Message: "validation failed",
Fields: fields,
}
}
// messageForError turns a raw validator tag into a human-readable message.
func messageForError(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "this field is required"
case "email":
return "must be a valid email address"
case "gte":
return "must be " + e.Param() + " or greater"
case "lte":
return "must be " + e.Param() + " or less"
default:
return "is invalid"
}
}
RegisterTagNameFunc makes errors report email (what the client sent) instead of Email (the Go field), and messageForError turns cryptic tags like lte into "must be 130 or less", pulling the rule’s argument from e.Param().
Step 4: One central error handler
Here’s the payoff for consistency. Handlers shouldn’t each remember to render errors correctly — they just return the AppError, and one function renders any of them. Echo is built for this: every handler returns error, and Echo routes that error to a single HTTPErrorHandler.
func errorHandler(err error, c echo.Context) {
if c.Response().Committed {
return
}
var appErr *apperr.AppError
if errors.As(err, &appErr) {
_ = c.JSON(appErr.Code.HTTPStatus(), appErr)
return
}
// Not an AppError — an unexpected failure. Never leak it; return a safe 500.
_ = c.JSON(http.StatusInternalServerError,
apperr.New(apperr.CodeInternal, "something went wrong"))
}
Register it once with e.HTTPErrorHandler = errorHandler. This is where Unwrap() earns its keep — errors.As sees through a wrapped error to find the *AppError inside. Anything that isn’t an AppError (a panic recovery, a stray bug) becomes a generic 500 with a safe message, never the raw error. That single function is the “one consistent place” I was missing for so long.
Step 5: Layers that all speak AppError
With the vocabulary in place, the layers fall out naturally. Each depends only on the one below it, and each returns AppError on failure.
The repository is where NOT_FOUND and CONFLICT are born — note there’s no HTTP here, just the right code:
func (r *memoryRepo) Create(ctx context.Context, u User) error {
if _, exists := r.users[u.Email]; exists {
return apperr.New(apperr.CodeConflict, "email already in use")
}
r.users[u.Email] = u
return nil
}
func (r *memoryRepo) GetByEmail(ctx context.Context, email string) (User, error) {
u, ok := r.users[email]
if !ok {
return User{}, apperr.New(apperr.CodeNotFound, "user not found")
}
return u, nil
}
The handler binds, validates, calls the service, and on any error just returns it:
func (h *Handler) Create(c echo.Context) error {
ctx := c.Request().Context()
var u User
if err := c.Bind(&u); err != nil {
return apperr.New(apperr.CodeValidation, "invalid request body")
}
if appErr := validation.Struct(u); appErr != nil {
return appErr // validation → 400 with field details
}
created, err := h.svc.Register(ctx, u)
if err != nil {
return err // conflict → 409, all rendered by errorHandler
}
return c.JSON(http.StatusCreated, created)
}
And main becomes pure wiring — build the layers bottom-up and inject each into the next:
e := echo.New()
e.HTTPErrorHandler = errorHandler
repo := user.NewMemoryRepo() // concrete storage
svc := user.NewService(repo) // inject repo
h := user.NewHandler(svc) // inject service
h.RegisterRoutes(e)
Because the service depends on a repository interface, swapping the in-memory store for Postgres later is a one-line change (NewMemoryRepo → NewPostgresRepo) — nothing else moves.
Step 6: Passing context.Context through the layers
You may have noticed ctx context.Context threaded through the repository and service signatures above. That wasn’t incidental — once I had clean layers, the next consistency problem was cancellation and timeouts. If a client disconnects, or a request blows past a deadline, every layer down to the database query should know to stop. context.Context is how that signal travels.
The first thing that tripped me up: echo.Context and context.Context are not the same thing.
context.Context (stdlib) |
echo.Context (Echo) |
|
|---|---|---|
| What it is | a cancellation/deadline signal + request-scoped values | a fat object for one HTTP request |
| Holds | a deadline, a cancel signal, some values | the *http.Request, the response writer, path params, bind helpers |
| You use it for | propagating cancel/timeout down the call stack | reading the request and writing the response |
They share a word and nothing else. The bridge between them lives in the handler:
ctx := c.Request().Context()
// ^echo.Context ^*http.Request ^the stdlib context.Context
That one line is where I cross from “web world” to “stdlib world.” Echo manages a context.Context per request and cancels it automatically when the client disconnects or the handler returns. I pull it out with c.Request().Context() and pass it down — to the service, then the repository:
func (s *Service) Register(ctx context.Context, u User) (User, error) {
u.Email = strings.ToLower(u.Email)
return u, s.repo.Create(ctx, u) // pass ctx down
}
The convention is rigid, and following it keeps things consistent: ctx is always the first parameter, you never store it in a struct, and you thread it through every call down the stack.
A question worth sitting with: why does the repository take context.Context and not echo.Context? Because the moment the repo imports Echo, my storage layer depends on a web framework. I could no longer unit-test it without faking an HTTP request, reuse it from a CLI or a background job, or swap Echo out without changes reaching all the way down to the database. context.Context is the neutral, standard-library type every layer already understands — so it’s the right currency to pass inward. HTTP concerns (echo.Context) stay quarantined in the handler, exactly like validator.ValidationErrors stays quarantined in the validation layer. Same principle, twice: don’t let an outer layer’s types leak inward.
The in-memory repo here accepts ctx without using it — a map lookup can’t be cancelled. But the signature is a contract for the whole interface, including the Postgres implementation that will come later. With a real database, ctx stops being decorative:
func (r *pgRepo) GetByEmail(ctx context.Context, email string) (User, error) {
var u User
err := r.db.QueryRowContext(ctx, // ← the driver now watches ctx
`SELECT name, email, age FROM users WHERE email = $1`, email,
).Scan(&u.Name, &u.Email, &u.Age)
if errors.Is(err, sql.ErrNoRows) {
return User{}, apperr.New(apperr.CodeNotFound, "user not found")
}
if err != nil {
return User{}, apperr.Wrap(apperr.CodeInternal, "query failed", err)
}
return u, nil
}
QueryRowContext(ctx, ...) honors the context: if the deadline passes or the client hangs up mid-query, the driver sends a real cancel to the database and the call returns immediately instead of holding a connection for a result nobody will read. And notice the error mapping — a missing row becomes CodeNotFound, an unexpected failure gets Wrap-ed with CodeInternal so the raw error goes to the logs (Err), never the client. The same pattern from Step 2, now at the database boundary.
I get all of that for free — but only because the ctx is threaded all the way down. Skip the plumbing and use context.Background() in the repo instead, and a cancelled request keeps burning database resources for a client who already left.
The proof
Three failure paths, born in three different layers, all rendered identically by one handler:
| Request | Status | Where the code came from |
|---|---|---|
| valid user | 201 | handler success |
| bad email + age | 400 | validation layer, with per-field details |
| duplicate email | 409 | repository (CodeConflict) |
| missing user | 404 | repository (CodeNotFound) |
A validation failure comes back clean and actionable:
{
"code": "VALIDATION_FAILED",
"message": "validation failed",
"fields": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "age", "message": "must be 130 or less" }
]
}
…and a conflict from the repository renders through the exact same path, no per-route code:
{ "code": "CONFLICT", "message": "email already in use" }
Wrapping up
The whole thing rests on two ideas that, together, finally gave me consistent error reporting across a project:
- One error type (
AppError) flowing through every layer — a stableCode, a safeMessage, optional field details, and a never-serialized wrapped cause. - One place that turns that type into HTTP, by asking the code for its own status.
context.Context, threaded the same way the errors flow back, extends that consistency to cancellation and timeouts. Adding a new error category is a two-line change. Adding a new endpoint needs zero new error-rendering code. A raw database error can’t leak to a client. And a request that’s been abandoned doesn’t keep working in the dark.
The piece I left out is testing — asserting on an error’s Code with errors.As rather than matching on message strings. That’s a short follow-up, and the error model built here is exactly what makes those tests clean.