1. Почему копипаста в handler’ах — это «тихий» источник багов
Когда вы только начинаете писать HTTP‑сервер, handler выглядит безобидно: несколько строк, пара проверок, w.WriteHeader(...), готово. Но как только появляется второй handler, затем третий, затем «ещё чуть-чуть валидации», затем «а давайте везде JSON», вы внезапно обнаруживаете, что половина проекта — это повторяющиеся блоки: выставить Content-Type, выбрать статус, сформировать JSON ошибки, не забыть return, не выдать наружу err.Error() на 500 и так далее.
Проблема не в эстетике (хотя эстетика тоже страдает, и IDE начинает плакать). Проблема в том, что копипаста почти всегда расходится: в одном handler’е вы забыли charset, в другом — отдали 500 вместо 400, в третьем — сделали формат ошибки «чуть другой», и клиенты вашего API начинают жить в мире неожиданностей.
В Go исторически любят решать такие штуки через простой и честный приём: ошибки — это значения, с ними можно программировать, а не только печатать их строкой. Эта идея хорошо сформулирована в классическом тексте “Errors are values”. И если ошибки — значения, то мы можем вынести повторяющуюся обработку ошибок в одно место, вместо того чтобы повторять её везде.
Главная идея: handler возвращает результат и ошибку
Чтобы избавиться от дублирования, нужно разделить ответственность. Handler должен заниматься смыслом: «прочитал вход → проверил → посчитал → вернул результат». А вот HTTP‑обвязка должна заниматься ритуалами: «если ошибка — переведи её в JSON, выставь статус, выставь заголовки; если успех — отдай JSON результата».
Это не новая магия и не «паттерн из модного фреймворка». В Go это базовая практика: сделать свой тип функции, дать ему метод ServeHTTP и тем самым встроить его в net/http. В старой (но очень показательной) статье про обработку ошибок в Go прямо демонстрируется, как тип appHandler помогает вынести повторяющуюся обработку ошибок из отдельных handler’ов в один общий ServeHTTP.
Мы сделаем похожую конструкцию, только вместо «просто http.Error текстом» будем возвращать единый error envelope в JSON и придерживаться нашего контракта по ValidationError.Fields.
2. Кирпичики контракта: ValidationError, HTTPError, error envelope
Прежде чем писать адаптер, нужно договориться о «форме данных». Иначе общий слой не сможет быть общим: ему нечего будет стандартизировать.
Сначала — ошибка валидации. Она хранит ошибки по конкретным полям, а Error() у неё — просто маркер “validation error”. Это удобно: человеку мы показываем нормальное сообщение на уровне API, а машине (и UI‑клиенту) — отдаём fields.
package main
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation error"
}
Теперь — error envelope (единая форма ответа об ошибке). Мы держим code (машинный), message (короткий и безопасный) и опционально 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"`
}
И наконец — «ошибка HTTP‑слоя», которая разделяет публичное и внутреннее. Внутри мы храним настоящую причину (Err) — для логов/диагностики. Снаружи — контролируемый 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,
}
}
Здесь важно прочувствовать философию: сообщение пользователю и внутренняя причина — это разные вещи. Если всё свалить в err.Error(), вы либо раскроете лишнее, либо будете вынуждены скрывать полезную диагностику.
4. Центральная точка encode: writeJSON
Любой серьёзный API в какой-то момент упирается в одно простое правило: «ответы должны выглядеть одинаково». И это касается не только ошибок, но и успешных ответов.
Если каждый handler сам делает json.NewEncoder(w).Encode(...), то неизбежно появляются отличия: где-то забыли Content-Type, где-то забыли статус до записи тела, где-то в одном месте начали писать ответ, а потом решили «ой, ошибка».
Мы сделаем маленький writeJSON, который станет единственным местом, где мы сериализуем JSON для успеха.
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)
}
Да, мы пока игнорируем ошибку Encode. Не потому что «ошибок не бывает», а потому что если кодек сломался на ровном месте — клиенту уже не помочь «красивым JSON»: заголовки могли быть отправлены, часть тела могла уйти. В реальных сервисах это логируют. Мы логирование сейчас сознательно не углубляем, чтобы не устраивать «всё и сразу».
5. Центральная точка ошибок: writeError и errors.As
Теперь делаем вторую центральную точку — для ошибок. Именно здесь наш ValidationError.Fields должен превращаться в fields внутри error envelope.
Чтобы безопасно извлечь типизированную ошибку из Err, мы используем errors.As. Это стандартный путь в Go для «найди в цепочке ошибок значение нужного типа и запиши в переменную», появившийся вместе с общим подходом error unwrapping.
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)
}
Обратите внимание на аккуратность: fields появляется только если это валидация. Для not found/internal и прочих ошибок fields будет отсутствовать (из-за omitempty), и это часть стабильного контракта.
6. Адаптер appHandler: один вход для всех handler’ов
Теперь собираем главное блюдо дня: адаптер, который превращает «нашу удобную функцию» в http.Handler.
Мы договоримся, что handler возвращает либо успешный Response, либо *HTTPError. Это даст нам единый путь: ServeHTTP либо вызывает writeError, либо 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)
}
И вот тут происходит маленькая «магия Go» (на самом деле — честная механика): у типа функции есть метод, значит он реализует интерфейс http.Handler, значит его можно регистрировать в mux. Ровно этот стиль «тип функции + ServeHTTP» часто используют, чтобы вынести повторяющуюся обработку ошибок из handler’ов в одну точку.
Если вы сейчас думаете «а что, так можно было?», то да — так можно было. Go вообще довольно добрый в этом месте: не требует классов, не требует наследования, просто берём тип и даём ему метод.
Небольшая схема потока выглядит так:
flowchart LR
A[ServeMux нашёл маршрут] --> B[appHandler.ServeHTTP]
B --> C[наш handler возвращает Response или *HTTPError]
C -->|успех| D[writeJSON]
C -->|ошибка| E[writeError]
7. Мини-приложение: API задач с чистыми handler’ами
Теперь давайте соберём маленький сервер, который демонстрирует стиль. Мы не строим хранилище и не делаем настоящий CRUD (это отдельная тема), а просто делаем минимальные обработчики, чтобы увидеть, как выглядит код без копипасты.
Сначала определим модель и «заглушечные данные»:
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},
}
Теперь сделаем поиск (простенький, линейный — нам сейчас не про алгоритмы):
package main
func findTaskByID(id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
parseID: единый контракт без размазывания проверок
Мы используем тот же контракт parseID, который уже фиксировали: строка → int, id > 0, ошибки — через 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}: линейный код без ручного «ответить ошибкой»
Теперь сам handler: он не знает, как устроен JSON‑ответ об ошибке. Он только возвращает *HTTPError с нужными полями.
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
}
Обратите внимание на приятный эффект: handler читается как обычная функция. Ошибки не «размазывают» логику. Мы как будто пишем «сначала проверь, потом сделай».
И это очень по‑Go: мы не пытаемся спрятать ошибки, мы пытаемся не дублировать одинаковую реакцию на них. Эта мысль напрямую продолжает идею «ошибки — значения, ими можно программировать».
Handler GET /tasks: просто отдаём список
package main
import "net/http"
func handleListTasks(r *http.Request) (Response, *HTTPError) {
return Response{Status: http.StatusOK, Body: tasks}, nil
}
Да, всё. Никаких Content-Type, никаких json.NewEncoder — всё это будет сделано общим слоем.
main: регистрируем маршруты через method-aware patterns
И наконец, подключаем всё к http.ServeMux. Здесь важно помнить, что mux.HandleFunc принимает «функцию без возвратов», а mux.Handle принимает http.Handler. Поэтому мы регистрируем через 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))
}
Теперь, если клиент запросит /tasks/abc, он получит 400 и JSON вида:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": { "id": "must be integer" }
}
}
А если /tasks/2, то получит 200 и JSON задачи.
Где живёт decode и почему важно не смешивать уровни
Слово «decode» в HTTP‑контексте иногда пугает новичков: кажется, что сейчас начнётся «декодируем JSON, валидируем DTO, маппим домен, пишем middleware…». На самом деле decode — это просто «вынуть входные данные из запроса и привести к нужным типам».
В нашем сегодняшнем минимуме decode — это две вещи: r.PathValue("id") (получить строку из пути) и parseID (привести строку к int и провалидировать). Важный момент: parseID не пишет HTTP‑ответ. Он возвращает error, и дальше handler решает, какой это класс ошибки (у нас — validation). Это позволяет сохранять границы: parseID — утилита, handler — HTTP‑логика, адаптер — форматирование ответа.
Ещё один тонкий, но полезный принцип: если ваш handler начал писать успешный ответ (например, w.WriteHeader(200)), а потом «вдруг» понял, что ошибка — вы уже опоздали. Именно поэтому стиль «handler возвращает Response/HTTPError, а пишет всегда адаптер» так дисциплинирует код: либо успех, либо ошибка, и решение централизовано.
8. Типичные ошибки при построении общего слоя handler’ов
Ошибка №1: часть handler’ов пишет ответ напрямую, а часть — через адаптер.
Это выглядит безобидно («ну тут я сам быстренько Encode сделаю»), но быстро превращается в мешанину форматов. В какой-то момент клиент API увидит два разных вида ошибок, а вы будете полдня искать «где же у нас тот handler, который не использует writeError». Лечится просто: выбираете один путь и придерживаетесь его хотя бы в рамках одного HTTP‑слоя.
Ошибка №2: возвращать наружу err.Error() на 500‑ошибках.
Соблазн понятный: «пусть клиент сам увидит, что сломалось». Но это почти всегда плохая идея: утечки внутренних деталей, нестабильные сообщения, и, в худшем случае, лишняя информация для атакующего. Правильный путь — фиксированное безопасное сообщение для 5xx, а внутренняя ошибка остаётся внутри (HTTPError.Err). В Go очень важно осознанно решать, что именно вы «экспортируете» наружу — как через ошибки, так и через HTTP‑ответы.
Ошибка №3: пытаться «впихнуть» всю валидацию в Error() строкой.
Если сделать ValidationError.Error() чем-то вроде "id must be integer", то UI‑клиенту придётся парсить строку (а парсинг строк — это всегда маленький театр абсурда). Гораздо надёжнее хранить детали структурно через Fields map[string]string, а строку Error() оставить маркером. Тогда адаптер легко достаёт данные через errors.As, что и было задумано в стандартном механизме типизированных ошибок.
Ошибка №4: писать заголовки и статус в нескольких местах.
Если часть кода ставит Content-Type, а часть — WriteHeader, очень легко получить «header already written»‑ситуацию или просто неконсистентные ответы. В вашем коде должен быть очевидный «единственный вход» в сериализацию. В нашем варианте это writeJSON и writeError, вызываемые только из ServeHTTP.
Ошибка №5: адаптер пытается быть слишком умным.
Есть опасный момент: увидев успех, хочется добавить туда «автоматическое логирование», «автоматическую метрику», «автоматический recover», «автоматическое чтение body», и вот у вас уже мини‑фреймворк, который понимаете только вы (и то по праздникам). Держите адаптер простым: его задача — стандартизировать ответы и убрать копипасту. Всё остальное должно появляться только тогда, когда у вас есть чёткая причина и ясная граница ответственности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