---
title: "Единый формат ошибок API"
description: "Формат ошибок API: единый JSON-конверт 400 — поле, тег, параметр и сообщение из ValidationErrors; словарь сообщений, Gin и Echo, 400 против 422."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/24-soobshcheniya-api/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Единый формат ошибок API

*Курс «Валидация в Go: go-playground/validator», урок 24 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/validator/lessons/24-soobshcheniya-api/*

Формат ошибок API — контракт, по которому клиент разбирает неудачу: один и тот же JSON на всех маршрутах, без сюрпризов от конкретного хендлера. Когда каждый хендлер пишет 400 по-своему, клиент заводит три парсера, тесты ломаются от перестановки полей, а логи перестают грепаться. Материал у валидатора готов: ValidationErrors — срез FieldError, где Field() даёт имя поля, Tag() — тег правила, Param() — параметр вроде восьмёрки из min=8, а Namespace() — путь до вложенного поля. Урок собирает из этих деталей конверт: код и сообщение наверху, детали по полям ниже, словарь тегов переводит правила в человекочитаемые сообщения, а Gin и Echo получают одну функцию сборки на весь проект. Формат — часть публичного API наравне с маршрутами и кодами ответов: его меняют реже любых текстов, документируют один раз и проверяют тестами как контракт. Отдельно — семантика статусов: где 400, когда уместен 422 и почему единообразие важнее выбора.

Клиент, получивший ошибку, решает две задачи: показать человеку, что поправить, и объяснить машине, что произошло. Для первой нужен человекочитаемый текст по каждому полю, для второй — стабильные коды, на которые не влияет переформулировка сообщений. Единый формат закрывает обе: наверху error с машинным кодом и общим сообщением, ниже details — массив по полям, где поле, правило и параметр разложены отдельно от текста. Разложенность — главная идея: текст локализуется и меняется, а поле и тег — контракт. Валидатор этот контракт уже отдал: ValidationErrors это срез FieldError, и всё, что нужно конверту, лежит в методах Field(), Tag() и Param(). Задача проекта — не потерять материал: не склеить всё в одну строку Error(), не оставить общее «проверьте поля» и не выдумывать поля заново в каждом хендлере. Одна функция сборки решает это раз и навсегда. Предсказуемость — фича: клиент, один раз прочитавший формат, разбирает вторую форму без чтения документации, а дежурный грепает логи по тегу, не открывая код хендлера. Хуже всего середина: формат вроде есть, но два маршрута от него отступают — и именно на них ловятся редкие клиенты, о которых никто не вспомнил при выпуске. Аудит быстрый: curl по пяти маршрутам вашего API — если ответы различаются формой или регистром полей, формат ещё не существует, существует догадка.

### Единый формат ошибок API: конверт из одной функции

Формат описывается один раз как DTO: наверху error с машинным кодом и общим сообщением, ниже details — срез по полям: field, rule, param, message. Собирает конверт одна функция BuildValidationResponse, принимающая validator.ValidationErrors и возвращающая DTO; она тестируется отдельно от фреймворка — табличный каркас из девятнадцатого урока ложится на неё напрямую. Хендлеры больше не пишут JSON руками: получили ValidationErrors — вызвали функцию, отдали результат фреймворку. Выигрыш виден на втором маршруте: форма профиля и форма заказа возвращают конверт одинаковой формы, и фронтенд пишет разбор один. Запасной путь тоже один: незнакомый тег получает нейтральный текст, но поле и правило в деталях всё равно точны — формат не разваливается от опечатки в словаре. Имя функции — не украшение: BuildValidationResponse читается в хендлере как команда, и на ревью достаточно взгляда — ответ собран централизованно. Возвращаемый тип — не map и не интерфейс, а описанный DTO: поля задокументированы компилятором, а не комментарием, и правка формата ломает компиляцию там, где она нужна. Ёмкость под len(errs) и готовый срез details — мелочь, которую приятно оставить в тесте: пустой ValidationErrors сериализуется пустым массивом, и клиентский цикл не падает на null.

### BuildValidationResponse: ValidationErrors в DTO

```go
type FieldDetail struct {
        Field   string `json:"field"`
        Rule    string `json:"rule"`
        Param   string `json:"param,omitempty"`
        Message string `json:"message"`
}

type ErrorDTO struct {
        Error struct {
                Code    string `json:"code"`
                Message string `json:"message"`
        } `json:"error"`
        Details []FieldDetail `json:"details"`
}

func BuildValidationResponse(errs validator.ValidationErrors) ErrorDTO {
        out := ErrorDTO{Details: make([]FieldDetail, 0, len(errs))}
        out.Error.Code = "VALIDATION_FAILED"
        out.Error.Message = "проверьте поля формы"
        for _, fe := range errs {
                out.Details = append(out.Details, FieldDetail{
                        Field:   fe.Field(),
                        Rule:    fe.Tag(),
                        Param:   fe.Param(),
                        Message: humanMessage(fe),
                })
        }
        return out
}
```

Функция — единственное место, где ValidationErrors превращаются в JSON: код и общее сообщение наверху, срез деталей по полям ниже. Field() даёт имя поля, Tag() — правило, Param() — параметр тега, текст берёт словарь humanMessage. Ёмкость среза готовится под len(errs), а details сериализуется массивом, а не null.

### Проверка: что в details[].rule

**Вопрос:** Форма не прошла тег min=8 на поле Password. Что лежит в details[].rule этого элемента?

- ✅ Строка min: тег правила, стабильный машинный контракт
- Текст сообщения «минимум 8 символов» на русском
- Число 8: значение параметра тега
- Индекс ошибки в ValidationErrors

rule — это Tag(), то есть имя правила без параметра. Восьмёрка живёт рядом в param, человеческий текст — в message. Класть в rule текст нельзя: он локализуется, а клиент и логи сравнивают именно тег.

### Field(), Tag(), Param(): материал ValidationErrors

Три метода FieldError закрывают весь разбор. Field() возвращает имя поля — для плоских структур этого достаточно; для вложенных и срезов с dive честнее Namespace(): он отдаёт полный путь вида Users[0].Email, тогда как Field() оставит только Email, и два разных адресата неразличимы. Это стык [пятого урока](/courses/validator/lessons/05-oshibki-validacii/) про разбор ValidationErrors — там каркас, здесь договорённость: если в API есть вложенные DTO, кладите в field Namespace(), если плоские — Field(), но везде одинаково. Tag() — имя правила: eqfield, oneof, datetime; это ключ для словаря сообщений и для логов. Param() — параметр: у min=8 это 8, у eqfield — имя второго поля, у oneof — перечисление вариантов; у тегов без параметра — пустая строка. Параметр приходит строкой, и «5» из min=5 кладётся в details как есть. У пары Tag() и ActualTag() разница проявляется на алиасах: Tag() вернёт имя алиаса, ActualTag() — реальный тег внутри него; для словаря и логов обычно нужен Tag(), согласованный с тем, что клиент видит в details. Три метода плюс Namespace() — весь бюджет разбора: больше валидатор ничего не обязан сообщать, остальное — забота словаря и конвенции проекта.

### min=8 против пароля из трёх символов

```go
type PasswordForm struct {
        Password string `validate:"required,min=8"`
}

// клиент прислал: {"password": "abc"}
err := v.Struct(PasswordForm{Password: "abc"})
// fmt.Println(fe.Field(), fe.Tag(), fe.Param())
```

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

- ✅ Password min 8
- Password required 8
- abc min 8
- Password min 3

Поле — Password, правило — min, параметр — 8: требование берётся из тега, а не из фактической длины. required не сработал: строка непустая и прошла первое правило цепочки. Параметр всегда строка из тега, даже если выглядит числом.

### Словарь тегов: сообщения без дублей

Детали дают клиенту машину, а человеку нужен текст: «минимум 8 символов», «адрес почты некорректен». Словарь тег → шаблон — тот же двухуровневый каталог из [шестого урока](/courses/validator/lessons/06-russkie-soobshcheniya/), свёрнутый до одной функции: по Tag() достали шаблон, подставили Param() или имя поля, вернули строку. Незнакомый тег получает запасной текст: формат не ломается, ведь поле и правило в деталях уже точны. Разделение обязанностей простое и полезное на ревью: теги — в логи для разработчика, сообщения — клиенту. Лог пишет поле, тег и параметр — по ним дежурный находит правило; клиент получает текст без тегов вообще. Дубли этого правила распознаются мгновенно: если в каком-то хендлере текст ошибки склеен руками, а не из словаря, — формат уже пошёл вразнос. Шаблоны держите рядом с кодом сборки: словарь — часть кодовой базы и меняется пулл-реквестом с тестом. Если понадобится второй язык — это второй словарь и тот же разбор: детали field, rule, param общие для любой локали, меняется только message.

### Словарь: тег и параметр в сообщение

```go
var templates = map[string]string{
        "required": "поле %s обязательно для заполнения",
        "min":      "минимум %s символов",
        "max":      "максимум %s символов",
        "email":    "некорректный адрес почты",
}

func humanMessage(fe validator.FieldError) string {
        t, ok := templates[fe.Tag()]
        if !ok {
                return "поле заполнено неверно" // запасной текст
        }
        p := fe.Param()
        if p == "" {
                p = fe.Field() // шаблонам без параметра идёт имя поля
        }
        return fmt.Sprintf(t, p)
}
```

Словарь — карта по Tag(): незнакомое правило получает запасной текст, и конверт не разваливается при добавлении тега. Параметр тега подставляется в шаблон: у min это граница, у required — имя поля. Функция чистая: без фреймворка и глобального состояния, тест — таблица кейсов по тегам.

> **Живое место: два хендлера, два формата.** Форма заказа возвращала 400 с массивом details, форма профиля — строку error с перечислением полей через запятую: писали разные авторы в разные недели. Клиентский разбор профиля молча падал, поддержка получала билеты «ошибка пустая». Если хендлер собирает ответ руками — вынесите сборку в BuildValidationResponse и запретите рукопись на ревью; если поле вложенное и field различает два Email — кладите Namespace(), а не Field(); если сомневаетесь в статусе — вернитесь к конвенции проекта, единообразие важнее идеального номера.

### Gin и Echo: одна сборка, две доставки

Функция сборки независима от фреймворка: она про данные, а доставка — слой ниже. В Gin хендлер отвечает [AbortWithStatusJSON](/courses/validator/lessons/12-validator-i-gin/) с кодом 400 и DTO: ответ уходит, цепочка прерывается, и после этого места код работает с валидными данными. В Echo два равных пути: вернуть ошибку из хендлера и разобрать её в своём HTTPErrorHandler или сразу написать c.JSON(400, dto); оба ведут к одному конверту, если errorHandler знает DTO. Стык тринадцатого урока про хуки Echo: там настройка, здесь — дисциплина одного конверта на все маршруты. Порядок проверки в хендлере тоже стоит зафиксировать: сначала InvalidValidationError — это ошибка кода, 500 и лог; потом ValidationErrors — 400 и конверт. Перепутанная ветка отправляет опечатку пользователя в алерты дежурного, а реальный баг кода — в молчаливый 400. Доставка повторяется в каждом хендлере — и ровно поэтому собирается в одну строчку: ответ уже готов, хендлер только отдаёт его фреймворку. Таблица ниже показывает, что именно видит клиент и откуда приходит каждое поле, — на ревью удобно сверять реальный ответ по ней.

### Что у клиента — откуда

| Поле в JSON | Откуда | Зачем клиенту |
| --- | --- | --- |
| error.code | константа проекта | стабильный машинный код для ветвления |
| error.message | константа проекта | общий текст без деталей |
| details[].field | Field() или Namespace() | какое поле подсветить в форме |
| details[].rule | Tag() | машинное правило: логи и аналитика |
| details[].param | Param() | граница правила: «8» у min=8 |
| details[].message | словарь тег → шаблон | человекочитаемый текст на языке клиента |

### 400 против 422: семантика по конвенции

Статус — часть формата. 400 говорит «запрос некорректен» и честно описывает ветку валидатора: теги не прошли, details заполнены по ValidationErrors. 422 — «запрос понят, но не принят по смыслу»: форма корректна, а правило домена — возраст, лимит, промокод — не сложилось; материал для details тот же, источник — доменные ошибки. С разметкой правил по слоям связан предыдущий урок: она подсказывает, какой статус отдавать. Выбор между 400 и 422 менее важен, чем его постоянство: клиент пишет разбор один раз, тесты сверяют код один раз. Итоговый [проект курса](/courses/validator/lessons/25-itogovyj-proekt/) собирает конвейер целиком: конверт, статусы, 409 на занятую почту — и тестирует ответы как контракт API. Слова споров здесь короткие: 400 закрывает синтаксис и формат, 422 — семантические проблемы — оба варианта легальны, и спор о выборе дешевле закрыть конвенцией, чем выиграть раз за разом в пулл-реквестах. В конвенции удобно записать и исключения: что 409 используется только для конфликтов состояния, а ошибки аутентификации вообще мимо этого конверта — у них свой код и свой разбор.

### Дисциплина формата одним списком

- одна функция BuildValidationResponse на весь проект: ValidationErrors → DTO
- details[].field — Field() для плоских структур, Namespace() для вложенных и dive
- details[].rule — Tag(): машинный контракт, текстом не заменяется
- details[].param — Param(): параметр тега строкой, «8» у min=8
- словарь тег → шаблон: незнакомый тег получает запасной текст
- теги и поля — в логи разработчику, сообщения — клиенту
- 400 — формат, 422 и 409 — смысл; конвенция фиксируется один раз
- ответы API тестируются как контракт: поле, тег, статус в таблице кейсов

> **Про песочницу.** HTTP-слой в песочнице не поднимется, но сборка конверта — чистая функция: её механика прогоняется в интерактивах урока. Если нужен полный цикл с Gin и Echo — запускайте локально: curl по вашим маршрутам покажет конверт быстрее, чем чтение, а тест контракта закрепит его в CI.

### Соберите разбор FieldError

Три пропуска: методы FieldError для поля, правила и параметра.

```go
for _, fe := range errs {
        out.Details = append(out.Details, FieldDetail{
                Field:   fe.___(), // имя поля клиента
                Rule:    fe.___(), // машинное имя правила
                Param:   fe.___(), // «8» у min=8
                Message: humanMessage(fe),
        })
}
```

Подсказка: Три метода FieldError: имя поля — Field, имя правила — Tag, параметр тега — Param.

Правильные ответы в пропусках: `Field`, `Tag`, `Param`

### Путь ValidationErrors до JSON

От ошибки валидатора до ответа клиенту.

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

1. `validate.Struct вернул ValidationErrors`
2. `InvalidValidationError отсечён отдельной веткой с 500`
3. `BuildValidationResponse собрал DTO по Field() и Tag()`
4. `Словарь подставил человекочитаемые сообщения`
5. `Поле, тег и параметр продублированы в логи`
6. `Gin отправил AbortWithStatusJSON(400, dto)`
7. `Echo разобрал ошибку в своём ErrorHandler тем же DTO`
8. `Клиент разобрал один формат на всех маршрутах`

### Проверка: Field против Namespace

**Вопрос:** В форме срез адресов: не прошли email у Users[0].Email и Users[1].Email. Что положить в details[].field, чтобы поля различались?

- ✅ Namespace(): полный путь Users[0].Email и Users[1].Email
- Field(): хватит Email, индекс не важен
- Индекс ошибки: 0 и 1
- Tag(): email в обоих случаях

Field() вернёт Email дважды, и подсветка формы не различит адресатов. Namespace() несёт полный путь с индексом. Тег один на оба элемента, а индекс ошибки зависит от порядка — это не имя поля.

### Карточки: единый формат ошибок

- **конверт ошибок** — error с машинным кодом и общим сообщением, ниже details по полям: field, rule, param, message. Один формат на все маршруты API.
- **Field() против Namespace()** — Field() — имя поля; для вложенных и dive различает не всё. Namespace() — полный путь Users[0].Email. Выбор фиксируется проектом.
- **Tag()** — Имя правила: min, eqfield, oneof. Машинный контракт: ключ словаря сообщений и поле для логов. Текст вместо тега — антипаттерн.
- **Param()** — Параметр тега строкой: «8» у min=8, имя соседнего поля у eqfield, варианты у oneof. Пустая строка, если параметра нет.
- **словарь тегов** — Карта Tag() → шаблон с подстановкой Param(). Незнакомый тег — запасной текст: формат не разваливается.
- **400 против 422** — 400 — теги не прошли, ValidationErrors. 422 — форма корректна, не сложился смысл домена. Постоянство выбора важнее номера.

Единый конверт — последний кирпич перед итоговым проектом: данные разобраны по Field(), Tag() и Param(), сообщения берутся из словаря, статус отделяет формат от смысла, а Gin и Echo доставляют один и тот же JSON. Проверьте свой проект на три симптома: хендлер, собирающий ошибку руками; field, не различающий два поля среза; правило на ревью, сверяющееся с текстом, а не с тегом. Каждый симптом чинится одной функцией и одним словарём. Отдельный аудит — логи: если в них нет тега и поля, дежурный восстанавливает картину по тексту сообщения, который меняется без предупреждения. Следующий урок собирает курс в итоговый проект: регистрационная форма от DTO и тегов до тестов и конверта 400 — всё, что разобрано за двадцать четыре урока, на одном экране.

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

- Единый формат: error с кодом и сообщением, ниже details по полям — контракт клиента.
- ValidationErrors — готовый материал: Field(), Tag(), Param() без выдумывания.
- Для вложенных и dive — Namespace(): Field() различит не всё.
- BuildValidationResponse — одна функция сборки, тестируется отдельно от фреймворка.
- Словарь тег → шаблон; незнакомый тег получает запасной текст, формат не разваливается.
- Теги и поля — в логи разработчику, сообщения — клиенту; аудит — по тегам, не по тексту.
- 400 — формат, 422 и 409 — смысл; единообразие важнее выбора номера.

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

Соберите конверт в своём проекте. Первое: опишите DTO с error.code, error.message и details по полям и функцию BuildValidationResponse, принимающую validator.ValidationErrors. Второе: заведите словарь тег → шаблон с подстановкой Param() и запасным текстом; проверьте его табличным тестом. Третье: пройдите по хендлерам и уберите ручную сборку ошибок — Gin получает AbortWithStatusJSON(400, dto), Echo — errorHandler с тем же DTO. Четвёртое: решите, где у проекта 400, а где 422 или 409, и зафиксируйте конвенцию рядом с описанием формата. Пятое: напишите тест контракта — поле, тег, статус — на двух разных маршрутах. Шестое: проверьте, что логи содержат тег и поле, а не только текст сообщения, — иначе аудит инцидентов сведётся к перечитыванию переписки поддержки.

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

### Почему field — это Field(), а не имя, вырезанное из Error()?

Error() склеивает всё в одну строку и зависит от нормализации и RegisterTagNameFunc — парсить её хрупко. FieldError отдаёт Field() и Namespace() отдельно, как и Tag(); конверт собирается из методов, а не из парсинга текста, поэтому формат не ломается от смены формулировок и версии библиотеки. Плюс парсинг строк — лишняя ветка в клиенте: чем меньше её код, тем меньше ревью.

### Что класть в details, если ошибку вернул не валидатор, а домен?

Тот же конверт, другой источник деталей: у доменной ошибки есть своё поле и код — ErrInsufficientFunds маппится в detail с полем суммы, правилом insufficient_funds и статусом 409 или 422. Формат один, фабрики деталей две: валидаторная и доменная. Клиент разбирает их одинаково, а тест контракта покрывает обе фабрики одной таблицей кейсов.

### Нужно ли класть русские сообщения в сам валидатор через RegisterTagNameFunc?

RegisterTagNameFunc меняет имена полей в ошибках — удобно для текста, но тогда логи и разбор видят переименованные поля, и контракт усложняется. Надёжнее держать поля машинными, а локализацию в словаре по Tag(): тег остаётся контрактом, текст — отображением поверх него. Если проект всё же переименовывает поля, для машинного имени остаётся StructNamespace(): он несёт фактические имена полей без подстановок.
