Где Spring и NestJS превращают тело запроса в объект автоматически, Go делает это явно: ты сам читаешь тело, декодируешь JSON, проверяешь ошибку и сам пишешь ответ. Кода чуть больше, зато видно ровно, что происходит, — никаких сюрпризов от магии биндинга.

Сигнатура обработчика

Обработчик — это функция вида func(http.ResponseWriter, *http.Request). ResponseWriter — куда писать ответ, Request — что пришло. Всё, что обработчик делает с HTTP, проходит через эти два аргумента.

func (h *ProductHandler) create(w http.ResponseWriter, r *http.Request) {
    // r — запрос, w — ответ
}

Декодирование JSON

Тело запроса читают json.Decoder. Хорошая практика — запрещать неизвестные поля (DisallowUnknownFields): тогда лишнее в JSON станет ошибкой, а не молча проигнорируется.

func decodeJSON(r *http.Request, dst any) error {
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("decode json: %w", err)
    }
    return nil
}

Структуры запроса размечают тегами json:

type CreateProductRequest struct {
    Name  string `json:"name"`
    Price int    `json:"price"`
}

Тег задаёт имя поля в JSON; экспортируемые (с большой буквы) поля сериализуются, неэкспортируемые — нет.

Кодирование ответа

Ответ пишут так же явно: выставить заголовок, код статуса, закодировать тело. Это выносят в helper, чтобы не повторять в каждом обработчике.

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("encode response", "err", err)
    }
}

Порядок важен: сначала заголовки, затем WriteHeader(status), затем тело — после WriteHeader заголовки уже не изменить. Ошибки HTTP отдают отдельным helper-ом, который ляжет на единый формат из статьи про ошибки.

Раздельные структуры запроса и ответа

Как и в других биндингах, на вход и на выход — разные структуры, а не одна на всё. Запрос несёт только то, что клиент вправе задать; ответ — только то, что сервис готов показать; доменная сущность внутри — третья.

type productResponse struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Price int    `json:"price"`
}

func toProductResponse(p product.Product) productResponse {
    return productResponse{ID: p.ID, Name: p.Name, Price: p.Price}
}

Преобразование — явная функция, а не общая модель: внутренние поля домена не утекут в ответ. Это та же дисциплина границ, что и при роутинге.

Где это в UCP

Обработчик в Go — тонкий край сервиса: прочитать, провалидировать, вызвать Handler, записать. Бизнес-логики тут нет — она в Handler-е и домене, как в любом биндинге UCP. Явные decode/encode означают, что граница данных видна в коде, а не спрятана в аннотациях; следующий шаг на этой границе — валидация, а перевод ошибок в ответ — отдельная статья. Эта явность — то, за что продукт-инженер выбирает Go, когда хочет точно знать, что происходит на краю сервиса, в отличие от магии биндинга в Spring.