---
title: "Echo, chi и свой Validation-хук"
description: "echo validation go: интерфейс из одного метода Validate, конвейер bind → normalize → validate и chi валидация через decodeAndValidate. Подменяем хук своим."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/13-validator-i-echo/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Echo, chi и свой Validation-хук

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

Echo validation go начинается с честного признания фреймворка: валидатора в коробке нет — есть интерфейс из одного метода, который вы наполняете сами, а chi валидация и вовсе начинается после декодера, потому что chi это роутер. Подключим validator v10 к обоим, соберём явный конвейер bind → normalize → validate и сделаем свой Validation-хук, в котором живут нормализация, StructLevel и конверт ошибок.

Gin из прошлого урока спрятал валидацию в один вызов и переключил словарь на binding. Echo и chi выбирают противоположную философию: ничего не прятать. Echo даёт точку подключения — интерфейс echo.Validator из одного метода — и честно оставляет вам решение, чем проверять; chi не даёт даже её, потому что он роутер поверх net/http и про тела запросов ничего не знает. Для validator v10 это означает приятную новость: здесь он работает без единой правки — родной словарь validate, родные ValidationErrors, родные структурные правила. Различие между фреймворками сводится к тому, кто и когда зовёт validate.Struct: в Echo — ваш Validation-хук из хендлера, в chi — ваш же код после декодера. Именно явность делает оба варианта прозрачнее gin-овского: когда запрос провален, вы точно видите строку, где это случилось.

### echo validation go: интерфейс из одного метода

Echo объявляет тип type Validator interface { Validate(i any) error } — один метод, и весь контракт: примите что угодно, верните ошибку, если данные плохи. В старых релизах v4 сигнатура встречается с interface{} — это тот же тип под старым именем, компилятор вашу реализацию примет в обоих написаниях. Реализация поверх validator v10 — считанные строки: структура с полем *validator.Validate и метод Validate, который вызывает Struct(i). Назначение — присваиванием: e.Validator = &CustomValidator{validate: validator.New()}, и с этого момента у каждого echo.Context есть рабочая c.Validate(i). Присваивайте сразу после e := echo.New(), до регистрации маршрутов: поле ничем не защищено, а влияние на живом сервере — гонка. Здесь же, в конструкторе валидатора, пропишите регистрации из десятого и одиннадцатого уроков — свои теги и структурные правила — один раз, при старте. Проверка структуры идёт по тегам validate: словарь никто не переключал, и структуры, наработанные в чистых v10-проектах, переезжают в Echo без правки. Контракт Validate(i) на вашей стороне: валидатор ждёт указатель на структуру — за ним идут правила; передать значение-не-структуру можно, но Struct ответит InvalidValidationError из пятого урока, и это ошибка программиста, а не данных. Удобный шаблон — отдельный пакет валидации: конструктор собирает validator.New(), выполняет регистрации и возвращает готовую реализацию echo.Validator; в main остаётся одна строка присваивания, а тесты собирают тот же пакет без HTTP.

### Свой Validation-хук и хендлер на Echo

```go
type CustomValidator struct {
        validate *validator.Validate
}

func (cv *CustomValidator) Validate(i any) error {
        return cv.validate.Struct(i)
}

func main() {
        e := echo.New()
        e.Validator = &CustomValidator{validate: validator.New()}
        // ...
}

func createTodo(c echo.Context) error {
        var in TodoInput
        if err := c.Bind(&in); err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
        }
        if err := c.Validate(&in); err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, err.Error())
        }
        return c.JSON(http.StatusCreated, in)
}
```

CustomValidator реализует echo.Validator автоматически: компилятор сверит сигнатуру Validate. В хендлере — явные два шага: c.Bind раскладывает тело и вообще не смотрит на правила, c.Validate зовёт ваш хук. Ошибка валидации оборачивается в echo.NewHTTPError 400 — это *echo.HTTPError, и дефолтный обработчик доставит её клиенту.

### Проверка: Validate без валидатора

**Вопрос:** e := echo.New() создан, но e.Validator никто не назначал. Что вернёт c.Validate(&in) на запросе?

- ✅ ErrValidatorNotRegistered — ошибку из errors.New, не *echo.HTTPError
- nil: валидация просто пропускается
- Панику нулевого указателя внутри Echo
- echo.NewHTTPError 400 с сообщением о настройке

