JavaRush /Курсы /Spring REST & MVC /Constraint-аннотации для DTO задач

Constraint-аннотации для DTO задач

Spring REST & MVC
15 уровень , 3 лекция
Открыта

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, “только латиница/цифры/дефис”). Но как только правило зависит от нескольких полей, от текущего состояния задачи или от данных в репозитории, регулярка перестаёт быть инструментом и становится наказанием. В такой ситуации лучше признать: это уже не “формат строки”, а другое правило (и не пытаться решать его регэкспом).

1
Задача
Spring REST & MVC, 15 уровень, 3 лекция
Недоступна
Базовые ограничения для `TaskCreateRequest`
Базовые ограничения для `TaskCreateRequest`
1
Задача
Spring REST & MVC, 15 уровень, 3 лекция
Недоступна
Формат кода и числовой диапазон для купона
Формат кода и числовой диапазон для купона
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