Опирается на правила:
R-VLD-MSG-1…R-VLD-MSG-3иR-VLD-MSG-X1…R-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-refR-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-X1 | Localize(fe) → читаемый русский текст |
Технические термины в тексте (field, tag, int64, uuid4) | R-VLD-MSG-X2 | Пользовательские названия: «сумма», «телефон», «дата» |
Один статичный текст на всё без fe.Param() | R-VLD-MSG-X3 | fmt.Sprintf("не более %s символов", fe.Param()) |
| Дублирование switch-по-тегам в нескольких хендлерах | R-VLD-MSG-X3 | Единый Localize в internal/validation/messages.go |
os.Stderr-вывод текстов ошибок вместо структурированного ответа | R-VLD-MSG-X1 | apperr.ValidationError → httperr.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.