Опирается на правила: R-VLD-MSG-1R-VLD-MSG-3 и R-VLD-MSG-X1R-VLD-MSG-X3 из Validation Style Guide → раздел 8. Сообщения и i18n.

Важно знать

  • fe.Error() — не для пользователя. Это технический текст вида Key: 'CreateOrderRequest.CustomerID' Error:Field validation for 'CustomerID' failed on the 'required' tag — он идёт в логи, не в ответ.
  • Маппинг тегов → читаемый текст — в Localize. Единственная точка, где тег "required" превращается в «поле обязательно».
  • Интерполяция через fe.Param(). Если тег имеет параметр (min=1, max=200, gt=0), fe.Param() возвращает "1", "200", "0" — подставляем в fmt.Sprintf.
  • fe.Field() — техническое имя. Go-поле CustomerID; через fieldLabel возвращается читаемое «идентификатор клиента». Таблица меток живёт рядом с Localize.
  • i18n — через golang.org/x/text/message. Только когда сервис реально мультиязычный. Для русскоязычного сервиса — строки прямо в Localize, без bundle — это не упрощение, а правильный выбор.
  • Один Localize на весь сервис. Не дублировать switch-по-тегам в каждом хендлере или пакете.
  • Тексты — спокойные, описывают правило. «Сумма должна быть больше нуля», не «Вы ввели неправильную сумму».
  • Доменные ошибки — не через Localize. *InsufficientFundsError — это агрегат, не валидация контракта; у него свой путь в ответ через httperr.Write (cross-ref R-VLD-WHERE-3).

validator/v10 возвращает ValidationErrors — слайс FieldError. Каждый элемент несёт тег (Tag()), параметр тега (Param()), имя Go-поля (Field()). Чтобы превратить это в «Количество не может быть меньше 1», нужен явный маппинг. Этот маппинг — Localize в internal/validation/messages.go. Раскрытие раздела 8 гайда.

Маппинг тегов → читаемый текст

R-VLD-MSG-1: текст — на русском, для пользователя на UI.

// internal/validation/messages.go

var fieldLabels = map[string]string{
    "CustomerID":  "идентификатор клиента",
    "ProductID":   "идентификатор товара",
    "Name":        "название",
    "Phone":       "телефон",
    "Email":       "электронная почта",
    "AmountCop":   "сумма",
    "Qty":         "количество",
    "StartsAt":    "дата начала",
    "EndsAt":      "дата окончания",
    "DeliveryDate": "дата доставки",
}

func fieldLabel(goField string) string {
    if label, ok := fieldLabels[goField]; ok {
        return label
    }
    return goField
}

func Localize(fe validator.FieldError) string {
    label := fieldLabel(fe.Field())
    switch fe.Tag() {
    case "required":
        return fmt.Sprintf("поле «%s» обязательно", label)
    case "min":
        return fmt.Sprintf("минимальная длина — %s символов", fe.Param())
    case "max":
        return fmt.Sprintf("максимальная длина — %s символов", fe.Param())
    case "gt":
        return fmt.Sprintf("%s должно быть больше %s", label, fe.Param())
    case "gte":
        return fmt.Sprintf("%s должно быть не меньше %s", label, fe.Param())
    case "lte":
        return fmt.Sprintf("%s должно быть не более %s", label, fe.Param())
    case "email":
        return "некорректный адрес электронной почты"
    case "e164":
        return "телефон должен быть в формате +7XXXXXXXXXX"
    case "ru_phone":
        return "телефон должен быть в формате +7XXXXXXXXXX"
    case "uuid4":
        return fmt.Sprintf("поле «%s» должно быть корректным UUID", label)
    case "url":
        return "некорректный адрес сайта"
    case "future":
        return fmt.Sprintf("%s должна быть в будущем", label)
    case "date_range":
        return "дата окончания не может быть раньше даты начала"
    case "passwords_match":
        return "пароли не совпадают"
    case "vat_number":
        return "ИНН должен содержать 10 или 12 цифр"
    default:
        return fmt.Sprintf("некорректное значение поля «%s»", label)
    }
}

Что попадёт в ответ при пустом customer_id:

{
  "type": "urn:problem:orders:validation-error",
  "title": "Validation failed",
  "status": 400,
  "errors": [
    {"field": "CustomerID", "code": "required", "message": "поле «идентификатор клиента» обязательно"}
  ]
}

Фронт показывает «поле «идентификатор клиента» обязательно» рядом с полем. Без перевода, без догадок.

Интерполяция через fe.Param()

R-VLD-MSG-2: динамические значения параметров тега подставляются через fe.Param().

