JavaRush /Курсы /Spring Boot /Bean Validation и fail-fast старт

Bean Validation и fail-fast старт

Spring Boot
18 уровень , 2 лекция
Открыта

1. Type-safe binding и корректность конфигурации

Если вы когда-нибудь видели конфиг вида max-featured-count: -999, то вы понимаете, что «оно же число!» — слабое утешение. Type-safe binding гарантирует, что значение можно преобразовать в нужный тип (int, boolean, LocalDate), но он не гарантирует смысла. А смысл — это то, что обычно и ломает приложения в реальности: пустой title, отрицательная длительность курса, отсутствующая валюта, null там, где дальше код ожидает «ну оно точно есть».

Для учебного catalog-service это особенно важно, потому что у нас нет базы данных. Фактически конфигурация — это наш «источник правды» для каталога. Если в базе данные обычно защищают constraints и миграции, то у нас «база» лежит в YAML. Значит, именно здесь мы должны поставить «турникет»: плохие данные не должны проходить дальше старта.

Fail-fast — это не «жёсткость ради жёсткости». Это экономия времени и нервов. Лучше получить понятную ошибку при запуске, чем потом ловить NPE где-то в обработке запроса /api/catalog/featured, вспоминая, кто и зачем поставил max-featured-count: 0 (и почему это вообще не запретили).

2. Bean Validation в Spring Boot

Когда говорят «валидация в Spring», новичок часто автоматически думает про @Valid в контроллере и красивые сообщения «поле обязательно». Но Bean Validation — это более общий механизм. Это стандарт (в мире Boot 4 — уже в пространстве имён jakarta.validation.*), который позволяет описывать ограничения прямо рядом с данными и затем централизованно проверять объект на соответствие этим ограничениям.

В контексте @ConfigurationProperties Bean Validation работает как «контроль качества на входе»: Boot связывает YAML с вашим record-объектом, а затем прогоняет этот объект через валидатор. Если ограничения нарушены, приложение не должно успешно подняться. И это логично: конфигурация — это «параметры сборки реальности» для приложения. Если реальность собрана криво, лучше не запускаться вообще.

Важно удержать границу: сегодня мы валидируем конфигурацию приложения, а не входящие HTTP-запросы. Валидация web-слоя — отдельная тема, там есть свои нюансы (и свои «как сделать так, чтобы клиент не плакал»). Здесь же клиент — это вы сами и ваш деплой, а задача — чтобы сервис не притворялся живым с некорректными настройками.

Подключаем spring-boot-starter-validation

Чтобы аннотации вроде @NotBlank и @Positive не были просто декоративными наклейками «я бы хотел, чтобы это было правильно», нужен validation provider на classpath. В Spring Boot это обычно Hibernate Validator, который подтягивается через starter spring-boot-starter-validation.

Обратите внимание на философию Boot: вы почти никогда не подключаете «один маленький jar для одной аннотации». Вы подключаете starter, который приносит согласованный набор зависимостей и дефолтов. Мы точно так же поступаем и здесь: добавляем один starter — и получаем рабочую валидацию.

В build.gradle.kts это выглядит так (версию не указываем: она управляется Boot BOM):

dependencies {
    // Провайдер Bean Validation (обычно Hibernate Validator) + автоконфигурация Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-validation")
}

Если вы забудете этот шаг, вы можете написать пол-романа из @NotNull и @NotBlank, но дальше будет грустно: приложение либо не будет валидировать конфигурацию так, как вы ожидаете, либо поведение окажется «странным», и вы начнёте подозревать Spring в колдовстве. На самом деле это будет просто отсутствие нужной зависимости.

3. Валидация @ConfigurationProperties

Теперь собираем «боевой комплект». Нам нужно две вещи: включить проверку для конкретного properties-класса и описать ограничения. В Spring Boot для @ConfigurationProperties чаще всего включение делается через аннотацию @Validated (это Spring-аннотация из org.springframework.validation.annotation.Validated), а ограничения — стандартные аннотации Bean Validation из jakarta.validation.constraints.

