JavaRush /Courses /Go SELF /Minimizing duplication: HTTP handlers

Minimizing duplication: HTTP handlers

Go SELF
Level 60, Lesson 4
Available

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.

1
Task
Go SELF, level 60, lesson 4
Locked
JSON Ping
JSON Ping
1
Task
Go SELF, level 60, lesson 4
Locked
Response adapter
Response adapter
1
Task
Go SELF, level 60, lesson 4
Locked
Notes without duplicates
Notes without duplicates
1
Survey/quiz
HTTP routing, 60 level, 4 lesson
Unavailable
HTTP routing
HTTP routing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION