JavaRush /Курси /Spring Boot /Constructor binding та @Def...

Constructor binding та @DefaultValue

Spring Boot
Рівень 18 , Лекція 1
Відкрита

1. Constructor binding в immutable-моделі конфігурації Spring Boot

Коли ми переходимо до immutable-моделі властивостей, ми ніби змінюємо спосіб спілкування із застосунком. Раніше це було: «ось тобі порожній обʼєкт — заповнюй його, як зможеш, сетерами», а тепер: «ось форма замовлення, і поки не заповнені всі поля, замовлення не оформлюється». Для конфігурації це особливо важливо, бо вона має бути даними, а не станом, що поступово наповнюється.

У старому mutable-стилі — клас плюс сетери — Spring Boot міг діяти за схемою «створити обʼєкт → викликати setXxx(...) для кожної властивості». Це називається binding через JavaBeans-підхід. Він робочий, але має побічний ефект: обʼєкт формально може існувати в напівзаповненому стані. А якщо десь у коді ви почнете його змінювати, то й після запуску.

Із record так не вийде. У record немає сеттерів, а значення задаються один раз у момент створення. Тому Spring Boot робить те, що логічно: бере кінцеві значення властивостей із Environment і викликає конструктор CatalogProperties(...). Це і є constructor binding: обʼєкт конфігурації збирається цілком і одразу.

Невелика схема того, що відбувається в Boot, — сильно спрощена, але корисна:

flowchart TD
    A["Джерела властивостей: YAML, профілі, змінні середовища, аргументи CLI"] --> B["Binder у Spring Boot"]
    B --> C["Перетворення типів: String → int/boolean/Duration/..."]
    C --> D["Виклик конструктора / record components"]
    D --> E["Готовий immutable-обʼєкт: CatalogProperties"]
    E --> F["DI: сервіси отримують його через конструктор"]

Тут важлива думка: Binder не «мутує» ваш обʼєкт, він його створює. А отже, будь-які значення за замовчуванням, які вам потрібні, теж треба формулювати так, щоб обʼєкт можна було зібрати передбачувано.

2. Constructor binding і record

Поняття constructor binding легко сплутати з constructor injection. І це нормальна плутанина: обидва терміни звучать як «щось із конструктором», а мозок розробника-початківця і так зайнятий тим, щоб не переплутати @Service з @Servlet. Давайте розкладемо все спокійно і по суті.

Constructor injection — це DI-історія: як Spring передає залежності вашому класу. Наприклад, CourseCatalogService отримує CatalogProperties у конструкторі.
Constructor binding — це конфігураційна історія: як Spring Boot створює сам CatalogProperties, коли читає app.catalog.* із конфігурації.

У record constructor binding особливо прозорий, тому що record components і є контрактом даних. Ось мінімальна версія:

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog") // Кажемо Boot, що це конфігурація з префіксом app.catalog
public record CatalogProperties(
        String title,          // Значення буде взято з app.catalog.title
        int maxFeaturedCount   // Значення буде взято з app.catalog.max-featured-count (relaxed binding)
) {}

Якщо в конфігурації є:

app:
  catalog:
    title: "Spring+ Catalog"        # Заголовок каталогу
    max-featured-count: 4           # Ліміт для featured-блоку (kebab-case у YAML)

то Boot «мисленно» робить приблизно це:

// Ніби binder обчислив кінцеві значення і просто викликав конструктор record
new CatalogProperties("Spring+ Catalog", 4);

І далі цей обʼєкт стає біном у контексті, тож будь-який сервіс може отримати його через звичайне впровадження залежностей.

Крім того, record розвʼязує одну дуже практичну проблему: імена компонентів у record відомі завжди — це частина мови. У звичайних класів із конструктором імена параметрів іноді губляться під час компіляції, якщо не зберігати назви параметрів, і тоді binder починає сумувати. Із record binder майже ніколи не сумує — він бачить імена title і maxFeaturedCount як частину типу.

3. Relaxed binding: YAML і параметри конструктора

Дивлячись на YAML, можна подумати: «який ще maxFeaturedCount, я ж писав max-featured-count». І тут зʼявляється один із найкорисніших і найпривітніших механізмів Spring Boot — relaxed binding. Він потрібен, щоб ви могли писати властивості читабельно, зазвичай у kebab-case, а в Java мати звичні camelCase-імена.

Сенс relaxed binding такий: Boot уміє вважати, що ці варіанти — «одне й те саме імʼя», просто записане в різних стилях:

