1. Чому дублювання в обробниках — це «тихе» джерело багів
Коли ви тільки починаєте писати HTTP‑сервер, обробник виглядає невинно: кілька рядків, пара перевірок, w.WriteHeader(...) — і готово. Але щойно з’являється другий обробник, потім третій, потім «ще трохи валідації», потім «а давайте всюди JSON», ви раптом помічаєте, що половина проєкту — це повторювані блоки: виставити Content-Type, вибрати статус, сформувати JSON помилки, не забути return, не показати назовні err.Error() на 500 і так далі.
Проблема не в естетиці — хоча й вона страждає, а IDE починає плакати. Проблема в тому, що дублювання майже завжди роз’їжджається: в одному обробнику ви забули charset, в іншому — віддали 500 замість 400, у третьому — зробили формат помилки «трохи іншим», і клієнти вашого API починають жити у світі сюрпризів.
У Go такі речі традиційно розв’язують простим і чесним способом: помилки — це значення, з ними можна програмувати, а не лише друкувати їх як рядок. Цю ідею добре сформульовано в класичному тексті «Errors are values». І якщо помилки — це значення, то ми можемо винести повторювану обробку помилок в одне місце замість того, щоб повторювати її всюди.
Головна ідея: обробник повертає результат і помилку
Щоб позбутися дублювання, потрібно розділити відповідальність. Обробник має займатися сенсом: «прочитав вхідні дані → перевірив → порахував → повернув результат». А HTTP‑обгортка має займатися ритуалом: «якщо є помилка — перетвори її на JSON, вистав статус і заголовки; якщо все добре — віддай JSON результату».
Це не нова магія і не «патерн із модного фреймворку». У Go це базова практика: створити свій тип функції, дати йому метод ServeHTTP і тим самим вбудувати його в net/http. У класичній, хоч і давній, статті про обробку помилок у Go прямо показано, як тип appHandler допомагає винести повторювану обробку помилок з окремих обробників в один спільний 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. Центральна точка серіалізації: writeJSON
Будь-який серйозний API рано чи пізно впирається в просте правило: відповіді мають бути однаковими. І це стосується не лише помилок, а й успішних відповідей.
Якщо кожен обробник сам викликає 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 для сценарію «знайди в ланцюжку помилок значення потрібного типу й запиши його в змінну», який з’явився разом із загальним підходом до розгортання помилок.
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: єдиний вхід для всіх обробників
Тепер збираємо головну страву дня: адаптер, який перетворює нашу зручну функцію на http.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» часто використовують, щоб винести повторювану обробку помилок з обробників в одне місце.
Якщо ви зараз думаєте: «А що, так можна було?», то так — можна. Go взагалі дуже добре поводиться в цьому місці: не вимагає класів, не вимагає наслідування, просто беремо тип і даємо йому метод.
Невелика схема потоку виглядає так:
flowchart LR
A[ServeMux знаходить маршрут] --> B[appHandler.ServeHTTP]
B --> C[наш обробник повертає Response або *HTTPError]
C -->|успіх| D[writeJSON]
C -->|помилка| E[writeError]
7. Мінізастосунок: API задач із чистими обробниками
Тепер давайте зберемо маленький сервер, який демонструє цей стиль. Ми не будуємо сховище і не робимо справжній CRUD (це окрема тема), а просто створюємо мінімальні обробники, щоб побачити, як виглядає код без дублювання.
Спочатку визначимо модель і тестові дані:
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var tasks = []Task{
{ID: 1, Title: "Вивчити Go", Done: false},
{ID: 2, Title: "Випити чаю", 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": "обовʼязкове поле"}}
}
id, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Fields: map[string]string{"id": "має бути цілим числом"}}
}
if id <= 0 {
return 0, &ValidationError{Fields: map[string]string{"id": "має бути додатним"}}
}
return id, nil
}
Обробник GET /tasks/{id}: лінійний код без ручного «повернути помилку»
Тепер сам обробник: він не знає, як улаштована 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: "некоректний запит", 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
}
Зверніть увагу на приємний ефект: обробник читається як звичайна функція. Помилки не розмазують логіку. Наче пишемо: «спочатку перевір — потім зроби».
І це дуже по-Go: ми не намагаємося сховати помилки, ми намагаємося не дублювати однакову реакцію на них. Ця думка напряму продовжує ідею «помилки — це значення, ними можна програмувати».
Обробник 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: реєструємо маршрути з урахуванням методу
І нарешті підключаємо все до 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": "некоректний запит",
"fields": { "id": "має бути цілим числом" }
}
}
А якщо /tasks/2, то отримає 200 і JSON задачі.
Де живе decode і чому важливо не змішувати рівні
Слово «decode» в HTTP‑контексті інколи лякає новачків: здається, що зараз почнеться «декодуємо JSON, перевіряємо DTO, мапимо домен, пишемо middleware…». Насправді decode — це просто «витягти вхідні дані із запиту й привести їх до потрібних типів».
У нашому сьогоднішньому мінімумі decode — це дві речі: r.PathValue("id") (отримати рядок із шляху) і parseID (привести рядок до int і перевірити його). Важливий момент: parseID не пише HTTP‑відповідь. Він повертає error, а далі обробник вирішує, до якого класу належить помилка (у нас — validation). Це дозволяє зберігати межі: parseID — утиліта, обробник — HTTP‑логіка, адаптер — форматування відповіді.
Ще один тонкий, але корисний принцип: якщо ваш обробник уже почав писати успішну відповідь (наприклад, w.WriteHeader(200)), а потім «раптом» зрозумів, що сталася помилка, ви вже запізнилися. Саме тому стиль «обробник повертає Response/HTTPError, а пише завжди адаптер» так дисциплінує код: або успіх, або помилка, і рішення централізоване.
8. Типові помилки під час побудови спільного шару обробників
Помилка № 1: частина обробників пише відповідь напряму, а частина — через адаптер.
Це виглядає невинно («ну тут я сам швиденько Encode зроблю»), але швидко перетворюється на мішанину форматів. У якийсь момент клієнт API побачить два різні види помилок, а ви пів дня шукатимете «де ж у нас той обробник, який не використовує 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» — і ось у вас уже міні‑фреймворк, який розумієте лише ви, та й то у свята. Тримайте адаптер простим: його завдання — стандартизувати відповіді й прибрати дублювання. Усе інше має з’являтися лише тоді, коли у вас є чітка причина й зрозуміла межа відповідальності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