Опирается на правила: R-RSP-1..8, R-RSP-X1..X4, R-FLD-1..7раздел JSON и ответы.

Важно знать

  • camelCase для всех JSON-полей: json:"orderId", json:"createdAt".
  • omitempty на необязательных полях — null не появляется в 2xx-ответах.
  • Не используй *T в response-структурах — указатели тянут null.
  • Коллекция{"content": [...], "page": 1, "size": 20, "total": 150}.
  • Пустая коллекция"content": [], никогда null.
  • Создание201 Created + Location + тело ресурса.
  • Удаление204 No Content, пустое тело.
  • Деньгиint64 в минорных единицах (копейки), не float64.
  • Enum — UPPER_SNAKE_CASE константы ("NEW", "CONFIRMED").

Хелперы ответов

Единые функции для всех хендлеров:

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)
}

Структуры ответа

R-FLD-1..7:

type OrderResponse struct {
    OrderID    string    `json:"orderId"`
    CustomerID string    `json:"customerId"`
    Status     string    `json:"status"`      // "NEW", "CONFIRMED", "SHIPPED"
    Total      int64     `json:"total"`        // копейки (R-FLD: деньги int64)
    Currency   string    `json:"currency"`     // "RUB"
    CreatedAt  time.Time `json:"createdAt"`    // ISO 8601 автоматически
    UpdatedAt  time.Time `json:"updatedAt"`
    Items      []OrderItemResponse `json:"items"`
    Note       string    `json:"note,omitempty"`  // необязательное поле
}

type OrderItemResponse struct {
    ItemID    string `json:"itemId"`
    ProductID string `json:"productId"`
    Name      string `json:"name"`
    Quantity  int    `json:"quantity"`
    Price     int64  `json:"price"`
}

omitempty убирает поле из JSON, если оно пустое ("", 0, false, nil). Это реализует R-RSP-X1 — null не появляется в 2xx.

type ProductResponse struct {
    ProductID   string `json:"productId"`
    Name        string `json:"name"`
    Description string `json:"description,omitempty"` // отсутствует если ""
    IsActive    bool   `json:"isActive"`
    CreatedAt   time.Time `json:"createdAt"`
}

Не используй *string в response-структурах — указатели сериализуются в null:

// ✗ — null в 2xx
type Bad struct {
    Note *string `json:"note"`
}

// ✓ — поле отсутствует если ""
type Good struct {
    Note string `json:"note,omitempty"`
}

Единичный ресурс

R-RSP-1: плоский объект без обёртки.

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))
}
{
  "orderId": "a3f2d1",
  "customerId": "c-101",
  "status": "CONFIRMED",
  "total": 49900,
  "currency": "RUB",
  "createdAt": "2026-03-14T09:00:00Z",
  "updatedAt": "2026-03-14T09:05:00Z",
  "items": [...]
}

R-RSP-X4: envelope-обёртка запрещена — не {"data": {...}}.

Коллекция с пагинацией

R-RSP-2:

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{}  // пустой slice, не nil — R-RSP-7
    }
    return PageResponse[T]{
        Content: items,
        Page:    page,
        Size:    size,
        Total:   total,
    }
}

func listOrders(w http.ResponseWriter, r *http.Request) {
    q, violations := parseListOrdersQuery(r)
    if len(violations) > 0 {
        writeValidationProblem(w, violations, traceIDFromCtx(r.Context()))
        return
    }
    result, err := svc.ListOrders(r.Context(), q)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    resp := toPageResponse(toOrderResponses(result.Items), q.Page, q.Size, result.Total)
    writeJSON(w, http.StatusOK, resp)
}

Пустая коллекция (R-RSP-7):

{"content": [], "page": 1, "size": 20, "total": 0}

Создание ресурса — 201 + Location

R-RSP-3:

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
    }
    if err := validate.Struct(req); err != nil {
        writeValidationProblem(w, toViolations(err.(validator.ValidationErrors)),
            traceIDFromCtx(r.Context()))
        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))
}

PUT/PATCH — 200 + обновлённый ресурс

R-RSP-4:

type PatchOrderRequest struct {
    Note *string `json:"note"` // nil = не трогать; explicit null = удалить (R-FLD-6)
}

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))
}

DELETE — 204 No Content

R-RSP-5:

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)
}

Что запрещено

АнтипаттернПравилоЧто взамен
null поле в 2xx (*string без omitempty)R-RSP-X1string + omitempty
"" пустая строка вместо отсутствияR-RSP-X2omitempty или не включать
{"data": {...}} envelopeR-RSP-X4плоский объект
"content": null при пустой коллекцииR-RSP-7"content": []
float64 для денежных значенийR-FLD-1int64 в минорных единицах
snake_case в json-тегахR-FLD-1camelCase
201 Created без LocationR-RSP-3обязателен
200 OK на созданиеR-RSP-3201 Created

Куда дальше

  • go/query-params.md — параметры для listOrders.
  • go/errors.md — формат ошибочного ответа (противопоставление).
  • go/headers.md — Location, Content-Type, Idempotency-Key.
  • go/openapi-and-antipatterns.md — схема PageResponse в спеке.