type CreateOrderRequest struct {
    CustomerID string             `json:"customer_id" validate:"required,uuid4"`
    Items      []OrderItemRequest `json:"items"       validate:"required,min=1,dive"`
    Comment    string             `json:"comment"     validate:"max=500"`
}

type OrderItemRequest struct {
    ProductID string `json:"product_id" validate:"required,uuid4"`
    Qty       int    `json:"qty"        validate:"required,gt=0,lte=9999"`
}

При Qty = 0 тег gt вернёт fe.Param() == "0", при Qty = 10000 тег lte вернёт fe.Param() == "9999". Localize подставит:

  • «количество должно быть больше 0»
  • «количество должно быть не более 9999»

Значения не хардкодятся в текст — они приходят из тега. Когда ограничение меняется с lte=9999 на lte=99999, текст обновится автоматически.

Теги без параметра (required, email, uuid4) — возвращают фиксированный текст, fe.Param() для них пустой.

Custom constraints — текст один раз в Localize

R-VLD-MSG-3: для кастомных тегов (ru_phone, vat_number, future) текст задаётся один раз — в Localize. Регистрация тега:

// internal/validation/tags.go

var ruPhoneRE = regexp.MustCompile(`^\+7\d{10}$`)

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

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

Использование:

type CreateCustomerRequest struct {
    Name  string  `json:"name"  validate:"required,min=1,max=100"`
    Phone string  `json:"phone" validate:"required,ru_phone"`
    Email string  `json:"email" validate:"required,email"`
}

При невалидном телефоне Localize вернёт «телефон должен быть в формате +7XXXXXXXXXX». Один раз в messages.go, без дублирования на каждом поле.

R-VLD-MSG-X3 — не дублировать однотипный текст вручную на каждом поле:

// AVOID — одинаковый текст описан три раза в разных местах
type CreateSupplierRequest struct {
    ContactPhone string `json:"contact_phone" validate:"required,ru_phone"`
    DirectorPhone string `json:"director_phone" validate:"required,ru_phone"`
    WarehousePhone string `json:"warehouse_phone" validate:"required,ru_phone"`
}

// Не добавлять отдельный switch для каждого поля — Localize покрывает всё.

Техническое имя поля → читаемое

Тег fe.Field() возвращает Go-имя структурного поля (CustomerID, AmountCop), не JSON-ключ. Для пользователя нужен читаемый вариант.

// internal/validation/messages.go

func toValidationError(err error) *apperr.ValidationError {
    var ve validator.ValidationErrors
    if !errors.As(err, &ve) {
        return apperr.NewValidation("некорректные данные запроса")
    }
    fields := make([]apperr.FieldError, len(ve))
    for i, fe := range ve {
        fields[i] = apperr.FieldError{
            Field:   fe.Field(),
            Code:    fe.Tag(),
            Message: Localize(fe),
        }
    }
    return &apperr.ValidationError{Fields: fields}
}

fe.Field() идёт в поле field ответа (для фронта по нему навешивается ошибка на инпут). Localize(fe) через fieldLabel превращает "AmountCop" в «сумма» внутри текста сообщения.

Пример для CreateProductRequest:

type CreateProductRequest struct {
    Name     string `json:"name"   validate:"required,min=1,max=200"`
    Sku      string `json:"sku"    validate:"required,min=3,max=50"`
    PriceCop int64  `json:"price"  validate:"required,gt=0,lte=100000000"`
    Stock    int    `json:"stock"  validate:"min=0,max=999999"`
}

При PriceCop = -100:

{"field": "PriceCop", "code": "gt", "message": "сумма должно быть больше 0"}

При Name = "":

{"field": "Name", "code": "required", "message": "поле «название» обязательно"}

Технические термины — не для пользователя

R-VLD-MSG-X2: пользователь не знает, что такое FieldError, FieldLevel, int64, uuid4.

// AVOID — технический текст напрямую из библиотеки
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    req, err := httpreq.Decode[CreateOrderRequest](r)
    if err != nil {
        var ve validator.ValidationErrors
        if errors.As(err, &ve) {
            http.Error(w, ve[0].Error(), 400) // "Key: 'CustomerID' Error:Field validation..."
        }
        return
    }
}

Что избегаем:

  • fe.Error() напрямую в ответ (R-VLD-MSG-X1).
  • Термины field, tag, validation, constraint, struct, int64 в пользовательских текстах.
  • Текст «validation failed» без расшифровки — пользователь не знает, что именно не так.
  • JSON-имя поля (customer_id) в тексте сообщения вместо человеческого названия.

