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) | |
|
| .properties-стиль | |
|
| ENV-змінна | |
|
| CLI-аргумент | |
|
Зверніть увагу: насправді 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 розвʼязує проблему відсутності, а не проблему сенсу. Тому обирати дефолти треба обережно, а значення — задавати осмислено.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