---
title: "Кодеки: JSON encoding/decoding"
description: "Кодеки go kit: ErrorEncoder и ServerErrorEncoder, StatusCoder и Headerer, готовый kithttp EncodeJSONResponse, JSON-конверт ошибок, честные статусы и protobuf."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/05-codeki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Кодеки: JSON encoding/decoding

*Курс «go-kit: микросервисный тулкит», урок 5 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/05-codeki/*

Транспорт из четвёртого урока работает, но дефолты ещё колются: сломанный JSON уезжает с 500 в text/plain, а кодирование ошибок умещается в один case. Кодеки go kit — это декодер и энкодер из NewServer, единственные функции, которым разрешено знать формат. Ты подключишь свой ErrorEncoder через опцию ServerErrorEncoder, разберёшься со StatusCoder и Headerer, попробуешь готовый kithttp EncodeJSONResponse и посмотришь, как меняется кодек при переходе на protobuf. После урока go kit json станет предсказуемым контрактом, а не набором сюрпризов.

### Дефолт, который пора заменить

Вспомни таблицу ошибок из урока 04: сломанный JSON попадает в DefaultErrorEncoder и возвращается клиенту со статусом 500 и телом text/plain. Разберём, почему это плохо в трёх проекциях. Первая — контракт API: потребитель не может отличить свою ошибку (битое тело) от нашей (упавший сервис), а значит, не может принять верное решение — повторить запрос или исправить payload. Вторая — мониторинг: если каждая ошибка декодирования — это 500, то график 500-х врёт; алерт на него будет будить дежурного из-за кривого скрипта заказчика, и однажды он проморгает настоящую аварию. Третья — утечка: текст внутренней ошибки, например от драйвера базы, уходит наружу как есть, в text/plain без единого правила форматирования. Кодеки go kit устроены так, что всё это чинится в одном месте: транспортный пакет даёт тип ErrorEncoder и опцию ServerErrorEncoder, а весь контракт ошибок API собирается в одну функцию с таблицей кодов. Это и есть главный урок темы: кодеки go kit — точка, где формат данных и статусы решаются централизованно, один раз на весь сервис.

### Кодек = декодер + энкодер: где живёт формат

Зафиксируем словарь. Кодек — это пара функций из kithttp.NewServer: DecodeRequestFunc превращает HTTP-запрос в request-структуру, EncodeResponseFunc — response-структуру в HTTP-ответ. Больше никто в проекте не имеет права трогать r.Body, заголовки и Content-Type — это правило мы проговорили в четвёртом уроке, а теперь оно становится рабочим инструментом. Хочешь отдавать клиенту protobuf вместо JSON? Меняешь пару функций, endpoint и сервис не двигаются — та самая транспорт-агностичность из урока 01. Хочешь единый конверт ошибок вида {"error": "..."}? Пишешь ErrorEncoder один раз — он сработает для всех эндпоинтов сервиса. Важно прочитать и обратную сторону: кодеки — не место для бизнес-логики. Декодер отвечает за транспортную корректность (тело распарсилось, обязательное поле есть, типы совпали), энкодер — за сериализацию и статусы. Проверка «сумма заказа больше нуля» живёт в сервисе из урока 03, и никакие кодеки её не подменяют. Проведи границу один раз и держи её: формат — кодекам, смысл — сервису.

### Свой ErrorEncoder: одна функция вместо сюрпризов

```go
import (
        "context"
        "errors"
        "encoding/json"
        "net/http"

        "github.com/go-kit/kit/endpoint"
        kithttp "github.com/go-kit/kit/transport/http"
)

// ErrorEncoder — func(ctx, err, w): ошибка целиком в твоих руках.
func encodeError(_ context.Context, err error, w http.ResponseWriter) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.WriteHeader(codeFrom(err))
        json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()})
}

func codeFrom(err error) int {
        var sc interface{ StatusCode() int }
        if errors.As(err, &sc) {
                // Тип-носитель (badRequestError): статус едет с ошибкой.
                return sc.StatusCode()
        }
        switch {
        case errors.Is(err, ErrEmpty):
                return http.StatusBadRequest // 400 — вина клиента
        default:
                return http.StatusInternalServerError // 500 — наша вина
        }
}
```

Тип ErrorEncoder из пакета kithttp — это func(ctx context.Context, err error, w http.ResponseWriter). Мы взяли encodeError и codeFrom из урока 04 и подняли их на уровень опции сервера: теперь одна и та же функция кодирует и бизнес-ошибки, и ошибки декодера. Обрати внимание на errors.As в codeFrom: сначала у ошибки спрашивают её собственный StatusCode — так таблица дружит с типами-носителями из следующего раздела. Конверт {"error": "..."} — единый для всего API; подключение — одной опцией, о ней ниже.

### ServerErrorEncoder: проводим ошибку до клиента

Посмотрим на путь ошибки в исходниках server.go пакета transport/http, потому что от него зависит всё остальное. Декодер вернул ошибку — Server вызывает errorHandler.Handle (это лог, по умолчанию NopLogger, настраивается опцией ServerErrorHandler) и сразу errorEncoder(ctx, err, w): endpoint не вызывается, клиент получает твой ответ. Endpoint вернул err — инфраструктурный сбой — и он идёт тем же маршрутом: лог, потом твой ErrorEncoder. Даже ошибка энкодера в актуальной версии идёт в errorHandler и затем в errorEncoder — но там, как мы говорили в уроке 04, заголовки обычно уже уехали, и честным последним словом остаётся лог. Вывод для практики: ErrorEncoder — это единственная функция, которой достаются все три класса ошибок, поэтому контракт ошибок API настраивается ровно в одном месте. Подключение — опция NewServer: kithttp.ServerErrorEncoder(encodeError). Опций у Server ровно шесть — ServerBefore, ServerAfter, ServerErrorEncoder, ServerErrorHandler, ServerErrorLogger (deprecated), ServerFinalizer — и это полный список ручек транспорта; всё остальное делается в твоих функциях.

### Сломанный JSON становится честным 400

```go
// Ошибка-носитель: сама знает свой статус и свой JSON.
type badRequestError struct{ err error }

func (e badRequestError) Error() string   { return e.err.Error() }
func (e badRequestError) StatusCode() int { return http.StatusBadRequest }

func (e badRequestError) MarshalJSON() ([]byte, error) {
        return json.Marshal(map[string]string{"error": e.err.Error()})
}

func decodeUppercaseRequest(_ context.Context, r *http.Request) (interface{}, error) {
        var req UppercaseRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                // Битое тело — вина клиента. Оборачиваем и отдаём наверх.
                return nil, badRequestError{err}
        }
        return req, nil
}
```

Канонический приём: декодер классифицирует ошибку, оборачивая её в тип-носитель. Дальше — фокус из исходников: DefaultErrorEncoder проверяет, не реализует ли err интерфейсы StatusCoder и json.Marshaler, и если да — берёт статус из StatusCode() и тело из MarshalJSON. То есть даже с дефолтным error encoder битый JSON теперь уедет с 400 и телом {"error":"..."} в application/json — без единой строчки в кодировщике.

### StatusCoder и Headerer: ошибки с амбициями

Только что мы использовали два интерфейса, о которых стоит сказать отдельно, потому что это официальная система расширения транспорта. StatusCoder — интерфейс StatusCode() int: если значение (ошибка или response) его реализует, кодер берёт статус из него, а не дефолтный. Headerer — интерфейс Headers() http.Header: реализовавшие его значения добавляют свои заголовки в ответ; для ошибки это работает после установки Content-Type. DefaultErrorEncoder идёт дальше и проверяет ещё json.Marshaler: ошибка, умеющая себя маршалить, уедет клиенту как application/json. Собираем в кучу: тебя есть три пути изобразить красивый ответ на ошибку — свой ErrorEncoder с таблицей codeFrom, тип-носитель с StatusCoder и MarshalJSON на дефолтном энкодере, или комбинация. Что выбрать? Для сервиса с одним API-контрактом чаще хватает первого: одна функция, которую читает новенький. Типы-носители выигрывают, когда ошибки порождаются в разных местах (декодер, адаптер, клиентская обёртка) и им удобнее нести свой статус с собой. Ничто не мешает смешать: sentinel-ошибки из урока 03 мапятся таблицей, а транспортные классы — типами-носителями.

### kithttp.EncodeJSONResponse: готовый энкодер

```go
mux := http.NewServeMux()
mux.Handle("/uppercase", kithttp.NewServer(
        makeUppercaseEndpoint(svc),
        decodeUppercaseRequest,
        kithttp.EncodeJSONResponse, // готовый EncodeResponseFunc
        kithttp.ServerErrorEncoder(encodeError),
))

// Response, который просит себе статус 201 Created:
type CreatedResponse struct {
        ID string `json:"id"`
}

func (r CreatedResponse) StatusCode() int { return http.StatusCreated }
```

В пакете kithttp есть готовый энкодер: EncodeJSONResponse выставляет Content-Type: application/json; charset=utf-8, применяет Headerer, берёт статус из StatusCoder (по умолчанию 200), вызывает WriteHeader и кодирует тело json.NewEncoder'ом. Если код 204, тело не пишется. Наш CreatedResponse со StatusCode() уедет как 201 — и это удобный способ давать честные статусы на создание ресурсов без ручного WriteHeader.

### Чего у готового энкодера нет: Failer

Прежде чем ты побежишь менять все свои NewServer на kithttp.EncodeJSONResponse, прочитай исходники внимательно: готовый энкодер не знает про Failer. Он не проверяет Failed() — для него ваш UppercaseResponse с заполненным Err-полем такой же успешный ответ, как и любой другой: уедет со статусом 200 и телом {"v":"","err":"empty string"}. Клиент получит 200 с ошибкой внутри — худшая из возможных комбинаций, потому что ретраи и алерты по статусам пропустят её мимо. Это не недоработка, а честное разделение ответственности: EncodeJSONResponse отвечает за сериализацию, а знание про Failed() — твоя политика. Отсюда канонический свой энкодер: тонкая обёртка, которая сначала смотрит на Failer, а счастливый путь делегирует готовой функции. Три строки, зато контракт ошибок единый и для endpoint'а, и для декодера, и для энкодера — весь API говорит на одном языке статусов.

### Свой энкодер: Failer + готовая функция

```go
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
        if f, ok := response.(endpoint.Failer); ok && f.Failed() != nil {
                encodeError(ctx, f.Failed(), w) // бизнес-ошибка → честный статус
                return nil
        }
        return kithttp.EncodeJSONResponse(ctx, w, response) // счастливый путь
}
```

Эволюция энкодера из урока 04: ручные Header().Set и json.NewEncoder заменены делегированием kithttp.EncodeJSONResponse, а ветка Failer осталась нашей. Теперь статус 204, заголовки и Content-Type — забота готовой функции; бизнес-ошибки — забота encodeError с codeFrom. Оба маршрута проверяются компилятором и пишутся один раз на сервис.

### Карта ответов и статусов stringsvc после урока

| Ситуация | Пример | Кто кодирует | Статус |
| --- | --- | --- | --- |
| Счастливый путь | POST /uppercase с валидным телом | kithttp.EncodeJSONResponse | 200 и JSON {"v":"HI"} |
| Сломанный JSON | тело not json | encodeError через badRequestError | 400 и {"error":"..."} |
| Бизнес-ошибка | ErrEmpty из сервиса | encodeResponse → encodeError | 400 и {"error":"empty string"} |
| Создание ресурса | response со StatusCode() 201 | kithttp.EncodeJSONResponse | 201 Created |
| Инфраструктурный сбой | err endpoint'а: база недоступна | encodeError → default ветка | 500 — честный сигнал |
| Ошибка энкодера | клиент отвалился на Encode | ServerErrorHandler → лог | обрубленный ответ |

### Когда JSON не нужен: protobuf и свои форматы

JSON — дефолт, но кодеки существуют именно для того, чтобы формат был решением, а не привычкой. XML: готового энкодера в kithttp нет — есть только EncodeJSONResponse, зато свой XML-энкодер пишется по образцу готового за пять минут: encoding/xml и xml.NewEncoder(w).Encode(response). protobuf: тело запроса — бинарные байты, декодер читает их через io.ReadAll и прогоняет через proto.Unmarshal в сгенерированную структуру, энкодер — proto.Marshal(response-сообщения) и Content-Type: application/proto; валидация статуса — всё те же StatusCoder и encodeError. Зачем это в HTTP-мире, если есть gRPC? Для пограничных сценариев: фронтенд на HTTP, а межсервисный трафик — на gRPC, при этом endpoint один (урок 13 покажет сборку gRPC-транспорта на тех же структурах). И общий совет по версиям: json-теги на request/response-структурах — это контракт API, меняй их так же осторожно, как публичные функции: переименование поля ломает клиентов молча и безвозвратно. Если контракт эволюционирует — добавляй поля с omitempty, а не переименовывай старые.

### Пять правил кодеков

1. Формат живёт только в кодеках: r.Body, заголовки и Content-Type не трогают ни сервис, ни endpoint, ни адаптер.
2. Один ErrorEncoder на сервис: контракт ошибок API описан одной функцией с таблицей codeFrom.
3. Декодер классифицирует транспортные ошибки: типы-носители со StatusCoder решают статус на месте.
4. Свой энкодер проверяет Failer до делегирования готовой функции — иначе бизнес-ошибки уезжают с 200.
5. Бизнес-валидация не в декодере: формат проверяет кодек, смысл проверяет сервис из урока 03.

> **Один конверт ошибок, а не сколько угодно.** Соблазн вырастить у каждого эндпоинта свой формат ошибки велик: «а здесь удобнее вернуть код строкой». Не поддавайся: потребитель пишет один обработчик ошибок на весь API, и в нём должна быть одна форма. Все различия — в коде статуса и тексте, не в структуре. Если появились заголовки вроде Retry-After — это ветка Headerer, а не повод менять конверт.

> **Честно о запуске кода.** Урок требует установленного Go и модуля github.com/go-kit/kit — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении и понимании кода. Локально собери сервис из урока 04, добавь ServerErrorEncoder и прогони curl'ом POST /uppercase с телом not json: вместо 500 text/plain теперь должен прийти честный 400 с JSON-конвертом.

**Вопрос:** POST /uppercase с телом not json, декодер оборачивает ошибку в badRequestError{StatusCode() 400, json.Marshaler}. Какой ответ получит клиент?

- 500 и text/plain — ничего не изменилось
- ✅ 400 и application/json с телом из MarshalJSON
- 200 и пустое тело — ошибка проглотится
- Соединение оборвётся без ответа

DefaultErrorEncoder проверяет интерфейсы ошибки: json.Marshaler даёт application/json и тело из MarshalJSON, StatusCoder даёт статус 400 вместо 500. Декодер отработал и вернул ошибку — endpoint не вызывается, ответ уходит сразу. Это и есть цель урока: плохой запрос больше не выглядит как авария.

**Вопрос:** Response-структура реализует только StatusCoder с StatusCode() 201. Что сделает kithttp.EncodeJSONResponse?

- Отдаст 200 — готовый энкодер статусы не читает
- ✅ Отдаст 201 с Content-Type application/json и телом в JSON
- Отдаст 201 с пустым телом — статус всегда гасит тело
- Паника: нужен ещё Headerer

EncodeJSONResponse выставляет Content-Type, применяет Headerer, берёт код из StatusCoder (дефолт 200), пишет WriteHeader и кодирует тело. Пустое тело бывает только при 204. Headerer не обязателен — это отдельный независимый интерфейс.

**Вопрос:** Почему свой encodeResponse обязан проверять Failer до вызова kithttp.EncodeJSONResponse?

- Иначе готовый энкодер упадёт с паникой на Err-поле
- ✅ Иначе бизнес-ошибка уедет клиенту со статусом 200 внутри тела
- Иначе Content-Type не выставится вовремя
- EncodeJSONResponse не умеет кодировать структуры с методом Failed

Готовый энкодер не знает про endpoint.Failer: он закодирует UppercaseResponse{Err: "empty string"} как обычный успешный ответ со статусом 200. Ретраи и алерты, смотрящие на статусы, такую ошибку пропустят. Проверка Failed() и ветка encodeError — наша политика, а не библиотечная.

### Ошибка, которая умеет в JSON: что выберет DefaultErrorEncoder?

```go
type quotaError struct{ Limit int }

func (quotaError) Error() string { return "quota exceeded" }
func (quotaError) StatusCode() int { return http.StatusTooManyRequests }
func (e quotaError) MarshalJSON() ([]byte, error) {
        return json.Marshal(map[string]int{"limit": e.Limit})
}

// Endpoint вернул err = quotaError{Limit: 10}
// ServerErrorEncoder НЕ настроен
```

**Что выведет программа?**

- 500 и text/plain — StatusCoder работает только для response
- ✅ 429 и application/json с телом {"limit":10}
- 429 и text/plain — MarshalJSON ошибок игнорируется
- 200 с телом {"limit":10} — ошибка не классифицируется

DefaultErrorEncoder проверяет три интерфейса на ошибке: json.Marshaler даёт Content-Type application/json и маршаленное тело, StatusCoder даёт статус 429 вместо дефолтных 500, Headerer добавил бы заголовки. Текст Error() при этом не используется — тело берётся из MarshalJSON. Это самый экономный способ дать ошибке богатый ответ без своего ErrorEncoder.

### nil на входе готового энкодера

```go
// Endpoint вернул response = nil, err = nil
// (NopRequestDecoder + endpoint без логики)
mux.Handle("/healthz", kithttp.NewServer(
        healthEndpoint,
        kithttp.NopRequestDecoder,
        kithttp.EncodeJSONResponse,
))
```

**Что выведет программа?**

- 204 и пустое тело — энкодер угадает намерение
- ✅ 200 и тело null — json.NewEncoder закодирует nil
- 500 — Encode вернёт ошибку nil-значения
- Паника в WriteHeader — код не соберётся

В актуальной версии EncodeJSONResponse нет ветки для nil: он честно выставит Content-Type, вызовет WriteHeader(200) и json.NewEncoder(w).Encode(nil), что даёт литерал null. Хочешь 204 — верни response с StatusCode() http.StatusNoContent: тогда WriteHeader(204) пройдёт, а тело не запишется. Дефолты надо знать, чтобы не удивляться null'ам в логах потребителей.

### Подключи свой ErrorEncoder и добей энкодер

Собери монтаж эндпоинта и свой энкодер. Пропуски — по порядку: опция error encoder'а, метод статуса, метод сериализации.

```go
mux.Handle("/uppercase", kithttp.NewServer(
        makeUppercaseEndpoint(svc),
        decodeUppercaseRequest,
        encodeResponse,
        kithttp.___(encodeError),
))

func encodeError(_ context.Context, err error, w http.ResponseWriter) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.___(codeFrom(err))
        json.NewEncoder(w).___(map[string]interface{}{"error": err.Error()})
}
```

Подсказка: Первый пропуск — ServerOption, подменяющая DefaultErrorEncoder; затем запись статуса и запись тела через json.NewEncoder.

Правильные ответы в пропусках: `ServerErrorEncoder`, `WriteHeader`, `Encode`

### Путь сломанного JSON после урока

Расставь этапы обработки POST /uppercase с телом not json в правильном порядке: от приёма соединения до ответа клиенту.

Правильный порядок строк:

1. `http.ListenAndServe принял соединение и передал mux`
2. `mux нашёл Server из kithttp.NewServer, ServerBefore пополнили контекст`
3. `декодер вызвал json.NewDecoder(r.Body).Decode и получил синтаксическую ошибку`
4. `декодер вернул badRequestError{err} — endpoint не вызывается`
5. `ServerErrorHandler.Handle записал ошибку в лог`
6. `errorEncoder = наш encodeError получил вызов`
7. `codeFrom/badRequestError.StatusCode() дали http.StatusBadRequest`
8. `клиент получил 400 и {"error":"..."} в application/json`

### Термины урока

- **ErrorEncoder** — func(ctx, err, w) из kithttp: единственная функция, кодирующая все классы ошибок — декодера, endpoint'а, энкодера — в HTTP-ответ.
- **ServerErrorEncoder** — ServerOption, подменяющая DefaultErrorEncoder твоим ErrorEncoder: kithttp.ServerErrorEncoder(encodeError) — по опции на каждый NewServer.
- **DefaultErrorEncoder** — Дефолт: 500 и text/plain; но если err реализует json.Marshaler/StatusCoder/Headerer — берёт их тело, статус и заголовки.
- **StatusCoder** — Интерфейс StatusCode() int: значение само просит себе статус — 201, 429, 503; работает и для response, и для ошибок.
- **Headerer** — Интерфейс Headers() http.Header: значение добавляет свои заголовки в ответ — Retry-After, X-Request-ID, кастомные ветвления.
- **kithttp.EncodeJSONResponse** — Готовый энкодер: Content-Type application/json; charset=utf-8, Headerer, StatusCoder, WriteHeader, 204 без тела, json.NewEncoder. Про Failer не знает.

### Что дальше

Контракт ошибок собран: сломанный JSON — честный 400, бизнес-ошибки — по таблице codeFrom, счастливый путь — делегирован готовому энкодеру. Весь go kit json теперь описан двумя функциями и горсткой интерфейсов. Вернуться к тому, откуда пришёл Failer, можно в [уроке 02-endpoint](/courses/go-kit/lessons/02-endpoint/), к sentinel-ошибкам сервиса — в [уроке 03-service](/courses/go-kit/lessons/03-service/), а полный разбор транспорта — в [уроке 04-http-transport](/courses/go-kit/lessons/04-http-transport/); карта курса — на [странице курса](/courses/go-kit/). Следующий шаг — [урок 06-logirovanie](/courses/go-kit/lessons/06-logirovanie/): kitlog, структурные пары ключ-значение, уровни и endpoint-middleware логирования, который будет писать в лог каждый вызов каждого endpoint'а — без единой строки в сервисе.

## Коротко: что запомнить

- Кодеки go kit — пара функций из NewServer: декодер читает транспорт, энкодер пишет ответ; формат живёт только здесь.
- Ошибка декодера/endpoint'а/энкодера идёт в ErrorEncoder; свой подключается опцией kithttp.ServerErrorEncoder(encodeError).
- DefaultErrorEncoder читает интерфейсы ошибки: json.Marshaler даёт application/json, StatusCoder — статус, Headerer — заголовки.
- Тип-носитель badRequestError превращает сломанный JSON из дефолтного 500 text/plain в честный 400 с JSON-конвертом.
- kithttp.EncodeJSONResponse — готовый счастливый путь: Content-Type, StatusCoder, 204 без тела; про Failer он не знает.
- Свой encodeResponse = проверка Failed() + делегирование EncodeJSONResponse: один контракт ошибок на весь API.
- Другой формат — другой кодек: XML пишется руками, protobuf — proto.Unmarshal/Marshal; endpoint и сервис не меняются.

## Домашнее задание

Возьми stringsvc из домашки урока 04 и доведи кодеки до боевых. Первое: подключи kithttp.ServerErrorEncoder(encodeError) к обоим NewServer и проверь curl'ом, что POST /uppercase с телом not json отдаёт 400 и конверт {"error":"..."} в application/json — а не дефолтный 500 text/plain. Второе: оберни ошибку декодера в тип-носитель со StatusCode() и MarshalJSON и убедись, что без настроенного ErrorEncoder статус всё равно честный — это запасная прочность контракта. Третье: перепиши encodeResponse на ветку Failer плюс делегирование kithttp.EncodeJSONResponse и добейся, чтобы ErrEmpty уходил как 400, а счастье — как 200. Четвёртое: добавь в codeFrom вторую sentinel-ошибку своего сервиса из урока 03 и зафиксируй изменение статуса. Пятое, со звёздочкой: напиши декодер и энкодер для protobuf-сообщения по своему proto-файлу и проверь, что endpoint не потребовал ни одной правки.

## Вопросы и ответы по уроку

### Свой ErrorEncoder или типы-носители со StatusCoder — что выбрать?

Начни с одного ErrorEncoder с таблицей codeFrom: он даёт единый конверт ошибок и читается как документация API. Типы-носители добавляй, когда ошибки рождаются в разных местах и каждой удобнее нести свой статус и тело с собой; оба подхода комбинируются без конфликтов.

### Почему DefaultErrorEncoder отдаёт 500 на сломанный JSON?

Потому что транспорт не может знать, чья вина в ошибке декодирования, и выбирает консервативный дефолт. Классификация — твоя работа: оборачивай ошибки декодера в тип со StatusCode() 400 или мапь их в codeFrom, и статус станет честным без изменения сигнатур.

### Нужен ли protobuf в HTTP-сервисе?

Когда на стыке скорость и строгая схема важнее читаемости, а gRPC по каким-то причинам не вариант: публичный шлюз остаётся на JSON, внутренний трафик — на protobuf через свои кодеки. Endpoint один и тот же — меняется только пара функций, поэтому эксперимент стоит три функции, а не рефакторинг.
