1. Why copy-paste in handlers is a “quiet” source of bugs
When you’re just starting to write an HTTP server, a handler looks harmless: a few lines, a couple of checks, w.WriteHeader(...), done. But as soon as you have a second handler, then a third, then “a bit more validation”, then “let’s do JSON everywhere”, you suddenly discover that half the project is repeating blocks: set Content-Type, choose a status, build JSON for the error, don’t forget return, don’t leak err.Error() in a 500, and so on.
The problem isn’t aesthetics (though aesthetics suffer too, and the IDE starts crying). The problem is that copy-paste almost always drifts: in one handler you forgot charset, in another you returned 500 instead of 400, in a third you made the error format “slightly different”, and your API clients start living in a world of surprises.
In Go, historically, people like to solve these things with a simple and honest technique: errors are values. You can program with them, not only print them as strings. This idea is well articulated in the classic text “Errors are values”. And if errors are values, we can move the repeated error handling into one place instead of repeating it everywhere.
The main idea: the handler returns a result and an error
To get rid of duplication, you need to separate responsibilities. The handler should focus on meaning: “read input → validate → compute → return a result”. And the HTTP wrapping should handle the rituals: “if there’s an error, convert it to JSON, set the status, set headers; if it’s a success, return JSON with the result”.
This isn’t new magic and not a “pattern from a trendy framework”. In Go, it’s a basic practice: define your own function type, give it a ServeHTTP method, and thereby plug it into net/http. In an old (but very illustrative) article about error handling in Go, it’s shown directly how a type appHandler helps move repeated error handling out of individual handlers into a single shared ServeHTTP.
We’ll build a similar construct, but instead of “just http.Error as text” we’ll return a single JSON error envelope and follow our contract for ValidationError.Fields.
2. Contract building blocks: ValidationError, HTTPError, error envelope
Before writing the adapter, we need to agree on the “data shape”. Otherwise the shared layer can’t be shared: it will have nothing to standardize.
First: the validation error. It stores errors for specific fields, and its Error() is just a marker “validation error”. This is convenient: we show a normal message to a human at the API level, and to a machine (and a UI client) we return fields.
package main
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation error"
}
Next: the error envelope (a unified shape for error responses). We keep code (machine-readable), message (short and safe), and optionally fields.
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
And finally: an “HTTP-layer error”, which separates public from internal. Inside we keep the real cause (Err) for logs/diagnostics. Outside we keep a controlled Message.
package main
import "net/http"
type HTTPError struct {
Status int
Code string
Message string
Err error
}
func internal(err error) *HTTPError {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "internal",
Message: "internal error",
Err: err,
}
}
It’s important to internalize the philosophy here: the message for the user and the internal cause are different things. If you dump everything into err.Error(), you’ll either leak too much, or you’ll be forced to hide useful diagnostics.
4. The central encode point: writeJSON
Any serious API eventually runs into one simple rule: “responses must look the same”. And that applies not only to errors, but also to successful responses.
If each handler does json.NewEncoder(w).Encode(...) on its own, differences inevitably appear: somewhere Content-Type was forgotten, somewhere the status wasn’t set before writing the body, somewhere you started writing the response and then decided “oops, error”.
We’ll make a tiny writeJSON that becomes the only place where we serialize JSON for success.
package main
import (
"encoding/json"
"net/http"
)
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)
}
Yes, for now we ignore the Encode error. Not because “errors never happen”, but because if the codec breaks out of nowhere, you can’t help the client with “pretty JSON” anymore: headers might have already been sent, part of the body might have gone out. In real services this is logged. We’re intentionally not going deep into logging right now so we don’t try to do “everything at once”.
5. The central error point: writeError and errors.As
Now we build the second central point—for errors. This is exactly where our ValidationError.Fields must turn into fields inside the error envelope.
To safely extract a typed error from Err, we use errors.As. This is the standard Go approach for “find a value of the needed type in the error chain and store it in a variable”, introduced along with the general error unwrapping approach.
package main
import (
"encoding/json"
"errors"
"net/http"
)
func writeError(w http.ResponseWriter, e *HTTPError) {
var ve *ValidationError
fields := map[string]string(nil)
if e.Err != nil && errors.As(e.Err, &ve) {
fields = ve.Fields
}
env := ErrorEnvelope{
Error: APIError{
Code: e.Code,
Message: e.Message,
Fields: fields,
},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(e.Status)
_ = json.NewEncoder(w).Encode(env)
}
Notice the carefulness: fields appears only if this is validation. For not found/internal and other errors, fields will be absent (because of omitempty), and that’s part of a stable contract.
6. The appHandler adapter: one entry point for all handlers
Now we assemble the main dish of the day: an adapter that turns “our convenient function” into an http.Handler.
We’ll agree that a handler returns either a successful Response or a *HTTPError. This gives us a single path: ServeHTTP either calls writeError, or writeJSON.
package main
import "net/http"
type Response struct {
Status int
Body any
}
type appHandler func(r *http.Request) (Response, *HTTPError)
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp, err := h(r)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, resp.Status, resp.Body)
}
And here a little “Go magic” happens (in reality—straightforward mechanics): a function type has a method, so it implements the http.Handler interface, so you can register it in a mux. This exact “function type + ServeHTTP” style is often used to move repeated error handling out of handlers into a single point.
If you’re thinking “wait, you can do that?”, yes—you can. Go is pretty friendly here: no classes, no inheritance; you just take a type and give it a method.
A small flow diagram looks like this:
flowchart LR
A[ServeMux found a route] --> B[appHandler.ServeHTTP]
B --> C[our handler returns Response or *HTTPError]
C -->|success| D[writeJSON]
C -->|error| E[writeError]
7. Mini app: a task API with clean handlers
Now let’s put together a small server that demonstrates the style. We’re not building storage and we’re not doing real CRUD (that’s a separate topic). We’ll just write minimal handlers to see what code looks like without copy-paste.
First, define the model and “stub data”:
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var tasks = []Task{
{ID: 1, Title: "Learn Go", Done: false},
{ID: 2, Title: "Drink tea", Done: true},
}
Now we’ll add a lookup (simple, linear—today isn’t about algorithms):
package main
func findTaskByID(id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
parseID: a single contract without smearing checks everywhere
We use the same parseID contract we already established: string → int, id > 0, errors via ValidationError.Fields.
package main
import "strconv"
func parseID(s string) (int, error) {
if s == "" {
return 0, &ValidationError{Fields: map[string]string{"id": "is required"}}
}
id, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Fields: map[string]string{"id": "must be integer"}}
}
if id <= 0 {
return 0, &ValidationError{Fields: map[string]string{"id": "must be positive"}}
}
return id, nil
}
Handler GET /tasks/{id}: linear code without manually “respond with an error”
Now the handler itself: it doesn’t know how the JSON error response is structured. It only returns *HTTPError with the right fields.
package main
import (
"fmt"
"net/http"
)
func handleGetTask(r *http.Request) (Response, *HTTPError) {
id, err := parseID(r.PathValue("id"))
if err != nil {
return Response{}, &HTTPError{Status: 400, Code: "validation", Message: "invalid request", Err: err}
}
t, ok := findTaskByID(id)
if !ok {
return Response{}, &HTTPError{Status: 404, Code: "not_found", Message: "task not found", Err: fmt.Errorf("task id=%d", id)}
}
return Response{Status: http.StatusOK, Body: t}, nil
}
Notice the nice effect: the handler reads like a normal function. Errors don’t “smear” the logic. It’s like we’re writing “first validate, then do the work”.
And this is very Go-like: we’re not trying to hide errors; we’re trying to avoid duplicating the same reaction to them. This directly continues the idea “errors are values; you can program with them”.
Handler GET /tasks: just return the list
package main
import "net/http"
func handleListTasks(r *http.Request) (Response, *HTTPError) {
return Response{Status: http.StatusOK, Body: tasks}, nil
}
Yep, that’s it. No Content-Type, no json.NewEncoder— all of that will be handled by the shared layer.
main: register routes via method-aware patterns
Finally, we wire everything into http.ServeMux. It’s important to remember that mux.HandleFunc takes a “function with no return values”, while mux.Handle takes an http.Handler. So we register via mux.Handle(..., appHandler(...)).
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.Handle("GET /tasks", appHandler(handleListTasks))
mux.Handle("GET /tasks/{id}", appHandler(handleGetTask))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Now, if a client requests /tasks/abc, it will get 400 and JSON like:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": { "id": "must be integer" }
}
}
And if it requests /tasks/2, it will get 200 and the task JSON.
Where decode lives and why it’s important not to mix layers
The word “decode” in the HTTP context sometimes scares beginners: it sounds like “now we’re going to decode JSON, validate DTOs, map the domain, write middleware...”. In reality, decode is simply “extract input data from the request and convert it to the required types”.
In our minimal example today, decode is two things: r.PathValue("id") (get a string from the path) and parseID (convert the string to an int and validate it). The important point: parseID does not write an HTTP response. It returns an error, and then the handler decides what class of error it is (for us: validation). This helps keep boundaries: parseID is a utility, the handler is HTTP logic, the adapter formats the response.
One more subtle but useful principle: if your handler started writing a successful response (for example, w.WriteHeader(200)), and then “suddenly” realized there’s an error—you’re already too late. That’s exactly why the style “the handler returns Response/HTTPError, and only the adapter writes” disciplines the code: either success or error, and the decision is centralized.
8. Common mistakes when building a shared handler layer
Mistake #1: some handlers write the response directly, and some go through the adapter.
It looks harmless (“I’ll just do Encode quickly here”), but it quickly turns into a mess of formats. At some point the API client will see two different kinds of errors, and you’ll spend half a day searching for “which handler is the one that doesn’t use writeError”. The fix is simple: choose one path and stick to it, at least within one HTTP layer.
Mistake #2: exposing err.Error() to the outside on 500 errors.
The temptation is understandable: “let the client see what broke”. But it’s almost always a bad idea: leaks of internal details, unstable messages, and in the worst case, extra information for an attacker. The right approach is a fixed safe message for 5xx, and keep the internal error inside (HTTPError.Err). In Go, it’s important to consciously decide what exactly you “export” to the outside—both through errors and through HTTP responses.
Mistake #3: trying to cram all validation into the Error() string.
If you make ValidationError.Error() something like "id must be integer", then the UI client will have to parse a string (and parsing strings is always a small theater of absurdity). It’s far more reliable to keep details structured via Fields map[string]string, and leave the Error() string as a marker. Then the adapter can easily extract data via errors.As, exactly as intended by the standard typed-error mechanism.
Mistake #4: writing headers and status in multiple places.
If part of the code sets Content-Type, and part sets WriteHeader, it’s very easy to get a “header already written” situation or simply inconsistent responses. Your code should have an obvious single entry point for serialization. In our version, that’s writeJSON and writeError, called only from ServeHTTP.
Mistake #5: the adapter tries to be too smart.
There’s a dangerous moment: once you see success, you want to add “automatic logging”, “automatic metrics”, “automatic recover”, “automatic body reading”, and now you have a mini-framework that only you understand (and only on holidays). Keep the adapter simple: its job is to standardize responses and remove copy-paste. Everything else should appear only when you have a clear reason and a clear boundary of responsibility.
GO TO FULL VERSION