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

Важно знать

  • message — на русском. Это текст, который попадёт в violations[].message и дойдёт до пользователя на UI.
  • Для пользователя, не для разработчика. «Сумма должна быть положительной», не «amount must be positive».
  • Интерполяция через {} из стандартных placeholder-ов: {value}, {min}, {max}, {regexp}. Spring подставит реальные значения в момент валидации.
  • i18n — через {key} + messages_ru.properties / messages_en.properties. Используется только когда сервис реально мультиязычный.
  • Default message на custom constraint имеет смысл. Переопределение на каждом поле — только если у конкретного поля свой текст.
  • Английский в message — антипаттерн. Этот текст не «техническая ошибка для логов», это «понятное сообщение для пользователя».
  • Технические термины в message (field amount, BigDecimal, null) — антипаттерн. Пользователь не знает, что такое field.

message в Jakarta-аннотации — единственный текст, который при невалидном вводе доходит до конечного пользователя через violations[].message в ProblemDetails. Это product copy, не «лог», поэтому правила формирования другие. Раскрытие раздела 8 гайда.

Default — на русском, для пользователя

R-VLD-MSG-1: текст пишется на языке UI (в проекте — русский).

public record CreateCustomerRequest(
    @NotBlank(message = "Имя обязательно")
    @Size(max = 100, message = "Имя не более 100 символов")
    String firstName,

    @NotBlank(message = "Телефон обязателен")
    @RussianPhone
    String phone,

    @NotNull(message = "Дата рождения обязательна")
    @Past(message = "Дата рождения должна быть в прошлом")
    LocalDate birthDate
) {}

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

{
  "type": "/errors/validation-error",
  "title": "Validation failed",
  "status": 400,
  "violations": [
    {"field": "firstName", "message": "Имя обязательно"}
  ]
}

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

R-VLD-MSG-X1: английский в message — антипаттерн.

// ПЛОХО
@NotBlank(message = "First name is required")
@Size(max = 100, message = "First name must be at most 100 characters")
String firstName

Что произойдёт:

  • Текст попадёт в UI как есть.
  • Пользователь увидит «First name is required» и подумает «глюк, форма на английском».
  • Перевод на стороне фронта потребует mapping "First name is required" → «Имя обязательно». Маппинг постоянно отстаёт от backend.

Backend — единственное место правды для текста ошибок. Перевод на UI стороне — антипаттерн, если backend уже отдаёт нужный язык.

Интерполяция через {} placeholders

R-VLD-MSG-2: динамические значения подставляются из стандартных placeholder-ов.

public record OrderItemRequest(
    @NotNull(message = "Артикул обязателен") String sku,

    @Min(value = 1, message = "Количество должно быть не меньше {value}")
    @Max(value = 9999, message = "Количество не более {value}")
    int quantity,

    @Size(min = 1, max = 50, message = "Длина имени от {min} до {max} символов")
    String displayName,

    @Pattern(regexp = "^[A-Z]{3}-\\d{6}$", message = "Артикул в формате {regexp}")
    String articleNumber
) {}

Стандартные placeholder-ы по типу аннотации:

АннотацияДоступные placeholders
@Min, @Max{value}
@DecimalMin, @DecimalMax{value}, {inclusive}
@Size{min}, {max}
@Pattern{regexp}, {flags}
@Past, @Future
@Email{regexp}, {flags}

Что подставляется в ответ при quantity = 0:

{"field": "items[0].quantity", "message": "Количество должно быть не меньше 1"}

Pattern-сам по себе в сообщении — спорная практика. Regex в UI пользователю не помогает. Лучше — human-readable описание:

@Pattern(regexp = "^[A-Z]{3}-\\d{6}$",
         message = "Артикул в формате AAA-000000 (три буквы, дефис, шесть цифр)")

Default message на custom constraint

R-VLD-MSG-3: для custom constraint текст пишется один раз в самой аннотации.