Ниже возьмём только тот кусок CatalogProperties, на котором проще всего увидеть механику включения валидации. Полный контракт проекта шире, но сам принцип от этого не меняется.

Минимальный пример: обязательный title

Начнём с простого. Заголовок каталога должен быть не пустым и не состоять из пробелов. Для строк это почти всегда @NotBlank, а не @NotNull.

package com.example.catalogservice.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import jakarta.validation.constraints.NotBlank;

@Validated // Включаем валидацию при биндинге конфигурации
@ConfigurationProperties("app.catalog") // Привязка к app.catalog.* в YAML
public record CatalogProperties(
        @NotBlank // Нельзя null/""/"   "
        String title
) {}

Почему не @NotNull? Потому что @NotNull не запрещает " ". И если вы когда-нибудь видели в интерфейсе заголовок из трёх пробелов, то вы знаете: формально это строка, но по смыслу — пустота и философский вопрос «что такое заголовок?».

Добавляем числовые ограничения: @Positive, @Min

В catalog-service у нас есть лимиты, например maxFeaturedCount. Это число обязано быть строго положительным. Вариантов два: @Min(1) или @Positive. Оба подходят, но @Positive читается чуть более «по-человечески».

package com.example.catalogservice.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

@Validated // Проверяем ограничения сразу на старте приложения
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        @NotBlank // title обязателен и не может состоять из пробелов
        String title,

        // Значение по умолчанию, если параметр не задан в YAML
        @DefaultValue("4")
        @Positive // Явно заданный 0 или -1 должен «забраковать» конфиг
        int maxFeaturedCount
) {}

Здесь видно интересное сочетание: default и validation работают вместе. Если значение не задано — будет 4, и оно проходит @Positive. Если значение задано явно, например 0, — default не используется, и валидация должна «забраковать» конфиг.

4. Каскадная валидация: @Valid для вложенных объектов

В реальном конфиге почти всегда есть вложенные структуры. У нас это список курсов, а внутри курса — price. Самая частая ошибка начинающих: они навешивают ограничения на вложенные record’ы, а потом удивляются, что ничего не проверяется. Причина в том, что Bean Validation по умолчанию валидирует только «верхний уровень». Чтобы валидатор «провалился внутрь», нужен @Valid.

Теперь смотрим не на весь корневой объект, а на тот участок, где validation особенно легко провалить: список курсов и вложенную цену.

Валидируем список курсов целиком и каждый элемент

Список курсов в учебном проекте должен быть не пустым (иначе сервис «каталог» превращается в философскую концепцию «каталог без элементов»). Для этого есть @NotEmpty. А чтобы проверялись элементы списка, добавляем @Valid.

package com.example.catalogservice.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;

@Validated // Включаем валидацию при создании объекта из YAML
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        @NotBlank // Человеко-читаемый заголовок каталога
        String title,

        @NotEmpty // Сам список не может быть пустым
        @Valid // А теперь ещё и проваливаемся внутрь и проверяем каждый CourseItem
        List<CourseItem> courses
) {}

Здесь @NotEmpty проверяет сам список (не null и не пустой), а @Valid говорит: «а теперь проверь каждый CourseItem внутри».

Валидируем поля CourseItem и вложенную цену

Сделаем конфигурационную модель одного курса. Здесь удобно использовать доменные enum’ы CourseTrack и CourseLevel, которые уже есть в проекте. Для них типобезопасность уже есть (не будет «SPRONG» вместо «SPRING»), но null всё ещё возможен, поэтому @NotNull остаётся полезным.

package com.example.catalogservice.config;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

import java.time.LocalDate;

import com.example.catalogservice.catalog.domain.CourseLevel;
import com.example.catalogservice.catalog.domain.CourseTrack;

public record CourseItem(
        @NotBlank // Технический идентификатор (не пустой и не из пробелов)
        String slug,

        @NotBlank // Название курса для UI
        String title,

        @Positive // Длительность должна быть строго > 0
        int durationDays,

        @NotNull // В YAML поле может отсутствовать => будет null
        CourseTrack track,

        @NotNull // Аналогично: отсутствие поля в YAML приведёт к null
        CourseLevel level,

        @NotNull // Дата старта обязательна
        LocalDate launchDate,

        @NotNull // Цена как объект должна присутствовать
        @Valid // И нужно провалиться внутрь MoneyProperties и проверить её поля
        MoneyProperties price
) {}

