Опирается на правила: R-VLD-CC-1R-VLD-CC-5 и R-VLD-CC-X1R-VLD-CC-X3 из Validation Style Guide → раздел 3. Custom constraints.

Важно знать

  • Кастомный тег в validator/v10 — именованная функция func(fl validator.FieldLevel) bool, зарегистрированная через v.RegisterValidation.
  • Пишем, когда формат встречается на 3+ полях или несёт доменное имя (ru_phone, vat_number, future).
  • Все кастомные теги регистрируются в internal/validation/tags.go; не inline в файле DTO.
  • Имена по доменному термину без префиксов: ru_phone, vat_number, future — не valid_phone, check_vat, is_future.
  • Функция-валидатор должна быть stateless — только значение поля, никакого внешнего состояния.
  • Zero-value (пустая строка, нулевое время) — обрабатывается через type assertion с ok; не паникуй на zero-value.
  • Для optional-полей — omitempty перед кастомным тегом: validate:"omitempty,ru_phone" — пропустить nil.

В validator/v10 нет концепции «аннотация + реализация» как в Jakarta, но идиома та же: именованный переиспользуемый тег, зарегистрированный один раз, применяемый везде. Главная разница — регистрация явная, поэтому важно держать все custom теги в одном пакете.

Когда вводим

Триггер — формат встречается в нескольких struct или несёт доменное имя:

  • Тот же regexp для телефона появляется в CustomerRequest, EmployeeRequest, ContactRequest.
  • Формат имеет общепринятое имя: ИНН, телефон РФ, будущая дата.
  • Валидация сложнее regexp: контрольная сумма ИНН, Luhn для карты.

Когда не нужен custom тег:

  • Формат уникален для одного поля (оставить validate:"min=20,max=20" или inline regexp).
  • Стандартный тег покрывает (email, e164, uuid4).

Регистрация в internal/validation/

R-VLD-CC-1 / R-VLD-CC-2: все кастомные теги — в internal/validation/tags.go, регистрация через RegisterCustomTags:

// internal/validation/tags.go
package validation

import (
    "regexp"
    "time"
    "unicode/utf8"

    "github.com/go-playground/validator/v10"
)

var (
    ruPhoneRE  = regexp.MustCompile(`^\+7\d{10}$`)
    vatInnRE   = regexp.MustCompile(`^\d{10}$|^\d{12}$`)
    bicRE      = regexp.MustCompile(`^\d{9}$`)
)

func RegisterCustomTags(v *validator.Validate) {
    v.RegisterValidation("ru_phone", validateRuPhone)
    v.RegisterValidation("vat_number", validateVatNumber)
    v.RegisterValidation("bic_code", validateBicCode)
    v.RegisterValidation("future", validateFuture)
    v.RegisterValidation("cyrillic", validateCyrillic)
}

func validateRuPhone(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    return ok && ruPhoneRE.MatchString(s)
}

func validateVatNumber(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    if !ok {
        return false
    }
    return vatInnRE.MatchString(s)
}

func validateBicCode(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    return ok && bicRE.MatchString(s)
}

func validateFuture(fl validator.FieldLevel) bool {
    t, ok := fl.Field().Interface().(time.Time)
    return ok && t.After(time.Now())
}

func validateCyrillic(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    if !ok {
        return false
    }
    for _, r := range s {
        if r < 'А' || (r > 'я' && r != 'ё' && r != 'Ё') {
            if r != ' ' && r != '-' {
                return false
            }
        }
    }
    return utf8.RuneCountInString(s) > 0
}

R-VLD-CC-3: имена — доменные термины без глагольных префиксов. ru_phone читается как «российский телефон»; valid_phone — «валидный телефон» (тавтология).

Применение в DTO

type CustomerRequest struct {
    FullName string  `json:"full_name"  validate:"required,cyrillic,min=2,max=100"`
    Phone    string  `json:"phone"      validate:"required,ru_phone"`
    AltPhone *string `json:"alt_phone"  validate:"omitempty,ru_phone"` // опционально
    INN      *string `json:"inn"        validate:"omitempty,vat_number"`
}

type CreateBookingRequest struct {
    StartsAt time.Time `json:"starts_at" validate:"required,future"`
    EndsAt   time.Time `json:"ends_at"   validate:"required"`
    VenueID  string    `json:"venue_id"  validate:"required,uuid4"`
}

R-VLD-CC-4: optional-поле — pointer-тип + omitempty перед кастомным тегом. validator/v10 пропустит nil-pointer при omitempty. Обязательное поле — required перед кастомным тегом: required ловит пустую строку, кастомный тег — формат.

Stateless функция

R-VLD-CC-5: функция-валидатор зависит только от значения поля. Никакого внешнего состояния, горутинобезопасна по умолчанию.

// ХОРОШО — pure функция
func validateRuPhone(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    return ok && ruPhoneRE.MatchString(s)
}

// ПЛОХО — внешнее mutable состояние
var callCount int // race condition при конкурентных запросах

func validateRuPhone(fl validator.FieldLevel) bool {
    callCount++
    s, ok := fl.Field().Interface().(string)
    return ok && ruPhoneRE.MatchString(s)
}

Если правило требует обращения к БД (проверить уникальность ИНН) — это бизнес-проверка, не input-валидация. Место — Handler или UseCase, не RegisterValidation. Читай Где валидировать.

Исключение — справочник, загружаемый один раз при старте (список кодов BIC из ЦБ РФ). Тогда BicCodeSet хранится как sync.Map или иммутабельный map[string]struct{}, заполненный до регистрации тега.

Type assertion — защита от zero-value

R-VLD-CC-X1: FieldLevel.Field().Interface() возвращает any; type assertion с ok защищает от паники.

// ХОРОШО — safe type assertion
func validateVatNumber(fl validator.FieldLevel) bool {
    s, ok := fl.Field().Interface().(string)
    if !ok {
        return false
    }
    return vatInnRE.MatchString(s)
}

// ПЛОХО — паника при unexpected type
func validateVatNumber(fl validator.FieldLevel) bool {
    s := fl.Field().String() // не паникует, но возвращает "<invalid>" для неверного типа
    return vatInnRE.MatchString(s)
}

fl.Field().String() безопасен только для string-полей; для time.Time и других типов вернёт строку-представление, а не zero-value. Всегда используй Interface().(T) с проверкой ok.

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

АнтипаттернПравилоЧто взамен
Паника или неверный результат на zero-valueR-VLD-CC-X1Interface().(T) с ok; при !ok вернуть false
v.RegisterValidation inline в файле DTOR-VLD-CC-X2Регистрация в internal/validation/tags.go
Один «мега-тег» для нескольких правилR-VLD-CC-X3Раздельные теги: required,ru_phone — каждый описывает одно правило
Имя с префиксом valid_, check_, is_R-VLD-CC-3Доменный термин: ru_phone, vat_number, future
Обращение к БД внутри функции-валидатораR-VLD-CC-5Бизнес-проверка в Handler; валидатор — stateless
omitempty забыт на optional pointer-поле с custom тегомR-VLD-CC-4*string + validate:"omitempty,ru_phone"

Куда дальше

  • Validation → раздел 3. Custom constraints — нормативные формулировки R-VLD-CC-*.
  • Стандартные constraints — встроенные теги перед custom.
  • Cross-field validation — RegisterStructValidation для правил между полями.
  • Messages и i18n — как имя кастомного тега маппится на текст ошибки.