c.Validate смотрит поле e.Validator и без него возвращает ErrValidatorNotRegistered с текстом validator not registered. Это обычная ошибка, не HTTPError: дефолтный обработчик отвечает на неё 500 с Internal Server Error. Симптом — все запросы пятисотые после деплоя, диагноз — строка в логе; лечится присваиванием e.Validator на старте.

### Конвейер bind → normalize → validate

Явность Echo расставляет роли по местам. c.Bind — раскладка: path-параметры, query, тело по Content-Type; на правила она не смотрит вовсе, и читаемый JSON с мусорными значениями развяжется без единой ошибки. c.Validate — правила: то, что вы назначили в e.Validator. Склейка — три строки вашего хендлера, и между ними есть место, которого нет в gin: нормализация. TrimSpace перед required, lower-case перед уникальностью — всё это делается между bind и validate, и конвейер bind → normalize → validate становится контрактом проекта: нормализуй, потом проверяй, потому что правила отработавшие на грязных данных бесполезны. Обратная сторона явности — забытый шаг: пропустили c.Validate — компилятор молчит, тест с валидным телом зелёный, а мусор едет в базу до первого ручного теста. Лечение дисциплинное: единый шаблон хендлера в команде, код-ревью на строку c.Validate и тест с битым телом на каждый эндпоинт — тесты правил из девятнадцатого урока здесь закрывают дыру. Ошибки двух шагов разводите с самого начала: c.Bind вернул ошибку — это проблемы транспорта, и сообщение не должно намекать на поля; c.Validate вернул ValidationErrors — это данные, и клиенту нужны детали по полям. Один и тот же echo.NewHTTPError(http.StatusBadRequest, err.Error()) на оба случая — самая частая небрежность Echo-проектов: она прячет тип ошибки и лишает фронтенд различия между «не смог прочитать» и «не прошёл проверку». Как именно устроена нормализация и почему её место именно там — отдельный сюжет пятнадцатого урока.

### chi валидация: хелпер decodeAndValidate

chi не навязывает даже bind: тело запроса читает кто угодно и как угодно, обычно json.NewDecoder прямо в хендлере. chi валидация поэтому выглядит как функция-хелпер: декодировали, проверили, вернули ошибку — и каждый хендлер зовёт её одной строкой. Тело http.Request — io.ReadCloser, который читается один раз, поэтому валидация не может жить в middleware до хендлера: middleware, прочитавший Body, оставит хендлер с пустым потоком. Декодирует тот, кто владеет данными, — хендлер, — и проверка структурой естественным образом живёт рядом. Ошибки — без сюрпризов: json-декодер даёт свои ошибки, validate.Struct — ValidationErrors, и errors.As разводит их на две ветки: детали по полям или честное «битое тело». Экземпляр валидатора — пакетная переменная или поле сервиса: ровно один на приложение, регистрации на старте. Перед декодом полезно ограничить размер тела: http.MaxBytesReader(w, r.Body, n) ставит потолок байт и возвращает ошибку при превышении — без него один гигантский POST способен занять память процесса. Обёртке нужен ResponseWriter, поэтому лимит ставят в начале хендлера; превышение приедет обычной ошибкой чтения в общую ветку «битое тело». Если хочется одинаковости с Echo, хелпер decodeAndValidate становится вашей реализацией того же Validation-хука — интерфейс чужой, идея общая.

### chi: хелпер и хендлер

```go
var validate = validator.New()

func decodeAndValidate(r *http.Request, dst any) error {
        if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
                return fmt.Errorf("битое тело: %w", err)
        }
        return validate.Struct(dst)
}

func createTask(w http.ResponseWriter, r *http.Request) {
        var in TaskInput
        if err := decodeAndValidate(r, &in); err != nil {
                var verrs validator.ValidationErrors
                if errors.As(err, &verrs) {
                        respondJSON(w, http.StatusBadRequest, fieldMap(verrs))
                        return
                }
                respondJSON(w, http.StatusBadRequest,
                        map[string]string{"error": err.Error()})
                return
        }
        // in проверен
        respondJSON(w, http.StatusCreated, in)
}
```

Хелпер прячет две ветки источника: декодер и движок. Обёртка fmt.Errorf с %w сохраняет исходную ошибку декодера для логов. ValidationErrors приедут из validate.Struct и разойдутся errors.As-ом: поля — клиенту, прочее — общая фраза. Хендлер остаётся плоским: один вызов, две ветки, дальше бизнес.

