Опирается на правила: R-VLD-XF-1R-VLD-XF-2 и R-VLD-XF-X1R-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/v10v.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_atvalidateDateRangedate_range
password == password_confirmvalidatePasswordsMatchpasswords_match
amount <= limitvalidateAmountWithinLimitamount_within_limit
Хотя бы одно из email/phone заполненоvalidateAtLeastOneContactat_least_one_contact
valid_until > valid_from + N днейvalidateMinValiditymin_validity_period

Что запрещено

АнтипаттернПравилоЧто взамен
Одноразовая cross-field проверка в Handler перед uc.HandleR-VLD-XF-X1RegisterStructValidation в 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.