И отдельно — деньги:

package com.example.catalogservice.config;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record MoneyProperties(
        @Min(0) // «Бесплатно» разрешаем, отрицательные значения — нет
        long amount,

        @NotBlank // Валюта не может быть пустой строкой/пробелами
        String currency
) {}

Обратите внимание: @Valid стоит на price в CourseItem. Без него MoneyProperties будет создан, но его внутренние ограничения (currency и amount) могут не провериться каскадно — и вы обнаружите проблему уже позже, когда начнёте сериализовать JSON или строить текст в startup summary.

Быстрая «карта» ограничений для конфигурации

Чтобы не превращать лекцию в справочник, но всё же дать ориентир, полезно иметь простую таблицу. Это не список «всё что существует», а базовый набор, которого нам хватит для catalog-service.

Что валидируем в конфиге Тип Обычно используем Что считаем плохим
Название (title, slug, currency) String @NotBlank null, "", " "
Числовой лимит (maxFeaturedCount, durationDays) int / long @Positive или @Min(…) 0, отрицательные значения
Обязательный enum (track, level) enum @NotNull null (отсутствует в YAML)
Обязательная дата (launchDate) LocalDate @NotNull null
Список курсов (courses) List<…> @NotEmpty + @Valid null, пустой список, элементы с плохими полями
Вложенный объект (price) MoneyProperties @NotNull + @Valid отсутствует целиком или содержит плохие поля

5. Fail-fast старт: сломанный конфиг

Теперь самое интересное: что именно произойдёт, если конфигурация плохая? Ответ — приложение не должно стартовать. На практике это выглядит так: Boot загружает property sources, делает binding в CatalogProperties, запускает валидацию, и если есть нарушения, контекст не «refresh’нется» до готового состояния.

Удобно представить этот путь как мини-конвейер:

flowchart TD
    A[application.yaml / catalog-data.yaml] --> B["Binder: @ConfigurationProperties"]
    B --> C[CatalogProperties record создан]
    C --> D["Bean Validation: constraints + @Valid"]
    D -->|OK| E[Context refresh продолжается]
    D -->|Ошибки| F[Startup fails: приложение не поднялось]

Чтобы почувствовать, что это не теория, посмотрим на пример YAML, который «вроде похож на правду», но на самом деле плох:

app:
  catalog:
    title: "   "
    max-featured-count: 0
    courses:
      - slug: ""
        title: "Spring Boot"
        duration-days: -1
        track: "SPRING"
        level: "BASIC"
        launch-date: "2026-04-01"
        price:
          amount: -10
          currency: ""

Тут сразу несколько проблем: title из пробелов, max-featured-count нулевой, slug пустой, длительность отрицательная, цена отрицательная, валюта пустая. Если у вас нет конфигурационной валидации, приложение может стартовать, а потом ломаться в самых неожиданных местах. С валидацией оно упадёт сразу и достаточно честно скажет, какие поля нарушают правила.

И вот это и есть fail-fast как инженерная ценность. Мы не пытаемся «подлечить» конфиг в рантайме и не делаем вид, что «ну оно как-нибудь». Мы выбираем предсказуемость: либо конфиг валиден, либо сервиса нет.

6. Инварианты и @AssertTrue

Ограничения уровня поля (@NotBlank, @Positive) решают большую часть проблем, но иногда правило относится не к одному значению, а к взаимосвязи. Например, если у вас есть лимит maxFeaturedCount, логично требовать, чтобы он не превышал количество курсов в конфигурации. Это уже не «валидация одного поля», это проверка согласованности модели целиком — то, что мы называем инвариантом.

Для таких случаев можно использовать простой, но очень понятный инструмент — @AssertTrue. Он вешается на метод (или boolean-поле), который должен вернуть true, иначе валидация провалится.