### Что уедет в базу

```go
func createTodo(c echo.Context) error {
        var in TodoInput // Title string, validate:"required,max=140"
        if err := c.Bind(&in); err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
        }
        // строку c.Validate(&in) кто-то удалил при рефакторинге
        return c.JSON(http.StatusCreated, in)
}
// тело запроса: {"title": ""}
```

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

- ✅ 201 Created: пустой title сохранён — c.Bind не валидирует
- 400: битое тело — c.Bind отверг пустую строку
- 500: ErrValidatorNotRegistered уйдёт в дефолтный обработчик
- 201, но title сохранён как null после нормализации

c.Bind при читаемом JSON возвращает nil независимо от содержимого: раскладка про раскладку. Правила проверяет только c.Validate, которого нет — значит 201 и пустой заголовок в хранилище. Тест с битым телом поймал бы это на код-ревью: у каждого эндпоинта с валидацией должен быть тест на мусор.

### Gin, Echo, chi: кто и как валидирует

| Фреймворк | Кто валидирует | Словарь тегов | Точка решения |
| --- | --- | --- | --- |
| Gin | встроенный движок за ShouldBind | binding | ваш хендлер ставит статус на err |
| Echo | ваш echo.Validator через c.Validate | validate | ваш хендлер оборачивает err в HTTPError |
| chi | ваш код после декодера | validate | ваш хелпер decodeAndValidate |

### 400 против 422: где ставить статус

Явные конвейеры возвращают вам право выбора статуса, и тут же возникает вечный спор: 400 Bad Request или 422 Unprocessable Content. Логика 422 красивая: тело синтаксически разобралось, семантика провалилась. Но по факту большинство клиентского кода, документаций и прокси привыкло к 400, а детализация по полям и так отличает валидационный отказ от битого JSON. Историческая справка для спора: 422 придумали в WebDAV (RFC 4918) как Unprocessable Entity, позже её переварили общие спецификации HTTP — в RFC 9110 она называется Unprocessable Content. Статус законный, но не обязательный: 400 тоже покрывает синтаксически корректный, но бессмысленный запрос, так что выбор — конвенция команды, а не требование стандарта. Моё правило: выберите один статус для всех валидационных отказов проекта, пропишите его в константе и не спорьте второй раз — консистентность важнее каноничности. В Echo решение живёт в месте оборачивания ошибки — лучше всего прямо в CustomValidator, чтобы хендлеры вообще не думали о статусах; в chi — в хелпере; в gin — в respondFields. Единый формат ошибок API с кодами и деталями — тема двадцать четвёртого урока, а здесь зафиксируйте принцип: одна точка, где ValidationErrors становятся ответом, и один статус на проект.

### Проверка: почему не middleware

**Вопрос:** Почему chi-валидацию не выносят в middleware до хендлера?

- ✅ Тело нужно сначала декодировать в структуру, а Body читается один раз
- chi запрещает middleware, обращающиеся к запросу
- Валидация в middleware невозможна технически ни в каком виде
- json.NewDecoder не работает вне хендлеров

Проверять нечего, пока данные не развязаны в структуру — декодирует их хендлер. Middleware, прочитавший Body раньше, оставит хендлер с исчерпанным потоком. Возможен гибрид с буферизацией тела, но это усложнение ради слабого выигрыша: одна строка decodeAndValidate в хендлере дешевле и понятнее.

> **Живое место: пятисотые после деплоя.** Рефакторинг main: e.Validator = ... перенесли в ветку, которая исполняется по флагу конфига, — и на продукте все POST-эндпоинты начали отвечать 500. В логе одна и та же строка: validator not registered; статус пятисотый, потому что ErrValidatorNotRegistered — не HTTPError, а дефолтный обработчик Echo считает такие ошибки аварией сервера. Тесты не поймали: они собирают приложение собственной функцией, где присваивание на месте. Мораль: присваивание e.Validator — строка уровня e := echo.New(), а smoke-тест на 201 с валидным телом обязан жить в CI.

> **Один экземпляр на всё приложение.** Echo, chi и фоновые джобы должны делить один validator.New(): кэш структур, свои теги и StructLevel живут на экземпляре. Пакетная переменная или зависимость через конструктор — оба пути рабочие, лишь бы регистрации случались один раз на старте. Подробно о цене второго экземпляра — четырнадцатый урок.

