1. Введение
Когда в проекте появляется @Valid, возникает иллюзия, что валидация “включена”. Но @Valid — это всего лишь кнопка “проверить”, а не сами правила. Если на DTO нет constraint-аннотаций, то проверять нечего: запрос пройдёт, даже если в title пришли пробелы, а priority вообще отсутствует. Поэтому сегодня мы делаем важный шаг: превращаем бизнес-требования типа “title обязателен и длиной 3..120” в декларативные ограничения.
Constraint-аннотации — это маленькие “кирпичики” входного контракта. Они отвечают на очень приземлённые вопросы: поле вообще должно быть? если да, то может ли оно быть пустым? какой длины может быть строка? в каком диапазоне число? подходит ли формат? Важно помнить границу: мы проверяем структуру и форму входа, а не предметные конфликты вроде “нельзя менять архивную задачу”. Такие штуки мы не запихиваем в @Pattern и не пытаемся “выразить в аннотациях любой ценой”.
Чтобы не утонуть в море аннотаций, будем мыслить не “какую аннотацию поставить”, а “какой вопрос я задаю полю”. Тогда выбор становится почти механическим (в хорошем смысле — мозгу меньше работы).
2. Четыре вопроса к полю DTO
Если вы когда-нибудь видели DTO, где на каждом поле висит по 5 аннотаций “на всякий случай” — это обычно результат паники, а не дизайна. Нормальная стратегия проще: задаём полю несколько базовых вопросов, и под каждый вопрос выбираем конкретный constraint. Это похоже на контроль качества на входе: не нужно проверять “всё на свете”, достаточно проверять то, что реально ломает систему и контракт.
Ниже — полезная шпаргалка именно для сегодняшней лекции (без экзотики и без будущих тем):
| Вопрос к полю | Что хотим гарантировать | Основные аннотации дня | Типичный пример в Task Tracker API |
|---|---|---|---|
| Поле обязательно? | Значение должно быть передано (не null) | @NotNull | priority задачи |
| Поле обязательно и это текст? | Нельзя пустую строку и “только пробелы” | @NotBlank | title задачи |
| Какие границы длины? | Строка не слишком короткая и не слишком длинная | @Size(min=..., max=...) | title, description, assigneeName |
| Есть ли числовой диапазон? | Число должно быть “между” | @Min, @Max | условная “оценка времени” в минутах |
| Есть ли простой формат? | Строка должна соответствовать шаблону | @Pattern | условный “код тега” или “slug” |
Чтобы визуально закрепить логику выбора, вот простой “маршрут” принятия решения:
flowchart TD
%% Базовый алгоритм выбора constraint-аннотаций для поля DTO
A["Поле в request DTO"] --> B{"Поле должно быть обязательным?"}
B -- "да" --> C{"Это строка?"}
%% Для строк: запрещаем null/пустоту/пробелы и фиксируем границы длины
C -- "да" --> D["@NotBlank + @Size(...)"]
%% Для не-строк: запрещаем null, а для чисел обычно добавляем диапазон
C -- "нет" --> E["@NotNull (+ @Min/@Max если число)"]
B -- "нет" --> F{"Нужно ограничение по длине/формату?"}
F -- "да" --> G["@Size(...) или @Pattern(...)"]
F -- "нет" --> H["Оставляем без constraints"]
Это не “единственно правильная истина”, но отличный способ держать голову в порядке: мы не ставим аннотации потому что “так принято”, а потому что поле требует конкретного обещания клиенту.
3. Обязательность: @NotNull и @NotBlank
С обязательностью чаще всего и начинается валидация, потому что “поля нет” — это самый популярный вид поломки контракта. На этом месте очень легко ошибиться, потому что @NotNull и @NotBlank выглядят как “одно и то же, просто другое слово”. На самом деле они проверяют разные вещи: @NotNull гарантирует наличие значения, а @NotBlank гарантирует, что строка ещё и не пустая/не пробельная.
@NotNull применим почти к любому ссылочному типу: TaskPriority, Integer, UUID (если бы мы его передавали), LocalDate. Он честно говорит: “в запросе это значение должно быть”. Но если вы поставите @NotNull на String title, то строка "" (пустая) или " " (три пробела) пройдёт проверку. Формально это не null, значит правило выполнено. Для текстовых полей это обычно не то, что вы хотите.
@NotBlank работает только для текстовых значений (CharSequence). Он включает в себя смысл “строка должна содержать видимые символы”. То есть "hello" ок, " " не ок, "" не ок, null тоже не ок. Для title задачи это почти всегда “правильная обязательность”.
Полезно держать в голове небольшую таблицу “кто что считает валидным”:
| Значение строки | @NotNull | @NotBlank |
|---|---|---|
| null | невалидно | невалидно |
| "" | валидно | невалидно |
| " " | валидно | невалидно |
| "Task" | валидно | валидно |
И ещё один нюанс, который часто ломает мозг новичкам: “а если поле просто не пришло в JSON?”. Для Jackson это почти всегда означает “в Java будет null” (если тип ссылочный). То есть отсутствие поля в JSON и null в JSON для нас часто одинаковы с точки зрения @NotNull/@NotBlank: оба случая не проходят.
Отдельно стоит сказать про примитивы. Если вы напишете int minutes вместо Integer minutes, то @NotNull не имеет смысла: int не может быть null. А если клиент не пришлёт поле, Jackson может подставить 0 (или вы получите ошибку привязки — зависит от контекста и настроек). Для входных DTO это почти всегда плохая идея: хотите валидировать обязательность — используйте Integer, Boolean и т.д., чтобы null был возможен и проверяем.
4. Длина строк: @Size
Ограничение длины кажется “мелочью”, пока вы не увидите description на 3 мегабайта и не начнёте гадать, почему логирование/трассировка/сериализация вдруг стали тяжелее. @Size — это простейший способ сделать контракт честным: клиент заранее знает границы, а сервис не обязан принимать бесконечные строки. В нашем Task Tracker API это выглядит очень естественно: у title есть нижняя и верхняя границы, у description — только верхняя, у assigneeName — верхняя.
Важно понимать тонкость, которая часто удивляет: @Size(max = 80) не делает поле обязательным. Если значение null, constraint обычно считается выполненным (Bean Validation в целом воспринимает null как “пусто — нечего проверять”, если вы отдельно не сказали, что null запрещён). Это удобно для опциональных полей: мы не требуем assigneeName, но если он есть — он должен быть разумной длины.
Для обязательной строки часто используется “пара” constraints: @NotBlank + @Size(...). Почему оба? Потому что @NotBlank отвечает за “не пусто/не пробелы”, а @Size(min = 3, max = 120) отвечает за длину. Если оставить только @Size(min = 3, ...), то строка " " может быть длиной 3 и формально пройдёт проверку длины, хотя смыслово это мусор.
Ещё одна практическая дисциплина: если поле имеет одинаковый смысл в create и update, то его ограничения должны совпадать. Иначе клиент получает странную картину: создать задачу с title длиной 120 можно, а обновить такой же title нельзя (или наоборот). Это не “небольшая разница”, это уже две разных версии контракта, только вы их не назвали “v1” и “v2”.
5. Числовой диапазон: @Min и @Max
Числа в API — это отдельный источник проблем, потому что они выглядят надёжно (“ну это же просто int”), а на практике ломают контракты так же легко, как строки. Если число приходит от пользователя, почти всегда есть “разумный диапазон”, и его лучше зафиксировать декларативно, чем каждый раз писать if (x < 0). @Min и @Max как раз про это: они гарантируют нижнюю и верхнюю границу.
В нашем домене задач явных числовых полей немного (мы работаем с enum для priority/status), поэтому покажу @Min/@Max на мини-примере, который часто встречается в таск-трекерах: оценка времени в минутах. Мы не обязаны прямо сейчас вводить это поле в API, но на нём отлично видно, как работает ограничение диапазона и почему лучше держать числа под контролем.
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public record TaskEstimateRequest(
// Поле обязательно должно присутствовать в запросе (иначе будет null)
@NotNull
// Нижняя граница "разумного" значения
@Min(1)
// Верхняя граница "разумного" значения (8 часов = 480 минут)
@Max(480)
Integer minutes
) {}
Здесь смысл читается без “перевода” на Java: поле обязательно, минимум 1 минута, максимум 480 (8 часов). И да, опять Integer, а не int: так мы можем отличить “не пришло вообще” (будет null) от “пришёл 0” (явно неверное значение).
Тонкая вещь, которую полезно помнить: @Min/@Max проверяют диапазон после того, как значение стало числом. То есть если клиент прислал "minutes": "abc", это вообще не validation-case — это уже ошибка конвертации JSON в DTO. Bean Validation туда даже не дойдёт, потому что DTO не будет создано. В рамках сегодняшней лекции нам важно просто разделять эти два мира: “не смогли прочитать число” и “число прочитали, но оно вне диапазона”.
6. Формат: @Pattern
Иногда ограничений “не пусто” и “не длиннее 80” недостаточно: строка должна иметь конкретный формат. Например, вы хотите, чтобы “код” состоял из латиницы, цифр и дефисов, потому что вы потом используете его в URL, в фильтрах, в интеграциях и не хотите сюрпризов вроде пробелов или эмодзи внутри идентификатора. Для таких случаев существует @Pattern — он проверяет соответствие строки регулярному выражению.
Но тут важно удержаться от соблазна: @Pattern — не место для сложной прикладной логики. Как только ваше регулярное выражение перестаёт помещаться в мозг без кофе и начинает выглядеть как заклинание на древнем языке, вы почти наверняка пытаетесь решить не ту задачу. Валидация формата должна быть простой и объяснимой.
Мини-пример “простого формата”:
import jakarta.validation.constraints.Pattern;
public record TagCodeRequest(
// Разрешаем только: латиница в нижнем регистре, цифры и дефис
// Такой код удобно использовать как slug/идентификатор в URL и фильтрах
@Pattern(regexp = "[a-z0-9-]+")
String code
) {}
Такой формат отлично подходит для slug-подобных строк: urgent, backend-2026, v1, todo-list. Если поле опциональное, то без @NotBlank оно может быть null — и это нормально: @Pattern не будет ругаться на null, потому что “проверять нечего”.
Ещё одна важная мелочь: @Pattern обычно проверяет всё значение целиком, поэтому регэксп должен быть “полным”. Например, [a-z]+ означает “строка из латиницы”, а не “строка, где где-то есть латиница”. Именно поэтому часто видите + (один или больше символов), а не * (ноль или больше), иначе пустая строка тоже пройдёт.
7. Валидация TaskCreateRequest и TaskPutRequest
Теперь соберём всё в проектный вид. У нас есть доменные правила: title обязателен и длиной 3.. 120, description может быть, но не длиннее 2000, assigneeName опционален, но до 80, а priority должен быть передан. Мы выражаем это прямо в request DTO — так контракт становится виден “на входе”, без поиска по сервисам и без чтения контроллеров.
Ниже — минимальная версия TaskCreateRequest (ровно те поля, на которых сегодня учимся). Обратите внимание на imports: в актуальном стеке Spring Boot 4 используются jakarta.validation.*, а не javax.validation.* из старых туториалов.
import com.example.tasktracker.domain.model.TaskPriority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Обязательный "человеческий" заголовок: нельзя пустоту/пробелы + ограничение длины
@NotBlank
@Size(min = 3, max = 120)
String title,
// Описание можно не передавать, но если передали — не больше 2000 символов
@Size(max = 2000)
String description,
// Исполнитель опционален, но не допускаем "простыню" в этом поле
@Size(max = 80)
String assigneeName,
// Приоритет обязателен: отсутствие поля или null должны ломать контракт на входе
@NotNull
TaskPriority priority
) {}
Для TaskPutRequest (полная замена mutable state задачи) ограничения обычно те же: если смысл поля одинаков, мы не выдумываем “второй набор правил”. Это делает API предсказуемым: создать задачу и потом заменить её через PUT можно в одном и том же формате и с теми же ограничениями.
import com.example.tasktracker.domain.model.TaskPriority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record TaskPutRequest(
// PUT = "замена", поэтому базовые требования к полям те же, что и в create
@NotBlank
@Size(min = 3, max = 120)
String title,
// Поле остаётся опциональным, но ограничение длины сохраняем
@Size(max = 2000)
String description,
// Аналогично: опционально, но с верхней границей
@Size(max = 80)
String assigneeName,
// В "полной замене" приоритет тоже обязателен
@NotNull
TaskPriority priority
) {}
Да, эти два DTO сейчас выглядят почти одинаково — и это нормально. Они решают разные задачи контракта (create vs replace), но базовые правила к полям у них общие. Позже (не сегодня) могут появиться различия из-за семантики частичного обновления или из-за расширения модели, но на текущем шаге курса нам важнее стабильность и предсказуемость, чем искусственная “уникальность классов”.
Валидные и невалидные запросы
Когда вы поставили constraint-аннотации, очень полезно уметь “на глаз” определять: этот payload пройдёт, а этот остановится ещё на границе контроллера. Это помогает и при разработке .http-сценариев, и при чтении логов, и просто при разговоре с фронтендом (“почему вы отправляете пустой title?”). Сейчас мы не обсуждаем, как именно будет выглядеть ответ об ошибке — только что считается валидным входом.
Пример валидного запроса на создание задачи:
### Создание задачи (валидный запрос)
# Все обязательные поля присутствуют, строки в допустимых границах, enum-значение корректное
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Сделать валидацию входа",
"description": "Добавить Bean Validation constraints в DTO и включить @Valid.",
"assigneeName": "Alex",
"priority": "HIGH"
}
title не пустой и достаточно длинный, description короче 2000, assigneeName короче 80, priority присутствует и попадает в enum. Такой запрос должен спокойно пройти в сервисный слой.
Теперь пример, который прочитается в DTO (JSON корректный), но не пройдет Bean Validation:
### Создание задачи (невалидный запрос)
# JSON корректный, но ограничения @NotBlank/@Size/@NotNull должны остановить запрос на валидации
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": " ",
"description": "x",
"assigneeName": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"priority": null
}
Здесь у нас сразу несколько нарушений: title состоит из пробелов — @NotBlank не пропустит; assigneeName длиннее 80 — @Size(max = 80) не пропустит; priority явно null — @NotNull не пропустит. И это как раз идеальная ситуация: запрос останавливается ещё до того, как мы начнём “создавать задачу” в сервисе.
Отдельно полезно помнить, что вот такой payload — это уже другая категория проблем:
### Создание задачи (ошибка конвертации)
# Здесь проблема не в Bean Validation, а в том, что значение не маппится в enum
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Нормальный заголовок",
"priority": "SUPER_IMPORTANT"
}
Тут priority не соответствует enum. DTO может не собраться (ошибка конвертации), и Bean Validation в привычном виде может даже не стартовать. То есть “невалидный enum” и “нарушение @NotNull” — это разные негативные сценарии, и это нормально: они ломаются на разных стадиях обработки запроса.
8. Типичные ошибки при выборе constraint-аннотаций
Ошибка №1: использовать @NotNull для обязательного текстового поля и удивляться, что пустая строка проходит.
Это очень частая ловушка: вроде бы “обязательное поле”, значит @NotNull, а потом в базу (или в память, в нашем случае) попадают задачи с title="". Для обязательного текста почти всегда нужен @NotBlank, потому что он запрещает и null, и пустоту, и “строки из пробелов”.
Ошибка №2: ожидать, что @Size(max = 80) делает поле обязательным.
@Size не отвечает за обязательность, он отвечает за длину. Если поле null, то проверка длины обычно считается успешной. Если вы хотите “обязательное и ограниченное по длине”, используйте связку вроде @NotBlank + @Size(...) для строк или @NotNull + @Min/@Max для чисел.
Ошибка №3: использовать примитивы (int, boolean) во входных DTO и потом пытаться проверять @NotNull.
Примитив не может быть null, поэтому смысл @NotNull теряется. Для request DTO чаще выбирают wrapper-типы (Integer, Boolean), чтобы отличать “значение не пришло” от “значение пришло и равно 0/false”. Это особенно важно, если вы реально хотите строгость контракта.
Ошибка №4: разные ограничения для одного и того же поля в create и update DTO без причины.
Если title — это “title задачи”, то его длина и обязательность не должны зависеть от того, вы создаёте задачу или обновляете. Иначе API получается непредсказуемым: клиенту приходится помнить два набора правил, а вы получаете больше багов “на ровном месте”.
Ошибка №5: пытаться запихнуть сложные правила в @Pattern, превращая регулярку в головоломку.
@Pattern хорош для простого формата (slug, code, “только латиница/цифры/дефис”). Но как только правило зависит от нескольких полей, от текущего состояния задачи или от данных в репозитории, регулярка перестаёт быть инструментом и становится наказанием. В такой ситуации лучше признать: это уже не “формат строки”, а другое правило (и не пытаться решать его регэкспом).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