Опирается на правила:
R-VLD-CC-1…R-VLD-CC-5иR-VLD-CC-X1…R-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-value | R-VLD-CC-X1 | Interface().(T) с ok; при !ok вернуть false |
v.RegisterValidation inline в файле DTO | R-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 — как имя кастомного тега маппится на текст ошибки.