1. Why response helpers are needed
When you write your first HTTP server, it feels like responding is simple: “well, I can just do json.NewEncoder(w).Encode(v) and that’s it.” And yes, it works… right up until you run into real errors and real people trying to use your API. An API has a contract: headers, status codes, error format, response stability, and predictability for the client.
Imagine that your client (CLI, frontend, another service) can handle errors only if they come in this format:
{ "error": { "code": "...", "message": "...", "fields": { ... } } }
And then you accidentally used http.Error(w, err.Error(), 500) in one handler. The client suddenly gets plain text instead of JSON, the parser crashes, and the user has a bad time. And the worst part is that it’s not “one mistake”—it’s a lack of discipline: every handler writes “whatever is convenient”.
There is an even more subtle technical problem: you can’t “rewind” an HTTP response. As soon as you start writing the body (or set the status), you can’t suddenly decide “oh, it’s actually an error” and send different JSON. That’s why helpers matter not only for cleanliness, but also to reduce the chance of a partially written, corrupted response.
A small diagram of where we’re headed:
flowchart TD
R[HTTP request] --> M[ServeMux selects a handler]
M --> H[Our handler]
H -->|success| WJ[writeJSON]
H -->|error| EH[return HTTPError]
EH --> A[appHandler.ServeHTTP adapter]
A --> WE[writeError -> writeJSON]
WJ --> RESP[HTTP response]
WE --> RESP
2. One JSON response and one error format
writeJSON: a single way to write a JSON response
When you introduce writeJSON, it’s like you’re telling yourself: “From now on, this project has a proper, single way to respond with JSON.” It’s a bit like a team agreement: we don’t argue every time whether we should set Content-Type, whether we should add \n, whether we should encode into a buffer. We just call one function—and everything is consistent.
The core responsibilities of writeJSON are simple: set the correct Content-Type, set the status code, serialize the value to JSON, and write it to the ResponseWriter. The nuance starts when serialization can fail: if you already started writing to w, and then Encode breaks, you can’t nicely send an error to the client because the headers are already gone. So a good “teaching + practical” technique is to encode into a bytes.Buffer first, and only then write to w.
Here is a minimal version of writeJSON:
package main
import (
"bytes"
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
return nil
}
Notice two “boring” details that save your nerves in practice. First, the Content-Type header is set before WriteHeader, because once you write the status, it’s too late to change headers. Second, we return an error, because JSON encoding is an operation that can fail (for example, if v contains a value that isn’t supported).
Now let’s see what a handler looks like with this helper. Important: we don’t mix “building a response” with “manual header work”—let writeJSON do it.
package main
import "net/http"
func handleHealth(w http.ResponseWriter, r *http.Request) *HTTPError {
resp := map[string]any{"ok": true}
if err := writeJSON(w, http.StatusOK, resp); err != nil {
return internalError(err)
}
return nil
}
Yes, for now we reference HTTPError and internalError, which we haven’t defined yet. That’s fine: we’re assembling the system like a construction set, piece by piece. The key idea right now is: a successful JSON response always goes through writeJSON.
writeError: one API error format
Errors in an API aren’t “oops, something went wrong”—they’re a separate product. The client must be able to tell a validation error from an internal error, must understand which field is wrong, and must have a machine-readable error code to write conditional logic like if code == "validation" { ... }. That’s exactly why we lock down a single format—an error envelope.
It’s convenient to describe the envelope structures as DTOs:
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"`
}
The fields field is marked omitempty: if there are no per-field errors, it shouldn’t “hang around” as an empty object—small detail, but details like this make an API nicer.
Now we write writeError. The idea is simple: writeError doesn’t do HTTP magic directly—it calls writeJSON, passing the envelope.
package main
import "net/http"
func writeError(w http.ResponseWriter, status int, code, msg string, fields map[string]string) {
_ = writeJSON(w, status, ErrorEnvelope{
Error: APIError{
Code: code,
Message: msg,
Fields: fields,
},
})
}
We intentionally have _ = writeJSON(...) here: once we already decided “we’re writing an error”, a possible serialization failure is a truly exceptional situation, and we won’t be able to “respond with an error about the error” a second time. Later you can add logging for this case (carefully, without overcomplicating things).
Now a handler can return a structured validation error as a value (and a separate layer will write the response):
package main
import "net/http"
func badRequest(fields map[string]string) *HTTPError {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "validation",
Message: "invalid request",
Fields: fields,
}
}
And from there—consistently: any 400-class error will be returned as an envelope. The client is happy: it always knows where to find error.code, error.message, and error.fields.
Safety rule: don’t return err.Error() for 500
It’s very tempting (especially in tutorial examples) to do this:
http.Error(w, err.Error(), 500)
It looks honest: “well, here’s the error, why hide it?” But on a real server this is almost always a design mistake.
Why? Because err.Error() often contains internal details: table names, file paths, server addresses, JSON fragments, internal identifiers, and sometimes even pieces of secrets (if someone built an error carelessly). Plus it’s unstable: you change a library—the error text changes, and client tests/scripts start failing.
There’s an important practical idea in Go: when you add context to an error or decide whether to “wrap” an error, you’re making an API decision about what details you’re willing to expose to the caller. This is often phrased like this: “wrap makes the underlying error available for inspection by the caller; don’t wrap if that would expose implementation details”.
For an HTTP API, the idea is even stricter: the client almost never needs the “raw internal cause”. The client needs a clear outcome: "internal error", while the details belong in your logs.
That leads to the lecture rule: for 500-class errors in a JSON response, always use a fixed message, for example "internal error". Keep the real err internally (in the error struct), and log it when needed.
HTTPError: what we return to the client vs. what we keep for ourselves
To make the rule “don’t expose err.Error() externally” automatic, we need an error type that stores both public fields (for the response) and an internal cause (for logs/diagnostics). That will be HTTPError.
package main
type HTTPError struct {
Status int
Code string
Message string
Fields map[string]string
Err error // internal cause (DO NOT return to the client)
}
It looks almost too simple. But that’s exactly how it should be: an error structure should be boring, like accounting. We’ll save creativity for variable names.
Now let’s make a constructor for internal errors. It locks down the contract: status 500, code "internal", message "internal error", and the internal cause is stored in Err.
package main
import "net/http"
func internalError(err error) *HTTPError {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "internal",
Message: "internal error",
Err: err,
}
}
3. Centralizing error handling: appHandler
If you keep writing handlers “straight up”, almost every one will repeat the same thing: input validation, if err != nil, building an error envelope, return. It’s not the end of the world, but your brain gets tired of identical branches, and at some point you’ll accidentally return an error in the wrong format in one handler.
A classic Go idea is to make an adapter: the handler returns an error (as a value), and the adapter implements ServeHTTP and centralizes the reaction to an error. In our version, the handler returns *HTTPError (because it is specifically an “HTTP-layer error”, with status/code/fields), and the adapter writes the response through writeError.
package main
import (
"log"
"net/http"
)
type appHandler func(http.ResponseWriter, *http.Request) *HTTPError
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := h(w, r); e != nil {
if e.Err != nil {
log.Printf("internal error: %v", e.Err) // details stay with us
}
writeError(w, e.Status, e.Code, e.Message, e.Fields)
}
}
Notice a neat thing: appHandler is a function type, and it has a method ServeHTTP. Yes, a function can have a method. Yes, Go can do that sometimes—and it’s one of those moments when the language seems to say: “I’m not forbidding you from being convenient; I’m just asking you not to be weird”.
4. Assembling a mini API: routes and consistent responses
Now let’s assemble a mini server where it’s clear that all responses go through one path. We’re not building a full CRUD and we’re not going into complex architecture—we want to reinforce exactly “responses and errors”.
Wiring the mux and starting the server:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.Handle("/health", appHandler(handleHealth))
mux.Handle("/ping", appHandler(handlePing))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
We’ve already seen /health: it responds with JSON { "ok": true }. Let’s build /ping to show both a successful response and method validation errors.
package main
import "net/http"
func handlePing(w http.ResponseWriter, r *http.Request) *HTTPError {
if r.Method != http.MethodGet {
return badRequest(map[string]string{
"method": "must be GET",
})
}
if err := writeJSON(w, http.StatusOK, map[string]string{"pong": "pong"}); err != nil {
return internalError(err)
}
return nil
}
Notice how the handler reads: first a guard clause (method check), then the success path, then return nil. No mixing of WriteHeader, json.Encode, http.Error, and random strings. That’s the goal: make the handler “linear”.
5. Nuance: writeJSON can also return an error
There’s a point that often surprises beginners: “what can possibly break when encoding the response?”. It feels like if you build the struct yourself, it will always encode. But in Go you can accidentally put into any something that JSON can’t encode (for example, a channel, a function, a cyclic structure), and then Encode will return an error.
That’s why writeJSON returns an error, and in the handler we do:
if err := writeJSON(...); err != nil {
return internalError(err)
}
It looks like an extra check, but it’s worth it: you avoid the situation where “half the response got written and then everything crashed”. Also, even if an encoding error does happen, you still don’t expose err.Error() externally, you return the stable message "internal error".
From an error-design perspective, this matches the general Go idea: errors are values; you can enrich them with context, but exposing internals externally must be a conscious choice.
6. Common mistakes with writeJSON/writeError and appHandler
Mistake #1: write part of the response, then try to “return an error”.
Usually it looks like this: you first do w.WriteHeader(200), then start writing JSON, then something fails—and you try to call writeError. But HTTP is not video editing: you can’t “rewind”. That’s why we encode into a buffer before writing headers, and in handlers we keep the rule: either we respond successfully, or we return *HTTPError before writing the body.
Mistake #2: return err.Error() to the client for the 500 class.
This is a classic, and it often appears “temporarily, for debugging”, and then the temporary becomes permanent. The correct approach: the public message is fixed ("internal error"), the internal cause is stored in HTTPError.Err and goes to the logs. This is not paranoia—it’s about contract and safety, because implementation details must not become part of the external API.
Mistake #3: mix error formats.
If one handler replies with a JSON error and another uses http.Error, the client has to write two parsers or guess based on Content-Type. Such an API feels “alive” only to the server developer; to the client it feels unpredictable. The fix is simple: all errors go through writeError, and writeError always writes an envelope.
Mistake #4: always include fields as an empty object.
If fields always appears, the client starts thinking it always has meaning and writes unnecessary logic. It’s better to keep the contract clean: fields exists only when there are actual per-field errors, so omitempty and nil are your friends.
Mistake #5: try to “log everything” inside every handler.
Beginners often add log.Println to every handler, and after a week the logs turn into noise. It’s much cleaner when you have one place (the ServeHTTP adapter) where you log the internal cause e.Err. This approach is valuable precisely because it reduces copy-paste and makes behavior consistent.
GO TO FULL VERSION