Тон — спокойный, описывающий правило:

  • «поле «название» обязательно» — правило
  • «вы ввели неправильное название» — обвинение, не правило
  • «ошибка валидации поля name» — технический жаргон

i18n через golang.org/x/text/message

R-VLD-MSG-3: когда сервис реально мультиязычный, ключи сообщений передаются в локализатор, не хардкодятся строками.

// internal/validation/messages.go

import "golang.org/x/text/message"

var (
    printerRu = message.NewPrinter(language.Russian)
    printerEn = message.NewPrinter(language.English)
)

func LocalizeWithLang(fe validator.FieldError, lang language.Tag) string {
    p := printerRu
    if lang == language.English {
        p = printerEn
    }
    label := fieldLabel(fe.Field())
    switch fe.Tag() {
    case "required":
        return p.Sprintf("поле «%s» обязательно", label)
    case "gt":
        return p.Sprintf("%s должно быть больше %s", label, fe.Param())
    // ...
    default:
        return p.Sprintf("некорректное значение поля «%s»", label)
    }
}

Язык приходит из заголовка запроса в edge-слое:

// edge/httpreq/decode.go

func DecodeWithLang[T any](r *http.Request) (T, language.Tag, error) {
    lang, _ := language.Parse(r.Header.Get("Accept-Language"))
    var req T
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return req, lang, apperr.NewValidation("некорректный JSON")
    }
    if err := validate.Struct(&req); err != nil {
        return req, lang, toValidationErrorWithLang(err, lang)
    }
    return req, lang, nil
}

Для русскоязычного сервиса — строки прямо в Localize, без bundle. Добавлять golang.org/x/text ради одного языка — избыточно.

Полный пример: заказ Sber Pay

// edge/order/request.go

type CreateSberPayOrderRequest struct {
    CustomerID  string    `json:"customer_id"   validate:"required,uuid4"`
    Amount      int64     `json:"amount_cop"    validate:"required,gt=0,lte=500000000"`
    Description string    `json:"description"   validate:"required,min=1,max=200"`
    Phone       string    `json:"phone"         validate:"required,ru_phone"`
    DeliveryDate time.Time `json:"delivery_date" validate:"required,future"`
    Items       []SberPayItem `json:"items"     validate:"required,min=1,dive"`
}

type SberPayItem struct {
    ProductID string `json:"product_id" validate:"required,uuid4"`
    Qty       int    `json:"qty"        validate:"required,gt=0,lte=99"`
    PriceCop  int64  `json:"price_cop"  validate:"required,gt=0"`
}

При нескольких нарушениях одновременно:

{
  "type": "urn:problem:sber-pay:validation-error",
  "title": "Validation failed",
  "status": 400,
  "errors": [
    {"field": "Amount",       "code": "gt",      "message": "сумма должно быть больше 0"},
    {"field": "Phone",        "code": "ru_phone", "message": "телефон должен быть в формате +7XXXXXXXXXX"},
    {"field": "DeliveryDate", "code": "future",   "message": "дата доставки должна быть в будущем"}
  ]
}

Каждое сообщение — короткое, на русском, с конкретным указанием правила.

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

АнтипаттернПравилоЧто взамен
fe.Error() напрямую в ответR-VLD-MSG-X1Localize(fe) → читаемый русский текст
Технические термины в тексте (field, tag, int64, uuid4)R-VLD-MSG-X2Пользовательские названия: «сумма», «телефон», «дата»
Один статичный текст на всё без fe.Param()R-VLD-MSG-X3fmt.Sprintf("не более %s символов", fe.Param())
Дублирование switch-по-тегам в нескольких хендлерахR-VLD-MSG-X3Единый Localize в internal/validation/messages.go
os.Stderr-вывод текстов ошибок вместо структурированного ответаR-VLD-MSG-X1apperr.ValidationErrorhttperr.Write → 400 problem+json

Куда дальше

  • go/standard-constraints.md — теги validator/v10 и какой fe.Param() доступен на каждом.
  • go/custom-constraints.md — регистрация тегов через RegisterCustomTags, где живёт текст для кастомного тега.
  • go/cross-field-validation.md — RegisterStructValidation и как формировать текст для struct-level ошибок.
  • go/where-to-validate.md — где Localize вызывается: toValidationError в internal/validation/error.go.
  • go/validation-groups.md — отдельные struct-типы вместо групп, один Localize на все типы.
  • go/openapi-generated-dto.md — как validate-теги сочетаются с contract-first генерацией.
  • go/configuration-validation.md — сообщения ошибок конфига при fail-fast старте через slog.Error.
  • Error Handling → problem+json mapping — как apperr.ValidationError встраивается в общий ответ httperr.Write.