Валидация входных данных в Go-сервисе: разбор на реальном примере
Сервис принимает JSON от клиента, маппит его в структуру — и молча кладёт в базу мусор. Разбираем, как выстроить валидацию в Go так, чтобы ошибки ловились на входе, а не при чтении данных через полгода.

Проблема: структура заполнена, но данные невалидны
Go строго типизирован, и это создаёт иллюзию безопасности. json.Decoder успешно заполнит поле Email string строкой "not-an-email", поле Age int — числом -5, а поле Role string — значением, которого нет ни в одном enum. Компилятор не поможет: формально тип совпал. Результат — мусор в базе, ошибки в бизнес-логике и баг-репорты, которые приходят не сразу, а когда данные уже размазаны по трём микросервисам.
Ниже — разбор подхода к валидации на примере типичного endpoint регистрации пользователя. Используем Go 1.22+ и библиотеку go-playground/validator/v10 — де-факто стандарт для struct-level валидации в Go.
Что валидируем и зачем
Допустим, сервис принимает POST-запрос с телом:
{
"email": "user@example.com",
"password": "s3cretPass!",
"age": 28,
"role": "editor"
}
Требования к данным:
email— обязательное, формат email;password— обязательное, от 8 до 64 символов;age— обязательное, от 18 до 150;role— обязательное, одно из значений:viewer,editor,admin.
Кажется тривиальным. Но дьявол в деталях: как вернуть клиенту понятную ошибку по каждому полю, как не размазать логику валидации по хендлерам, и что делать, когда бизнес-правила сложнее одного тега.
Шаг 1. Структура с тегами валидации
// request.go
package user
// CreateUserRequest описывает тело запроса на создание пользователя.
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=64"`
Age int `json:"age" validate:"required,gte=18,lte=150"`
Role string `json:"role" validate:"required,oneof=viewer editor admin"`
}
Тег validate — это не магия фреймворка, а контракт, который читается на уровне определения типа. Любой, кто открывает структуру, сразу видит ограничения. Это важно: валидация, спрятанная в глубине хендлера, неизбежно дублируется или забывается.
Шаг 2. Единый валидатор с человекочитаемыми ошибками
Главная боль go-playground/validator — ошибки приходят в виде validator.ValidationErrors, а внутри — имена полей из Go-структуры и названия тегов. Клиенту не нужно знать, что поле называется CreateUserRequest.Email и нарушен тег required. Ему нужно: "email": "поле обязательно".
// validation.go
package validation
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
)
// Validate — экземпляр валидатора, создаётся один раз при старте.
var Validate = validator.New(validator.WithRequiredStructEnabled())
// FieldError — ошибка одного поля, удобная для сериализации в JSON.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidateStruct валидирует структуру и возвращает срез понятных ошибок.
func ValidateStruct(s interface{}) []FieldError {
err := Validate.Struct(s)
if err == nil {
return nil
}
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
// Неожиданная ошибка — не глотаем, а пробрасываем как единственную.
return []FieldError{{Field: "_", Message: err.Error()}}
}
out := make([]FieldError, 0, len(ve))
for _, fe := range ve {
out = append(out, FieldError{
Field: fe.Field(),
Message: msgForTag(fe),
})
}
return out
}
func msgForTag(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "поле обязательно"
case "email":
return "некорректный формат email"
case "min":
return fmt.Sprintf("минимальная длина — %s", fe.Param())
case "max":
return fmt.Sprintf("максимальная длина — %s", fe.Param())
case "gte":
return fmt.Sprintf("значение должно быть не меньше %s", fe.Param())
case "lte":
return fmt.Sprintf("значение должно быть не больше %s", fe.Param())
case "oneof":
return fmt.Sprintf("допустимые значения: %s", fe.Param())
default:
return fmt.Sprintf("не прошло проверку: %s", fe.Tag())
}
}
Функция msgForTag — точка, где вы управляете языком ошибок. Хотите i18n — замените switch на lookup по тегу и локали. Но начинать с switch проще: его легко читать и тестировать.
Почему один экземпляр валидатора
validator.New() внутри кэширует рефлексию для каждого типа. Создавать его на каждый запрос — бессмысленный расход. Один Validate на приложение, создаётся при старте.
Шаг 3. Хендлер, который не молчит
// handler.go
package user
import (
"encoding/json"
"net/http"
"yourmodule/validation"
)
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
// Декодируем JSON. Лимитируем тело — защита от слишком большого payload.
r.Body = http.MaxBytesReader(w, r.Body, 1_048_576) // 1 МБ
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "невалидный JSON",
})
return
}
// Валидация.
if errs := validation.ValidateStruct(req); errs != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity) // 422
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": errs,
})
return
}
// Здесь бизнес-логика: сохранение, хеширование пароля и т.д.
w.WriteHeader(http.StatusCreated)
}
Клиент получает ответ вроде:
{
"errors": [
{"field": "Email", "message": "некорректный формат email"},
{"field": "Age", "message": "значение должно быть не меньше 18"}
]
}
Все ошибки — за один запрос, а не по одной. Это важно для фронтенда: пользователь видит полную картину, а не играет в "угадай следующее поле".
Валидация — это не слой "для порядка". Это граница доверия: всё, что прошло валидацию, дальше обрабатывается без дополнительных проверок. Если граница дырявая, каждый слой ниже вынужден перестраховываться, и код превращается в лапшу из if-ов.
Когда тегов не хватает: кастомные валидаторы
Теги покрывают 80% случаев. Оставшиеся 20% — бизнес-правила, которые не укладываются в min/max/oneof. Например: пароль должен содержать хотя бы одну цифру и одну заглавную букву.
// custom.go
package validation
import (
"unicode"
"github.com/go-playground/validator/v10"
)
func init() {
// Регистрируем кастомный тег "strongpass".
Validate.RegisterValidation("strongpass", func(fl validator.FieldLevel) bool {
password := fl.Field().String()
var hasUpper, hasDigit bool
for _, ch := range password {
if unicode.IsUpper(ch) {
hasUpper = true
}
if unicode.IsDigit(ch) {
hasDigit = true
}
}
return hasUpper && hasDigit
})
}
Тег в структуре меняется:
Password string `json:"password" validate:"required,min=8,max=64,strongpass"`
И добавляем сообщение в msgForTag:
case "strongpass":
return "пароль должен содержать заглавную букву и цифру"
Кастомный валидатор регистрируется один раз. Он такой же тег, как email или min, — комбинируется с другими через запятую.
Сравнение подходов к валидации
| Подход | Плюсы | Минусы | Когда подходит |
|---|---|---|---|
Теги go-playground/validator | Декларативно, рядом с типом; кэш рефлексии | Сложные правила неудобно выражать тегами | CRUD, типовые формы, API |
| Ручные проверки в хендлере | Полный контроль, нет внешних зависимостей | Дублирование, легко забыть поле | Одно-два поля, прототипы |
| Валидация по JSON Schema | Контракт разделяется с фронтендом | Тяжело интегрировать с Go-типами; парсинг схемы на каждый запрос | API-first дизайн с кодогенерацией |
Protobuf + protovalidate | Валидация на уровне контракта, gRPC-нативно | Не подходит для REST без прослойки | gRPC-сервисы |
Где подход ломается
Межполевая зависимость
"Если роль — admin, поле department обязательно." Тег required_if в go-playground/validator существует, но при трёх-четырёх таких связях строка тегов становится нечитаемой. В этот момент лучше написать метод Validate() []FieldError на самой структуре и вызывать его после struct-level валидации.
Вложенные структуры и срезы
Тег dive позволяет валидировать элементы среза: validate:"required,dive,gte=1". Но если элемент среза — структура с собственными тегами, ошибки приходят с составными именами полей (Items[0].Name), и маппинг в человекочитаемое сообщение требует дополнительной логики. Не пытайтесь покрыть это универсальным хелпером на все случаи — пишите маппинг под конкретный endpoint.
Валидация ≠ санитизация
Валидатор скажет, что email формально корректен, но не обрежет пробелы по краям и не приведёт к нижнему регистру. Это отдельный шаг, и он идёт до валидации, а не после. Типичная ошибка — валидировать "как есть", а нормализовать позже, когда данные уже сохранены.
Struct-level валидация* — подход, при котором правила проверки привязываются к полям Go-структуры через теги или методы, а не проверяются вручную в каждом хендлере.
Что делать дальше
- Добавьте слой нормализации перед валидацией: trim, lowercase для email, удаление невидимых Unicode-символов.
- Напишите unit-тесты на валидацию отдельно от хендлеров —
ValidateStructпринимает любую структуру, тестировать его тривиально через табличные тесты. - Проверьте, что ваш хендлер ограничивает размер тела запроса (
http.MaxBytesReader). Без этого валидация не спасёт от payload в гигабайт. - Если API растёт, вынесите маппинг ошибок в middleware: decode → validate → handler. Каждый хендлер получает уже валидированную структуру.
- Замерьте аллокации на горячем пути:
go test -bench=. -benchmem. Валидатор использует рефлексию, и на тысячах запросов в секунду это может стать заметным — но обычно кэш типов вvalidatorделает это приемлемым.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: go-playground/validator — официальный репозиторий и документация, Go стандартная библиотека — encoding/json
Частые вопросы
Чем 422 Unprocessable Entity лучше 400 Bad Request для ошибок валидации?
Обязательно ли использовать go-playground/validator, или можно обойтись ручными проверками?
Что будет, если клиент пришлёт поле, которого нет в структуре?
Как валидировать query-параметры, а не JSON-тело?
Не тормозит ли рефлексия в валидаторе на высоконагруженном сервисе?
Что делать, если нужна валидация, зависящая от данных в базе (например, email уникален)?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.