Валидация и трансформация данных в Go: построение надёжного слоя ввода для REST API без фреймворков
Введение: почему валидация — это не просто проверка полей
Валидация входящих данных — одна из тех задач, которую разработчики недооценивают до первого серьёзного инцидента. Кажется, что это просто: проверить, что email не пустой и содержит @. Но на практике плохо спроектированный слой валидации разрушает архитектуру: бизнес-логика смешивается с парсингом HTTP, доменные объекты принимают невалидные состояния, а ошибки клиенту возвращаются в разных форматах в зависимости от того, какой разработчик писал конкретный хендлер.
В экосистеме Go принято строить REST API без тяжёлых фреймворков — и это правильно. Стандартная библиотека net/http даёт всё необходимое. Но именно в этом контексте архитектурные решения по валидации приходится принимать самостоятельно. В этой статье мы разберём, как построить надёжный, тестируемый и поддерживаемый слой валидации и трансформации данных для REST API на Go.
Обзор подходов к валидации в Go
Ручная валидация
Самый простой подход — писать проверки вручную в теле хендлера или метода. Плюсы: полный контроль, нет зависимостей, легко читать. Минусы: дублирование кода, сложно собрать все ошибки за один проход, правила рассыпаются по кодовой базе.
Теги структур (struct tags)
Подход, популярный в мире Go: валидационные правила записываются в теги полей структуры. Удобно для простых случаев, но теги быстро становятся нечитаемыми, не поддерживают сложную условную логику и привязывают правила валидации к структуре данных, нарушая separation of concerns.
Библиотека go-playground/validator
Наиболее популярное решение в экосистеме. Поддерживает теги, кастомные валидаторы, локализацию. Плюсы: богатый набор встроенных правил, активное сообщество. Минусы: отражение (reflection) под капотом влияет на производительность, API бывает неочевидным, а кастомные правила через RegisterValidation сложно тестировать изолированно. Для highload-сервисов стоит взвешивать этот компромисс.
В этой статье мы реализуем собственный лёгкий слой валидации, при необходимости показывая, где его можно заменить или дополнить go-playground/validator.
Проектирование слоя валидации: где живут правила
Ключевой архитектурный вопрос: где должна жить валидация? Ответ зависит от типа проверки:
- Синтаксическая валидация (формат email, длина строки, обязательные поля) — уровень хендлера или middleware. Это «входные ворота» — сюда не должны попадать структурно некорректные данные.
- Семантическая валидация (уникальность email в базе, бизнес-ограничения) — уровень usecase/сервиса. Здесь есть доступ к репозиториям и бизнес-контексту.
- Инварианты доменной модели (например, сумма заказа не может быть отрицательной) — уровень домена, в конструкторах или фабричных методах.
Смешивание этих уровней — главная причина, по которой валидация ломает архитектуру. Хендлер не должен знать о бизнес-правилах, а доменный объект не должен парсить HTTP-запросы.
Реализация кастомного валидатора
Определим интерфейс и базовые примитивы. Идея: валидатор собирает все ошибки за один проход и возвращает их списком, а не останавливается на первой.
// validation/validator.go
package validation
import (
"fmt"
"net/mail"
"regexp"
"strings"
)
// FieldError описывает ошибку конкретного поля.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
Code string `json:"code"`
}
func (e FieldError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors — список ошибок валидации.
type ValidationErrors []FieldError
func (ve ValidationErrors) Error() string {
msgs := make([]string, len(ve))
for i, e := range ve {
msgs[i] = e.Error()
}
return strings.Join(msgs, "; ")
}
// Rule — функция-правило: принимает значение поля, возвращает ошибку или nil.
type Rule func(field, value string) *FieldError
// FieldValidator накапливает правила для одного поля.
type FieldValidator struct {
field string
value string
rules []Rule
}
// Validator — корневой объект, содержит наборы правил для всех полей.
type Validator struct {
fields []*FieldValidator
}
// Field начинает цепочку правил для поля.
func (v *Validator) Field(field, value string) *FieldValidator {
fv := &FieldValidator{field: field, value: value}
v.fields = append(v.fields, fv)
return fv
}
// Required проверяет, что поле не пустое.
func (fv *FieldValidator) Required() *FieldValidator {
fv.rules = append(fv.rules, func(field, value string) *FieldError {
if strings.TrimSpace(value) == "" {
return &FieldError{Field: field, Message: "обязательное поле", Code: "required"}
}
return nil
})
return fv
}
// MinLen проверяет минимальную длину строки.
func (fv *FieldValidator) MinLen(min int) *FieldValidator {
fv.rules = append(fv.rules, func(field, value string) *FieldError {
if len([]rune(value)) < min {
return &FieldError{
Field: field,
Message: fmt.Sprintf("минимальная длина: %d символов", min),
Code: "min_length",
}
}
return nil
})
return fv
}
// MaxLen проверяет максимальную длину строки.
func (fv *FieldValidator) MaxLen(max int) *FieldValidator {
fv.rules = append(fv.rules, func(field, value string) *FieldError {
if len([]rune(value)) > max {
return &FieldError{
Field: field,
Message: fmt.Sprintf("максимальная длина: %d символов", max),
Code: "max_length",
}
}
return nil
})
return fv
}
// Email проверяет формат email-адреса.
func (fv *FieldValidator) Email() *FieldValidator {
fv.rules = append(fv.rules, func(field, value string) *FieldError {
_, err := mail.ParseAddress(value)
if err != nil {
return &FieldError{Field: field, Message: "некорректный email", Code: "invalid_email"}
}
return nil
})
return fv
}
// Matches проверяет соответствие регулярному выражению.
func (fv *FieldValidator) Matches(pattern, code, message string) *FieldValidator {
re := regexp.MustCompile(pattern)
fv.rules = append(fv.rules, func(field, value string) *FieldError {
if !re.MatchString(value) {
return &FieldError{Field: field, Message: message, Code: code}
}
return nil
})
return fv
}
// Validate запускает все правила и возвращает список ошибок.
func (v *Validator) Validate() ValidationErrors {
var errs ValidationErrors
for _, fv := range v.fields {
for _, rule := range fv.rules {
if err := rule(fv.field, fv.value); err != nil {
errs = append(errs, *err)
// Продолжаем — собираем все ошибки поля
}
}
}
if len(errs) == 0 {
return nil
}
return errs
}
Такой дизайн даёт fluent API, легко читается и тестируется. Каждое правило — чистая функция без побочных эффектов.
Трансформация данных: DTO-паттерн и маппинг в доменные объекты
DTO (Data Transfer Object) — структура, которая описывает форму входящего запроса. Она не является доменным объектом и не содержит бизнес-логики. После валидации DTO трансформируется в доменную модель.
// dto/user.go
package dto
import (
"strings"
"time"
"myapp/domain"
"myapp/validation"
)
// CreateUserRequest — DTO входящего запроса на создание пользователя.
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
// Validate выполняет синтаксическую валидацию DTO.
func (r *CreateUserRequest) Validate() validation.ValidationErrors {
v := &validation.Validator{}
v.Field("name", r.Name).Required().MinLen(2).MaxLen(100)
v.Field("email", r.Email).Required().Email()
v.Field("password", r.Password).Required().MinLen(8).MaxLen(72)
v.Field("role", r.Role).Required().Matches(
`^(admin|user|moderator)$`,
"invalid_role",
"допустимые роли: admin, user, moderator",
)
return v.Validate()
}
// ToDomain трансформирует DTO в доменный объект.
// Вызывается только после успешной валидации.
func (r *CreateUserRequest) ToDomain() domain.CreateUserParams {
return domain.CreateUserParams{
Name: strings.TrimSpace(r.Name),
Email: strings.ToLower(strings.TrimSpace(r.Email)),
Password: r.Password, // хеширование — на уровне usecase
Role: domain.Role(r.Role),
CreatedAt: time.Now().UTC(),
}
}
Важный принцип: метод ToDomain() вызывается только после успешной валидации. Он может делать нормализацию данных — strings.TrimSpace, приведение к нижнему регистру, конвертацию типов — но не бизнес-логику.
Обработка и возврат ошибок валидации: RFC 7807 Problem Details
RFC 7807 определяет стандартный формат ответа об ошибке для HTTP API. Использование этого стандарта делает API предсказуемым для клиентов и совместимым с OpenAPI-документацией.
// apierror/problem.go
package apierror
import (
"encoding/json"
"net/http"
"myapp/validation"
)
// ProblemDetail соответствует RFC 7807.
type ProblemDetail struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
}
// FieldError — поле в ответе об ошибке.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
Code string `json:"code"`
}
// WriteValidationError записывает ответ с ошибками валидации.
func WriteValidationError(w http.ResponseWriter, r *http.Request, errs validation.ValidationErrors) {
fields := make([]FieldError, len(errs))
for i, e := range errs {
fields[i] = FieldError{Field: e.Field, Message: e.Message, Code: e.Code}
}
problem := ProblemDetail{
Type: "https://api.example.com/errors/validation",
Title: "Validation Error",
Status: http.StatusUnprocessableEntity,
Detail: "Переданные данные содержат ошибки.",
Instance: r.RequestURI,
Errors: fields,
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(problem)
}
// WriteInternalError записывает ответ о внутренней ошибке.
func WriteInternalError(w http.ResponseWriter, r *http.Request) {
problem := ProblemDetail{
Type: "https://api.example.com/errors/internal",
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
Instance: r.RequestURI,
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(problem)
}
Пример ответа клиенту при ошибках валидации:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "Переданные данные содержат ошибки.",
"instance": "/api/v1/users",
"errors": [
{"field": "email", "message": "некорректный email", "code": "invalid_email"},
{"field": "password", "message": "минимальная длина: 8 символов", "code": "min_length"}
]
}
Локализация сообщений об ошибках
Если API обслуживает несколько локалей, сообщения об ошибках должны возвращаться на языке клиента. Простой подход — маппинг кодов ошибок на локализованные строки.
// validation/i18n.go
package validation
type Locale string
const (
LocaleRU Locale = "ru"
LocaleEN Locale = "en"
)
// messages содержит переводы по коду ошибки и локали.
var messages = map[Locale]map[string]string{
LocaleRU: {
"required": "обязательное поле",
"min_length": "значение слишком короткое",
"max_length": "значение слишком длинное",
"invalid_email": "некорректный адрес электронной почты",
},
LocaleEN: {
"required": "field is required",
"min_length": "value is too short",
"max_length": "value is too long",
"invalid_email": "invalid email address",
},
}
// Translate возвращает локализованное сообщение по коду ошибки.
func Translate(locale Locale, code string) string {
if msgs, ok := messages[locale]; ok {
if msg, ok := msgs[code]; ok {
return msg
}
}
// Фолбэк на английский
if msg, ok := messages[LocaleEN][code]; ok {
return msg
}
return code
}
Локаль определяется из заголовка Accept-Language и передаётся в хендлер через контекст запроса.
Тестирование слоя валидации
Один из главных плюсов описанного подхода — тестируемость. Валидаторы — чистые функции без зависимостей. Используем table-driven tests:
// validation/validator_test.go
package validation_test
import (
"testing"
"myapp/validation"
)
func TestEmailValidation(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
wantCode string
}{
{name: "valid email", email: "user@example.com", wantErr: false},
{name: "empty email", email: "", wantErr: true, wantCode: "required"},
{name: "no at sign", email: "notanemail", wantErr: true, wantCode: "invalid_email"},
{name: "no domain", email: "user@", wantErr: true, wantCode: "invalid_email"},
{name: "with spaces", email: "user @example.com", wantErr: true, wantCode: "invalid_email"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &validation.Validator{}
v.Field("email", tt.email).Required().Email()
errs := v.Validate()
if tt.wantErr && len(errs) == 0 {
t.Errorf("ожидалась ошибка, но валидация прошла успешно")
}
if !tt.wantErr && len(errs) > 0 {
t.Errorf("не ожидалась ошибка, но получено: %v", errs)
}
if tt.wantErr && len(errs) > 0 && tt.wantCode != "" {
found := false
for _, e := range errs {
if e.Code == tt.wantCode {
found = true
}
}
if !found {
t.Errorf("ожидался код %q, но в ошибках его нет: %v", tt.wantCode, errs)
}
}
})
}
}
// TestCreateUserRequestValidate тестирует DTO целиком.
func TestCreateUserRequestValidate(t *testing.T) {
tests := []struct {
name string
input dto.CreateUserRequest
wantFields []string // поля, в которых ожидаются ошибки
}{
{
name: "valid input",
input: dto.CreateUserRequest{
Name: "Ivan Petrov", Email: "ivan@example.com",
Password: "securePass1", Role: "user",
},
wantFields: nil,
},
{
name: "missing name and invalid role",
input: dto.CreateUserRequest{
Name: "", Email: "ivan@example.com",
Password: "securePass1", Role: "superadmin",
},
wantFields: []string{"name", "role"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := tt.input.Validate()
errFields := map[string]bool{}
for _, e := range errs {
errFields[e.Field] = true
}
for _, f := range tt.wantFields {
if !errFields[f] {
t.Errorf("ожидалась ошибка в поле %q, но её нет", f)
}
}
if len(tt.wantFields) == 0 && len(errs) > 0 {
t.Errorf("не ожидались ошибки, но получено: %v", errs)
}
})
}
}
Интеграция с middleware: автоматическая валидация на уровне роутера
Чтобы не дублировать код парсинга и валидации в каждом хендлере, вынесем это в middleware. Используем дженерики (Go 1.18+) для типобезопасного декодирования.
// middleware/validate.go
package middleware
import (
"context"
"encoding/json"
"net/http"
"myapp/apierror"
"myapp/validation"
)
// Validatable — интерфейс для DTO с методом валидации.
type Validatable interface {
Validate() validation.ValidationErrors
}
// contextKey — тип для ключей контекста, чтобы избежать коллизий.
type contextKey[T any] struct{}
// DecodeAndValidate — middleware-фабрика для декодирования и валидации тела запроса.
// Использует дженерики: T должен реализовывать Validatable.
func DecodeAndValidate[T Validatable](next func(http.ResponseWriter, *http.Request, T)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var dto T
// Ограничиваем размер тела запроса (защита от DoS)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // строгий режим
if err := decoder.Decode(&dto); err != nil {
apierror.WriteBadRequestError(w, r, "некорректный JSON")
return
}
if errs := dto.Validate(); errs != nil {
apierror.WriteValidationError(w, r, errs)
return
}
next(w, r, dto)
}
}
Пример использования в хендлере:
// handler/user.go
package handler
import (
"net/http"
"myapp/dto"
"myapp/middleware"
"myapp/usecase"
)
type UserHandler struct {
userUC usecase.UserUseCase
}
func (h *UserHandler) Register(mux *http.ServeMux) {
// middleware.DecodeAndValidate берёт на себя декодирование и валидацию
mux.HandleFunc("POST /api/v1/users",
middleware.DecodeAndValidate(h.createUser),
)
}
func (h *UserHandler) createUser(w http.ResponseWriter, r *http.Request, req dto.CreateUserRequest) {
// Здесь req уже валиден — можно безопасно трансформировать
params := req.ToDomain()
user, err := h.userUC.CreateUser(r.Context(), params)
if err != nil {
// Обработка бизнес-ошибок
return
}
w.WriteHeader(http.StatusCreated)
// ... сериализация ответа
_ = user
}
Такой подход полностью убирает boilerplate из хендлеров. Хендлер получает уже валидный DTO и занимается только своей задачей — оркестрацией бизнес-логики.
Заключение: чек-лист хорошего слоя валидации
Подводя итог, сформулируем ключевые признаки качественного слоя валидации в REST API на Go:
- Separation of concerns: синтаксическая валидация — в хендлере/middleware, семантическая — в usecase, инварианты — в домене.
- Все ошибки за один проход: клиент должен получить полный список проблем, а не исправлять их по одной.
- DTO отделён от доменного объекта: трансформация происходит явно через метод
ToDomain()только после валидации. - Стандартизированный формат ошибок: RFC 7807 делает API предсказуемым для всех потребителей.
- Локализация через коды ошибок: не хардкодьте строки в правилах — используйте коды и переводите их отдельно.
- 100% покрытие валидаторов тестами: каждое правило тестируется изолированно с table-driven подходом.
- Middleware убирает boilerplate: хендлер не занимается парсингом и валидацией — только логикой.
- Ограничение размера тела запроса:
http.MaxBytesReaderобязателен как минимальная защита. - Минимальные зависимости: стандартная библиотека покрывает большинство потребностей;
go-playground/validatorдобавляйте только при реальной необходимости.
Построенный слой валидации легко масштабируется: добавление нового правила — это одна функция типа Rule, добавление нового DTO — реализация интерфейса Validatable. Архитектура остаётся чистой, тесты — быстрыми, а API — предсказуемым для клиентов.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →