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 не були просто декоративними наліпками «я би хотів, щоб це було правильно», потрібен провайдер Bean Validation на 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.

Тепер дивимося не на весь кореневий об’єкт, а на той фрагмент, де валідацію найпростіше зіпсувати: список курсів і вкладену ціну.

Перевіряємо список курсів цілком і кожен елемент

Список курсів у навчальному проєкті має бути непорожнім (інакше сервіс «каталог» перетворюється на філософську концепцію «каталог без елементів»). Для цього є @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 або формувати текст у стартовому підсумку.

Швидка «карта» обмежень для конфігурації

Щоб не перетворювати лекцію на довідник, але все ж дати орієнтир, корисно мати просту таблицю. Це не перелік усього, що існує, а базовий набір, якого нам вистачить для 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 завантажує джерела властивостей, виконує binding у CatalogProperties, запускає валідацію, і якщо є порушення, контекст не доходить до стану готовності.

Зручно уявити цей шлях як мініконвеєр:

flowchart TD
    A[application.yaml / catalog-data.yaml] --> B["Біндер: @ConfigurationProperties"]
    B --> C[Створено CatalogProperties record]
    C --> D["Bean Validation: обмеження + @Valid"]
    D -->|OK| E[Оновлення контексту триває]
    D -->|Помилки| F[Старт завершується помилкою: застосунок не піднявся]

Щоб відчути, що це не теорія, подивімося на приклад 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 має бути <= 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.
Обмеження на полях можуть бути, але саме для біндингу конфігурації перевірка не буде увімкнена. У голові зазвичай це звучить так: «Але я ж поставив @NotNull! Чому воно не спрацювало?» Тому що ви описали правила, але не сказали Spring 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 лекція
Недоступна
Каскадна валідація списку й вкладеної ціни
Каскадна валідація списку й вкладеної ціни
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