### Единый хук: StructLevel и сообщения внутри Validate

Соберём три урока в одну точку. Ваш CustomValidator Validate не обязан быть однострочником: это законное место для нормализации перед проверкой, для StructPartial в PATCH-обновлениях, для конверта ошибок из каталога шестого урока. Структурные правила одиннадцатого урока регистрируются на том же экземпляре — и passwordmatch автоматически заработает в c.Validate без единого изменения хендлеров. Свои теги RegisterValidation — туда же. В chi всё это живёт в decodeAndValidate или в вашем типе с методом Validate, который хендлеры дергают после декодера: интерфейс другой, наполнение то же. Так проект получает то, что gin дал из коробки, но прозрачнее: bind и validate разделены, нормализация имеет своё место, а правила — одни и те же в HTTP-хендлерах, фоновых задачах и тестах. Сам хук легко тестировать без сервера: соберите CustomValidator с реальным движком и прогоните валидные и битые структуры через Validate — это обычные юнит-тесты; в httptest остаётся проверить, что хендлер правильно оборачивает ошибки. И да, это осознанный повтор формулы: одна библиотека, один экземпляр, один словарь тегов — три фреймворка перестают быть тремя мирами валидации.

### Чек-лист Echo и chi одним списком

- e.Validator присваивается сразу после echo.New(), до маршрутов
- c.Validate без назначенного валидатора — ErrValidatorNotRegistered и 500, а не 400
- c.Bind раскладку делает, правила не проверяет: явный вызов c.Validate обязателен
- конвейер хендлера: bind → normalize → validate, порядок — часть контракта
- chi: json.NewDecoder в хендлере или хелпер decodeAndValidate, middleware до декодера бессилен
- ошибки: ValidationErrors через errors.As, остальное — битое тело без деталей
- статус валидационного отказа — одна точка решения, один на проект
- в хук же: нормализация, StructPartial, каталог сообщений и регистрации на старте

> **Проверка без запуска порта.** Оба конвейера — Echo и chi — проверяются без прослушивания сокета: httptest.NewServer или вызов ServeHTTP на голом роутере дают реальный биндинг и декод, а валидатор в них тот же. Нормализацию и StructLevel тестируйте и вовсе напрямую — они не знают про HTTP. Единственное, что не поймать юнит-тестом, — что e.Validator вообще назначен: для этого и живёт smoke-тест на реальном экземпляре приложения.

### Соберите подключение к Echo

Четыре пропуска: поле, метод интерфейса, вызов движка и статус ошибки.

```go
type CustomValidator struct {
        validate *validator.Validate
}

func (cv *CustomValidator) ___(i any) error {
        return cv.validate.___(i)
}

// в main:
e.___ = &CustomValidator{validate: validator.New()}

// в хендлере:
if err := c.Validate(&in); err != nil {
        return echo.NewHTTPError(http.___, "проверьте поля")
}
```

Подсказка: Метод интерфейса echo.Validator — Validate; проверка структуры — Struct; назначение — e.Validator; статус валидационного отказа — 400.

Правильные ответы в пропусках: `Validate`, `Struct`, `Validator`, `StatusBadRequest`

### Соберите конвейер Echo

POST /todos с битым заголовком. Расставьте шаги по порядку.

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

1. `e.Validator назначен при старте приложения`
2. `Приходит POST с телом JSON`
3. `c.Bind раскладывает тело в TodoInput`
4. `in.Normalize() триммит заголовок`
5. `c.Validate зовёт ваш CustomValidator`
6. `validate.Struct исполняет тег required`
7. `ValidationErrors оборачиваются в echo.NewHTTPError 400`
8. `Дефолтный обработчик Echo доставит конверт клиенту`

### Карточки: Echo и chi

