Когда пишешь REST API, нужно договориться: в каком формате идут данные, какие HTTP-статусы возвращать и как не наплодить null там, где их никто не ждёт. Разберём всё по порядку.
Как Go превращает структуру в JSON
В Go любая структура превращается в JSON через поле json:"...". Имя поля в JSON задаётся тегом, а не именем поля в Go.
Проблема без тегов: Go экспортирует поля с заглавной буквы (OrderID, CreatedAt), и по умолчанию они попадут в JSON точно так же — OrderID, CreatedAt. REST API принято делать с camelCase: orderId, createdAt.
Решение — json-теги:
type OrderResponse struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
Total int64 `json:"total"`
CreatedAt time.Time `json:"createdAt"`
}
time.Time Go сериализует в ISO 8601 автоматически: "2026-03-14T09:00:00Z". Дополнительной настройки не нужно.
omitempty — как убрать пустые поля из ответа
Иногда поле необязательное: у заказа может не быть примечания. Если в Go это пустая строка "", без дополнительных настроек она всё равно попадёт в JSON: "note": "". Это лишний шум в ответе.
Добавь omitempty — и поле просто исчезнет из JSON, когда оно пустое:
type OrderResponse struct {
OrderID string `json:"orderId"`
Note string `json:"note,omitempty"` // не появится, если ""
}
omitempty считает «пустым»: пустую строку "", ноль 0, false и nil. Для строк и чисел это обычно именно то, что нужно.
Почему не надо использовать указатели в ответах
Иногда в ответных структурах пишут *string (указатель на строку). Проблема: когда значения нет, Go сериализует указатель как null. И клиент получает "note": null там, где ожидал просто отсутствие поля.
// частая ошибка — null попадёт в ответ
type Bad struct {
Note *string `json:"note"`
}
// правильно — поле просто отсутствует
type Good struct {
Note string `json:"note,omitempty"`
}
Исключение: в структурах для PATCH-запросов указатель нужен, чтобы различить «поле не передали» и «передали null намеренно, чтобы удалить значение». Но это только для входящих данных, не для ответов.
Хелперы для записи ответа
Вместо того чтобы в каждом обработчике повторять одни и те же строки, заведи пару маленьких функций:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("writeJSON failed", "err", err)
}
}
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
Теперь в обработчиках — одна строка вместо трёх.
Единичный ресурс — просто плоский объект
Когда клиент запрашивает один заказ, ответ — просто объект без лишних обёрток:
func getOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.GetOrder(r.Context(), id)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
JSON-ответ выглядит так:
{
"orderId": "a3f2d1",
"customerId": "c-101",
"status": "CONFIRMED",
"total": 49900,
"currency": "RUB",
"createdAt": "2026-03-14T09:00:00Z",
"updatedAt": "2026-03-14T09:05:00Z",
"items": [...]
}
Распространённая ошибка — завернуть объект в обёртку: {"data": {...}}. Это усложняет клиентский код без пользы. Отдавай объект напрямую.
Коллекция с пагинацией
Когда нужно вернуть список с постраничной навигацией, используют единый формат: массив в поле content плюс метаданные о странице.
type PageResponse[T any] struct {
Content []T `json:"content"`
Page int `json:"page"`
Size int `json:"size"`
Total int `json:"total"`
}
func toPageResponse[T any](items []T, page, size, total int) PageResponse[T] {
if items == nil {
items = []T{} // пустой срез, не nil
}
return PageResponse[T]{
Content: items,
Page: page,
Size: size,
Total: total,
}
}
Ответ выглядит так:
{"content": [...], "page": 1, "size": 20, "total": 150}
Важный момент: если список пустой, content должен быть [], а не null:
{"content": [], "page": 1, "size": 20, "total": 0}
Вот почему в toPageResponse есть проверка if items == nil — неинициализированный срез в Go сериализуется в null. Это защита от случайной ошибки.
Создание ресурса — 201 и заголовок Location
Когда клиент создаёт новый ресурс, правильный ответ — 201 Created. Дополнительно нужно вернуть заголовок Location с адресом созданного ресурса — так клиент сразу знает, куда идти за ним.
func createOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Location", "/api/v1/orders/"+order.ID)
writeJSON(w, http.StatusCreated, toOrderResponse(order))
}
Часто вижу 200 OK на создание — это неверно. 200 означает «запрос выполнен, ресурс уже существовал». 201 говорит «создан новый ресурс».
Обновление и удаление
При обновлении через PUT или PATCH возвращай 200 OK и обновлённый ресурс:
func patchOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req PatchOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.PatchOrder(r.Context(), id, req.Note)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
При удалении — 204 No Content с пустым телом:
func deleteOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := svc.DeleteOrder(r.Context(), id); err != nil {
httperr.Write(w, r, err)
return
}
writeNoContent(w)
}
Деньги и перечисления
Два момента, где часто делают ошибки:
Деньги. Никогда не используй float64 для денежных значений — плавающая точка накапливает погрешность. Используй int64 в минорных единицах: копейках, центах. Значение 49900 в ответе означает 499,00 ₽. Клиент делит на 100 при отображении.
Перечисления. Статусы и категории отдавай строками в верхнем регистре: "NEW", "CONFIRMED", "SHIPPED". Это читаемо и однозначно.
type OrderResponse struct {
Status string `json:"status"` // "NEW", "CONFIRMED", "SHIPPED"
Total int64 `json:"total"` // 49900 = 499.00 RUB
Currency string `json:"currency"` // "RUB"
}
Типичные ошибки
snake_caseв json-тегах (order_idвместоorderId) — REST API договорились наcamelCase.*stringв ответных структурах безomitempty— клиент получает"note": null.{"data": {...}}— обёртка вокруг объекта усложняет клиентский код."content": nullпри пустой коллекции — должен быть[].float64для денег — используйint64в минорных единицах.200 OKна создание — должен быть201 CreatedсLocation.
Коротко
- JSON-теги задают имена полей:
json:"orderId"даётcamelCaseв ответе. omitemptyубирает пустые поля из JSON — не нужныnullв 2xx-ответах.- В ответных структурах избегай
*T— указатели даютnull; используй значение +omitempty. - Коллекция с пагинацией:
{"content": [...], "page": 1, "size": 20, "total": 150}. - Пустая коллекция —
"content": [], никогдаnull. - Создание —
201 Created+ заголовокLocation+ тело ресурса. - Удаление —
204 No Content, тело пустое. - Деньги —
int64в копейках/центах, неfloat64. - Перечисления — строки в верхнем регистре:
"NEW","CONFIRMED".
Что почитать дальше
- Query-параметры — как принимать фильтры и параметры страницы.
- Ошибки RFC 9457 — как устроен ответ при ошибке.
- Заголовки —
Location,Content-Type,Idempotency-Key.