Де Приклад Що отримуємо в Java
YAML (kebab-case)
max-featured-count
maxFeaturedCount
.properties-стиль
maxFeaturedCount
maxFeaturedCount
ENV-змінна
APP_CATALOG_MAX_FEATURED_COUNT
maxFeaturedCount
CLI-аргумент
--app.catalog.max-featured-count=10
maxFeaturedCount

Зверніть увагу: насправді binder робить дві речі. Спочатку він нормалізує імʼя властивості, а потім зіставляє його з іменем параметра конструктора або record component. Для нас як для розробників корисно тримати в голові просте правило: у Java ви називаєте компоненти нормально, у YAML пишете нормально, а Boot працює як перекладач між двома світами.

Невеликий приклад із нашого catalog-service, де особливо помітна цінність цього перекладача:

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog") // Префікс для всіх властивостей нижче
public record CatalogProperties(
        String title,                   // app.catalog.title
        boolean defaultPublishedOnly     // app.catalog.default-published-only
) {}

У YAML це буде:

app:
  catalog:
    title: "Spring+ Catalog"            # Читабельний заголовок
    default-published-only: true        # Налаштування в kebab-case

І це виглядає по-людськи: YAML читається майже як речення, а Java залишається Java.

4. Відсутні властивості та «тихий нуль»

Коли ви вперше робите immutable-конфігурацію, найнеприємніший сюрприз виглядає так: ви забули додати властивість, застосунок стартував, і… поводиться дивно. Жодних помилок, просто «щось не так». Це не магія Spring, а звичайна логіка типів, особливо примітивів.

Уявімо, що у вас є такий record:

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog") // Читаємо налаштування з app.catalog.*
public record CatalogProperties(
        String title,        // Якщо відсутнє — буде null
        int maxFeaturedCount // Якщо відсутнє — буде 0 (дефолт примітива)
) {}

А в YAML ви випадково забули max-featured-count. Що має зробити Binder? Йому потрібно викликати конструктор, а конструктор вимагає int. int не може бути null. Тому виходить дуже «тиха» ситуація: binder підставляє 0 — бо 0 є дефолтом примітива — і обʼєкт успішно створюється.

І ось це — головне джерело дивних багів із конфігурацією у новачків. Бо 0 для ліміту «скільки featured-курсів показати» — цілком валідне число, але майже напевно не те, що ви хотіли.

Щоб відчути це руками, можна вивести значення на старті. У нашому проєкті для цього вже підходить стартовий раннер, який ми створили раніше:

import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component // Робимо раннер біном, щоб він виконався на старті застосунку
public class StartupSummaryRunner implements ApplicationRunner {

    private final CatalogProperties properties;

    public StartupSummaryRunner(CatalogProperties properties) {
        // Конфігурацію також отримуємо через конструктор (це вже constructor injection, а не binding)
        this.properties = properties;
    }

    @Override
    public void run(org.springframework.boot.ApplicationArguments args) {
        // Демонстрація: за відсутності maxFeaturedCount у YAML тут буде 0
        System.out.println("maxFeaturedCount = " + properties.maxFeaturedCount()); // maxFeaturedCount = 0
    }
}

Тут важливий висновок: constructor binding сам по собі не гарантує осмислені значення. Він гарантує, що обʼєкт можна зібрати, але не гарантує, що ви не зібрали його з випадкових нулів і null.

Із String-полями схожа історія, тільки замість 0 ви отримаєте null. І це теж буде тихо, доки не станеться NPE десь у несподіваному місці.

Звідси випливає здоровий принцип проєктування конфігурації: якщо значення справді опціональне, йому потрібен зрозумілий fallback; якщо значення обовʼязкове, краще не маскувати його відсутність випадковими дефолтами мови.

5. @DefaultValue: явні значення за замовчуванням поруч із параметром

Щойно ви побачили «тихий нуль», рука сама тягнеться написати щось на кшталт int maxFeaturedCount = 4;. Але в record так не зробиш, а навіть якби й зробили через кастомний конструктор, це вже була б не модель даних, а модель із логікою. Spring Boot дає для цього акуратніший інструмент: @DefaultValue.

Ідея дуже проста: ви говорите binderʼу, що робити, якщо властивість відсутня. Причому робите це прямо поруч із тим параметром, до якого це стосується. Це схоже на значення за замовчуванням у формі реєстрації: якщо користувач не заповнив поле «місто», ми підставимо «Токіо». Тільки у нас користувач — це конфіг.