Берём локальный срез модели: только те поля, между которыми реально есть зависимость.

package com.example.catalogservice.config;

import jakarta.validation.constraints.AssertTrue;

import java.util.List;

public record CatalogProperties(String title, int maxFeaturedCount, List<CourseItem> courses) {

    @AssertTrue(message = "maxFeaturedCount must be <= courses.size()") // Сообщение ошибки при нарушении инварианта
    public boolean isFeaturedLimitSane() {
        // Если courses вообще нет, пусть это поймают более точные @NotEmpty / @NotNull
        if (courses == null) {
            return true;
        }

        // Важно: инварианты должны быть быстрыми проверками, без I/O и тяжёлых вычислений
        return maxFeaturedCount <= courses.size();
    }
}

Так cross-field правило не маскирует более точную проблему вроде отсутствующего списка курсов: её честнее отдать @NotEmpty / @NotNull, а инвариант должен проверять именно согласованность уже присутствующих данных.

Здесь нет магии: метод возвращает boolean, вы описываете правило «истинности». Да, это похоже на if, только оформленный как часть контракта конфигурации. И это удобно: правило живёт рядом с моделью и срабатывает на старте, а не в сервисах, разбросанных по проекту.

Важно помнить, что инварианты нужно писать аккуратно. Если вы внутри такого метода начнёте делать тяжёлую работу (сетевые вызовы, чтение файлов, сложные расчёты), вы превратите валидацию конфигурации в мини-процессинг. Инвариант — это быстрые проверки согласованности данных.

7. Типичные ошибки при валидации @ConfigurationProperties

Ошибка №1: поставить ограничения, но забыть spring-boot-starter-validation.
В этот момент аннотации начинают напоминать табличку «не влезай — убьёт», приклеенную к проводам, которые не подключены к электричеству. Вы вроде написали @NotBlank, но реального валидатора в приложении нет. В итоге конфиг может пройти дальше, и вы будете ждать fail-fast, а получите fail-later (самый обидный жанр фейла).

Ошибка №2: забыть @Validated на корневом @ConfigurationProperties.
Ограничения на полях могут быть, но проверка не будет включена именно для binding’а конфигурации. В голове обычно это звучит так: «Но я же поставил @NotNull! Почему оно не сработало?» Потому что вы описали правила, но не сказали Boot’у: «пожалуйста, применяй их к этому объекту при старте».

Ошибка №3: провалиться в рекурсию “я валидирую всё, кроме вложенных объектов”, потому что нет @Valid.
Самый частый сценарий: CatalogProperties валидируется, courses не пустой — и вы довольны. А внутри CourseItem валюта пустая, сумма отрицательная, launchDate отсутствует. Если на коллекции и на вложенных объектах нет @Valid, валидатор не обязан ходить внутрь. В результате вы получаете иллюзию безопасности: «корень проверили», а самое интересное осталось без контроля.

Ошибка №4: перепутать @NotNull и @NotBlank для строк.
@NotNull — это про «объект существует». Для строки этого мало, потому что " " — это не null, но для пользователя (и для вас через неделю) это почти всегда мусор. Поэтому для текстовых параметров конфигурации чаще всего нужен именно @NotBlank. Это маленькая деталь, которая убирает большой класс «странных» проблем.

Ошибка №5: сделать ограничения слишком строгими и заблокировать нормальные сценарии.
Иногда в порыве сделать «как в проде» хочется запретить вообще всё, что не идеально. В учебном сервисе это часто приводит к тому, что вы не можете стартовать приложение с минимальным конфигом для локальной разработки. Валидация должна защищать инварианты, но не превращаться в бюрократию. Если параметр действительно опциональный, ему лучше дать @DefaultValue и мягкое ограничение, чем требовать его всегда.

1
Задача
Spring Boot, 18 уровень, 2 лекция
Недоступна
Fail-fast для некорректных корневых свойств
Fail-fast для некорректных корневых свойств
1
Задача
Spring Boot, 18 уровень, 2 лекция
Недоступна
Каскадная валидация списка и вложенной цены
Каскадная валидация списка и вложенной цены
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