Опирается на правила:
R-VLD-XF-1…R-VLD-XF-2иR-VLD-XF-X1…R-VLD-XF-X2из Validation Style Guide → раздел 5. Cross-field validation.
Важно знать
- Cross-field = правило, в котором участвуют 2+ поля одного struct:
ends_at > starts_at,password == password_confirm,amount <= credit_limit.- В
validator/v10—v.RegisterStructValidation(fn, Type{}): функция получает весь struct черезStructLevel.sl.ReportError(field, jsonName, structFieldName, tag, param)— прицеп ошибки к конкретному полю; без него ошибка без поля вerrors-массиве.- Имя тега в
ReportErrorописывает правило:date_range,passwords_match— неvalidate_request,check_order.- Регистрация — в
internal/validation/cross_field.goчерезRegisterStructValidations, не inline в DTO.- Проверка в Handler перед
uc.Handle— антипаттерн: это контракт входных данных, а не бизнес-логика.
Cross-field — правило, которое ни один field-level тег не может выразить в одиночку: нужно видеть два поля одновременно. RegisterStructValidation — прямой механизм для этого в validator/v10.
RegisterStructValidation: структура
R-VLD-XF-1: регистрация кросс-полевого правила на конкретный тип struct.
// internal/validation/cross_field.go
package validation
import "github.com/go-playground/validator/v10"
func RegisterStructValidations(v *validator.Validate) {
v.RegisterStructValidation(validateDateRange, DateRangeRequest{})
v.RegisterStructValidation(validatePasswordsMatch, ChangePasswordRequest{})
v.RegisterStructValidation(validateAmountWithinLimit, PaymentRequest{})
}
Функции валидации:
func validateDateRange(sl validator.StructLevel) {
r, ok := sl.Current().Interface().(DateRangeRequest)
if !ok {
return
}
if r.EndsAt.IsZero() || r.StartsAt.IsZero() {
return // null-tolerant: отдельные required-теги поймают отсутствие
}
if r.EndsAt.Before(r.StartsAt) {
sl.ReportError(r.EndsAt, "ends_at", "EndsAt", "date_range", "")
}
}
func validatePasswordsMatch(sl validator.StructLevel) {
r, ok := sl.Current().Interface().(ChangePasswordRequest)
if !ok {
return
}
if r.Password != r.PasswordConfirm {
sl.ReportError(r.PasswordConfirm, "password_confirm", "PasswordConfirm", "passwords_match", "")
}
}
func validateAmountWithinLimit(sl validator.StructLevel) {
r, ok := sl.Current().Interface().(PaymentRequest)
if !ok {
return
}
if r.LimitCop > 0 && r.AmountCop > r.LimitCop {
sl.ReportError(r.AmountCop, "amount_cop", "AmountCop", "amount_within_limit", "")
}
}
Struct-типы:
type DateRangeRequest struct {
StartsAt time.Time `json:"starts_at" validate:"required"`
EndsAt time.Time `json:"ends_at" validate:"required"`
Topic string `json:"topic" validate:"required,max=200"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required,min=8"`
Password string `json:"password" validate:"required,min=8"`
PasswordConfirm string `json:"password_confirm" validate:"required,min=8"`
}
type PaymentRequest struct {
OrderID string `json:"order_id" validate:"required,uuid4"`
AmountCop int64 `json:"amount_cop" validate:"required,gt=0"`
LimitCop int64 `json:"limit_cop" validate:"omitempty,gt=0"`
}
ReportError — прицеп к полю
Аргументы sl.ReportError:
sl.ReportError(
value interface{}, // значение проблемного поля (для отладки)
jsonName string, // имя поля в JSON (snake_case из json-тега)
fieldName string, // имя поля в struct (PascalCase)
tag string, // имя правила — попадёт в fe.Tag() → Localize(fe)
param string, // доп. параметр (обычно "")
)
tag — имя правила — попадает в fe.Tag() при маппинге ValidationErrors → apperr.FieldError. Localize(fe) в internal/validation/messages.go должен обработать этот тег:
case "date_range":
return "дата окончания не может быть раньше даты начала"
case "passwords_match":
return "пароли не совпадают"
case "amount_within_limit":
return fmt.Sprintf("сумма превышает лимит %s копеек", fe.Param())
Имя описывает правило
R-VLD-XF-2: имя функции и тег в ReportError — про правило, не про объект.
// ХОРОШО — имя описывает правило
v.RegisterStructValidation(validateDateRange, DateRangeRequest{})
// tag: "date_range"
v.RegisterStructValidation(validatePasswordsMatch, ChangePasswordRequest{})
// tag: "passwords_match"
// ПЛОХО — имя описывает объект, не правило
v.RegisterStructValidation(validateBookingRequest, BookingRequest{})
// tag: "booking_request_valid"
Правило с понятным именем — grep "date_range" находит все места за секунду. Имя «validate_booking_request» не говорит, что именно проверяется.
Типовые cross-field правила
| Правило | Функция | Тег в ReportError |
|---|---|---|
ends_at >= starts_at | validateDateRange | date_range |
password == password_confirm | validatePasswordsMatch | passwords_match |
amount <= limit | validateAmountWithinLimit | amount_within_limit |
Хотя бы одно из email/phone заполнено | validateAtLeastOneContact | at_least_one_contact |
valid_until > valid_from + N дней | validateMinValidity | min_validity_period |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Одноразовая cross-field проверка в Handler перед uc.Handle | R-VLD-XF-X1 | RegisterStructValidation в internal/validation/ |
Cross-field проверка в Handler: if req.EndsAt.Before(req.StartsAt) | R-VLD-XF-X2 | Правило на DTO через RegisterStructValidation; Handler получает чистую команду |
ReportError без имени поля (пустой jsonName) | R-VLD-XF-1 | Передать jsonName — появится в errors[].field ответа |
Имя тега "validate_order_request" вместо "date_range" | R-VLD-XF-2 | Описательное имя правила |
| Одна функция-валидатор с логикой нескольких правил | — | По одной функции на правило; RegisterStructValidation вызывается несколько раз |
Куда дальше
- Validation → раздел 5. Cross-field validation — нормативные формулировки
R-VLD-XF-*. - Custom constraints — field-level кастомные теги.
- Где валидировать — почему cross-field на DTO, не в Handler.
- Messages и i18n — как
fe.Tag()отReportErrorмаппится на текст. - Validation groups — другой механизм, который иногда путают с cross-field.