Приклад для нашого ліміту featured-курсів:

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        String title,
        @DefaultValue("4") int maxFeaturedCount // Якщо app.catalog.max-featured-count відсутнє, використовуємо 4
) {}

Тепер, якщо в YAML немає max-featured-count, binder підставить рядок "4", сконвертує його в int і викличе конструктор як new CatalogProperties(title, 4).

Корисний момент: @DefaultValue приймає рядок, але Boot уміє конвертувати його в цільовий тип за тими самими правилами, що й звичайні властивості. Тому це працює не лише для чисел і булевих значень, а й для типізованих значень на кшталт Duration.

Наприклад, якщо ви додали в конфігурацію невелике налаштування таймінгу, то можна зробити так:

import java.time.Duration;
import org.springframework.boot.context.properties.bind.DefaultValue;

public record StartupReportProperties(
        @DefaultValue("2s") Duration delay // Якщо delay не задано, вважаємо, що затримка становить 2 секунди
) {}

І тоді в YAML можна за бажанням написати delay: 5s, а якщо не написати — буде 2s. Тут важливо, що формат "2s" — це той самий формат, який ви б використали в конфігурації.

Ще одна ключова деталь: явне значення з конфігурації завжди важливіше за default. Тобто якщо ви все-таки задасте:

app:
  catalog:
    max-featured-count: 10 # Явно задане значення перекриває @DefaultValue("4")

то буде 10, а не 4. @DefaultValue — це саме «план Б», а не «мені байдуже, що там у YAML».

6. Де тримати дефолти: у YAML чи в Java

На цьому місці зазвичай зʼявляється спокуса: «О! Давайте зробимо defaults у Java, а YAML буде крихітним». А потім виникає друга спокуса: «А давайте ще й у YAML продублюємо, щоб точно було видно». І ось тут народжується легендарний конфігураційний звір — два джерела правди, який живиться вашим часом і нервами.

Добра конфігураційна модель зазвичай обирає усвідомлене місце для дефолтів, а не варіант «трохи тут і трохи там». У реальності в дефолтів є два нормальні місця: або в коді поруч із параметром (@DefaultValue), або в YAML як частина базової конфігурації проєкту.

Зручно порівняти так:

Підхід Коли доречний Переваги Недоліки
Default у Java (@DefaultValue) Коли значення опціональне і має бути однаковим усюди Контракт поруч із типом, менше шуму в YAML Можна сховати важливий факт від того, хто читає лише YAML
Default у YAML (application.yaml) Коли ви хочете, щоб базовий конфіг був самодостатнім Усе видно в конфігу, зручно змінювати без перекомпіляції YAML розростається, легко плодити дублікати за профілями
Без default узагалі Коли значення справді обовʼязкове Помилка неповного конфігу спливає раніше Потрібно дисципліновано задавати це значення під час запуску

У нашому навчальному проєкті catalog-service логіка зазвичай така: прапорці й ліміти, які «розумні за замовчуванням», зручно тримати прямо в моделі через @DefaultValue. А те, що описує вміст каталогу — список курсів, їхні ціни та дати — це вже дані, які мають жити в YAML, бо це зміст застосунку, а не інженерне налаштування.

Найголовніше правило тут звучить нудно, але працює як швейцарський годинник: default має бути осмисленим. Якщо ви пишете @DefaultValue("123") просто тому, що «ну треба ж щось підставити», ви не задаєте default — ви ховаєте майбутню проблему.

7. Оновлюємо CatalogProperties у catalog-service

Тепер зберемо це в реальний крок усередині проєкту. Нижче — не весь конфігураційний контракт catalog-service, а ближчий до нього зріз. На ньому зручно побачити, як поруч живуть обовʼязкові поля, defaults і список курсів. Перевірки коректності та інваріанти застосовуватимуться до цієї ж моделі, а не до якогось окремого «іншого конфігу».

Приклад цілком здорової версії CatalogProperties з дефолтами для опціональних прапорців і лімітів:

import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties("app.catalog") // Усі поля нижче — з app.catalog.*
public record CatalogProperties(
        String title, // Зазвичай обовʼязкове поле: якщо не задано, далі легко отримати NPE або порожній інтерфейс

        @DefaultValue("false")
        boolean maintenanceMode, // Якщо властивість відсутня — maintenance вимкнено

        @DefaultValue("4")
        int maxFeaturedCount, // Якщо властивість відсутня — показуємо 4 елементи в добірці

        @DefaultValue("true")
        boolean defaultPublishedOnly, // Безпечна поведінка за замовчуванням: показувати лише опубліковане

        @DefaultValue("true")
        boolean startupReportEnabled, // За замовчуванням можна залишати увімкненим для діагностики в навчальному проєкті

        List<CourseItem> courses // Список зазвичай задають у YAML як "дані застосунку"
) {}

