Где 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.