// common/validation/RussianPhone.java
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RussianPhoneValidator.class)
public @interface RussianPhone {
    String message() default "Номер должен быть в формате +7XXXXXXXXXX";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

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

public record CreateCustomerRequest(
    @NotBlank(message = "Телефон обязателен") @RussianPhone String phone   // default message от @RussianPhone
) {}

Если для конкретного поля нужен другой текст (например в админ-форме «Телефон менеджера» — в default-сообщении это не отражено), переопределяем:

@RussianPhone(message = "Телефон менеджера в формате +7XXXXXXXXXX")
String managerPhone

R-VLD-MSG-X3: переопределение default message без бизнес-причины — антипаттерн.

// ПЛОХО — переопределение без смысла
@RussianPhone(message = "Номер должен быть в формате +7XXXXXXXXXX")   // совпадает с default
String phone

@RussianPhone(message = "Номер должен быть в формате +7XXXXXXXXXX")   // и снова
String alternatePhone

Если текст совпадает с default — не переопределяем. Это лишний шум, который ещё и нужно синхронизировать с самой аннотацией при смене default.

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

R-VLD-MSG-X2: пользователь не знает, что такое field, null, BigDecimal, «property amount must be positive».

// ПЛОХО
@DecimalMin(value = "0.01", message = "Field amount must be positive")
@Pattern(regexp = "...", message = "Property orderNumber doesn't match pattern")

// ХОРОШО
@DecimalMin(value = "0.01", message = "Сумма должна быть положительной")
@Pattern(regexp = "...", message = "Номер заказа в формате AAA-000000")

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

  • field, property, value в человеческом тексте.
  • Java-терминыnull, BigDecimal, String, Integer.
  • Технический жаргон — «doesn't match pattern», «must satisfy constraint», «violates regex».
  • Сообщения от Jakarta default (must not be null, must not be empty) — заменяем на русские «обязательно», «не должно быть пустым».

Тон сообщения — спокойный, описывающий правило, без обвинения пользователя:

  • ✓ «Сумма должна быть положительной»
  • ✗ «Вы ввели неправильную сумму»
  • ✗ «Ошибка в поле сумма»

i18n через {key} + messages.properties

Если сервис мультиязычный (UI на русском, английском, узбекском), используем bundle-based подход.

public record CreateOrderRequest(
    @NotBlank(message = "{order.name.required}") String name,
    @DecimalMin(value = "0.01", message = "{order.amount.positive}") BigDecimal amount
) {}

src/main/resources/messages_ru.properties:

order.name.required=Имя заказа обязательно
order.amount.positive=Сумма должна быть положительной

src/main/resources/messages_en.properties:

order.name.required=Order name is required
order.amount.positive=Amount must be positive

Spring выбирает bundle по Accept-Language header клиента (если настроен MessageSource с LocaleResolver).

В UCP-проекте сайт только на русском — bundle-based подход не нужен, сразу пишем русский текст в message(). Локализация — overengineering, если нет реального второго языка.

R-LOC-3 в REST API style guide описывает, как message локализуется на уровне REST-ответа. См. REST API → Локализация.

Полный пример

public record CreateProductRequest(
    @NotBlank(message = "Название обязательно")
    @Size(min = 1, max = 200, message = "Название от {min} до {max} символов")
    String name,

    @NotBlank(message = "Артикул обязателен")
    @Pattern(regexp = "^[A-Z]{3}-\\d{6}$",
             message = "Артикул в формате AAA-000000 (три буквы, дефис, шесть цифр)")
    String sku,

    @NotNull(message = "Цена обязательна")
    @DecimalMin(value = "0.01", message = "Цена должна быть не меньше {value}")
    @DecimalMax(value = "999999.99", message = "Цена не более {value}")
    BigDecimal price,

    @NotNull(message = "Категория обязательна")
    @Valid
    CategoryRef category,

    @Size(max = 5000, message = "Описание не более {max} символов")
    String description
) {}

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

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

АнтипаттернПравилоЧто взамен
Английский в message ("must be positive")R-VLD-MSG-X1Русский: «должно быть положительным»
Технические термины (field, null, BigDecimal)R-VLD-MSG-X2Пользовательский язык («сумма», «дата»)
Дублирование default message на каждом поле без причиныR-VLD-MSG-X3Default — в аннотации; переопределяем только для специфичных полей
Default Jakarta-сообщения (must not be null)R-VLD-MSG-1Явное русское message («обязательно»)
Hardcoded значения в тексте вместо {value}R-VLD-MSG-2"не меньше {value}" — подставится из аннотации

Куда дальше

  • Validation → раздел 8. Сообщения и i18n — нормативные формулировки R-VLD-MSG-*.
  • Стандартные constraints — какие placeholder-ы доступны на каких аннотациях.
  • Custom constraints — где задаётся default message для custom-constraints.
  • REST API → ЛокализацияR-LOC-* про язык ответов.
  • Error Handling → ProblemDetails mapping — как violations встраиваются в общий ProblemDetails.