Цього вже достатньо, щоб побачити, як constructor binding збирає кореневий обʼєкт цілком. Саму якість значень — це окреме питання; його ми закриватимемо validationʼом і інваріантами.

Зверніть увагу на сенс, а не на анотації. Ми робимо дві речі.

По-перше, ми говоримо: «у звичайному режимі maintenance вимкнено». Якщо його не вказали — усе одно вимкнено. Це зручно, бо випадкове вмикання maintenance-mode набагато небезпечніше, ніж випадкове вимикання.

По-друге, ми задаємо базові значення, які впливають на поведінку каталогу. Наприклад, defaultPublishedOnly = true означає: якщо клієнт не уточнив фільтр, ми показуватимемо лише опубліковані курси. Це безпечна поведінка для read-only каталогу.

Тепер сервіс, який використовує ці налаштування, може бути помітно простішим. Наприклад, якщо ви обмежуєте featured-добірку, вам більше не потрібно думати: «А що, якщо ліміт 0 через те, що властивість забули?» Ви або отримаєте значення з конфігурації, або зрозумілий default:

import org.springframework.stereotype.Service;

@Service // Звичайний сервіс, який використовує вже зібрані CatalogProperties
public class FeaturedCourseService {

    private final CatalogProperties properties;

    public FeaturedCourseService(CatalogProperties properties) {
        // Тут ми отримуємо готовий бін із налаштуваннями (обʼєкт уже створено binderʼом раніше)
        this.properties = properties;
    }

    public int featuredLimit() {
        // Єдина точка отримання ліміту: або значення з YAML, або @DefaultValue
        return properties.maxFeaturedCount();
    }
}

Якщо ви не задали max-featured-count, метод поверне 4. Якщо задали 10 — поверне 10. Поведінка не «плаває» через те, що хтось забув рядок у YAML.

І ще один тонкий момент: @DefaultValue не захищає від явно заданих дивних значень. Якщо ви напишете в YAML max-featured-count: 0, то буде 0. Default — це fallback на випадок відсутності значення, а не «охоронець сенсу». Тому defaults і якість значень — це дві різні теми, і їх важливо не змішувати в голові.

8. Типові помилки з @DefaultValue

Помилка №1: плутати constructor binding і constructor injection.
Дуже поширена плутанина: студент чує «конструктор» і думає, що це про DI. У результаті він намагається впровадити Environment у CatalogProperties або, навпаки, очікує, що @DefaultValue якось впливає на звичайні сервіси. Тримайте просту межу: binding — це створення обʼєкта конфігурації зі властивостей, injection — це роздавання готових обʼєктів по застосунку.

Помилка №2: залишати примітиви без @DefaultValue, а потім дивуватися «магічним нулям».
Якщо поле int або boolean і властивість відсутня, ви часто не отримаєте помилку — ви отримаєте 0/false. Це не баг Spring, а базова математика Java. У properties-моделі примітив без default — це запрошення до тихих сюрпризів.

Помилка №3: ставити defaults на обовʼязкові поля «щоб застосунок хоч якось стартував».
Якщо ви поставили @DefaultValue("Catalog") на title, то застосунок, звісно, стартує. Але ви самі собі підклали міну: в одному середовищі ви хотіли "Spring+ Catalog", а в іншому отримали "Catalog", бо десь забули конфіг. Якщо поле справді важливе, краще не підміняти його значенням за замовчуванням.

Помилка №4: дублювати один і той самий default і в Java, і в YAML.
Коли @DefaultValue("4") стоїть у коді, а в YAML написано max-featured-count: 4, ви отримуєте запитання без відповіді: «А яке з цих місць є справжнім?» Найнеприємніше, що за місяць хтось змінить YAML на 6, а в коді забуде — і почнеться класична суперечка: «Чому в одному запуску 4, а в іншому 6?» Краще одне джерело правди.

Помилка №5: думати, що @DefaultValue — це перевірка коректності.
Default не рятує, якщо значення задане, але воно погане. Якщо в YAML поставити max-featured-count: -10, default не ввімкнеться — значення ж є. @DefaultValue розвʼязує проблему відсутності, а не проблему сенсу. Тому обирати дефолти треба обережно, а значення — задавати осмислено.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