- **echo.Validator** — Интерфейс из одного метода Validate(i any) error. Назначается присваиванием e.Validator; реализация поверх v10 — метод, зовущий validate.Struct.
- **ErrValidatorNotRegistered** — Ошибка c.Validate без назначенного e.Validator. Не HTTPError: дефолтный обработчик отвечает 500. Диагноз в логе — validator not registered.
- **c.Bind против c.Validate** — Bind раскладывает path, query и тело, правил не смотрит. Validate запускает ваш хук. Забытый Validate — мусор в базе при статусе 201.
- **decodeAndValidate** — Хелпер для chi: json.NewDecoder.Decode + validate.Struct. Две ветки ошибок — декодер и ValidationErrors через errors.As.
- **почему не middleware** — Body — io.ReadCloser, читается один раз; декодирует хендлер. Валидация живёт рядом с декодером, а не до него.
- **Validation-хук** — Ваш тип с Validate: нормализация, StructPartial, каталог сообщений и регистрации. Тот же экземпляр v10, что и фоновые задачи, — один словарь тегов.

Следующий урок — [один экземпляр на приложение](/courses/validator/lessons/14-odin-ekzemplar/): что именно кэширует validator.New(), почему регистрации не потокобезопасны и во что обходится второй экземпляр. Механика ShouldBind из gin-мира — в [двенадцатом уроке](/courses/validator/lessons/12-validator-i-gin/), сравнение конвейеров — в [курсе Echo](/courses/echo/) и [курсе chi](/courses/chi/). Примеры урока запускаются локально: Echo, chi и validator — внешние модули, песочница сайта работает только с чистым Go, поэтому конвейер гоняйте go run-ом с httptest или curl-ом.

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

- Echo даёт интерфейс echo.Validator из одного метода Validate; реализация поверх v10 назначается e.Validator на старте.
- c.Validate без валидатора возвращает ErrValidatorNotRegistered — дефолтный обработчик отвечает 500, а не 400.
- c.Bind не валидирует: явный конвейер bind → normalize → validate — контракт каждого хендлера.
- chi — роутер: декодирует хендлер, поэтому валидация — хелпер decodeAndValidate рядом с декодером, не middleware.
- Один экземпляр v10 на приложение: свои теги, StructLevel и каталог сообщений живут в вашем Validation-хуке.
- Статус валидационного отказа — одна точка решения: в хуке или хелпере, один на проект.
- Словарь здесь родной validate: структуры из чистых v10-проектов переезжают без правки тегов.

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

Поднимите мини-сервис на Echo и тот же на chi: POST /tasks со структурой Title validate:"required,min=3" и Priority validate:"required,gte=1,lte=5". В Echo напишите CustomValidator с нормализацией заголовка внутри Validate, в chi — хелпер decodeAndValidate. Добавьте smoke-тест на 201 и тест на пустой title с ожиданием 400, затем удалите строку c.Validate и убедитесь, что тест поймал регресс. Сверьте, что оба сервиса отдают одинаковый формат ошибок. Затем поставьте http.MaxBytesReader с лимитом в один мегабайт и прогоните тест с телом вдвое больше: убедитесь, что ответ — честный 400, а не зависание или 500.

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

### Почему Echo не встраивает валидатор, как Gin?

Это осознанный минимализм: Echo даёт точку подключения — интерфейс из одного метода — и не хочет решать за вас, чем проверять. Стоимость — явный вызов c.Validate и назначение e.Validator; выигрыш — свой словарь тегов, своя нормализация и свой конверт ошибок без борьбы с чужими умолчаниями. Из того же решения растёт и цена: Echo не может гарантировать, что валидация вообще случилась, — гарантия на совести команды, и smoke-тест из живого места здесь не перестраховка, а минимум.

### Можно ли провалидировать тело в Echo-биндере, чтобы не звать c.Validate?

Можно написать свой Binder, который после раскладки вызывает Validate, — Echo позволяет подменить и его. Но это снова прятание конвейера: код-ревью сложнее увидеть пропущенную проверку, а нормализация теряет своё место. Практика устойчивее: явные три строки bind, normalize, validate в каждом хендлере.

### Как в chi разделить ошибки декодера и валидации?

По типу: json.NewDecoder вернёт свои ошибки (синтаксис, типы), validate.Struct — validator.ValidationErrors. В decodeAndValidate поставьте errors.As: ветка ValidationErrors отдаёт детали по полям, остальное — общая фраза «битое тело». Обёртка с %w сохранит исходную ошибку декодера для логов. Полезный второй слой — журнал: полное err.Error() от ValidationErrors в лог сервера и короткий конверт клиенту; связка выручает, когда через полгода приходит тикет «не принимает форму», а воспроизвести точное тело уже поздно.
