1. Конфігурація як дані
Коли ми говоримо про «конфігурацію застосунку», дуже легко уявити собі якийсь «пульт керування», де можна крутити ручки вже під час роботи. Насправді в більшості сервісів Spring Boot конфігурація — це знімок: під час старту застосунок читає значення, зв’язує їх із типами і далі живе з цим набором як із фактом біографії. Як дата народження: змінити її, звісно, можна спробувати, але світ від цього стане лише дивнішим.
Для нашого catalog-service конфігурація — майже як «база даних»: список курсів, ліміти для featured-підбірки, прапорці поведінки. Це означає, що конфігураційна модель — це контракт, який має бути передбачуваним і читабельним. Якщо контракт можна випадково змінити після старту, ми отримуємо застосунок, чия поведінка починає нагадувати: «ну… воно інколи так робить». А це, повірте, найгірший жанр backend-літератури.
Є ще один практичний момент: застосунок Spring Boot — багатопотоковий. Ваш контролер обробляє запити паралельно, сервіси викликаються в різних потоках, а конфігурація при цьому використовується всюди. Конфіг, який хтось випадково мутує, перетворюється на лотерею. І так, лотерея — це класно, але зазвичай не в продакшені й не в дедлайнах.
Тому сьогоднішня думка проста: конфігураційна модель має бути read-only, тобто незмінною після того, як Boot її зв’язав і контекст стартував.
2. Ризики mutable properties із setters
Класичний стиль @ConfigurationProperties зі «старої школи» (або з автогенератора в голові) виглядає як JavaBean: приватні поля, getXxx(), setXxx(). Він робочий, і Spring Boot із задоволенням його заповнить. Проблема не в тому, що він «неправильний», а в тому, що він залишає забагато свободи там, де вона не потрібна.
Подивімося на мініверсію CatalogProperties в mutable-стилі (спрощено, лише два поля — нам важлива форма, а не фінальна структура):
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public class CatalogProperties {
// Це значення конфігурації, які Spring Boot заповнить під час старту застосунку.
private String title;
private int maxFeaturedCount;
// Геттери й сеттери формують стиль JavaBean.
// Саме тут і ризик: об’єкт можна змінити після старту застосунку.
public String getTitle() { return title; }
// Сетер дає будь-якому коду змогу "підкрутити" конфігурацію під час роботи застосунку.
public void setTitle(String title) { this.title = title; }
public int getMaxFeaturedCount() { return maxFeaturedCount; }
// На практиці саме такі сеттери й перетворюють конфіг на "лотерею".
public void setMaxFeaturedCount(int maxFeaturedCount) { this.maxFeaturedCount = maxFeaturedCount; }
}
Якщо ви дивитеся на це як розробник, який щойно починає звикати до Spring, мозок каже: «Ну, нормально, звичайний POJO». І ось тут починається небезпечна частина: ви звикаєте, що об’єкт можна змінювати. Причому змінювати може не лише ваш код, а й будь-який інший компонент, який отримав посилання на CatalogProperties.
Уявіть собі реалістичний сценарій рівня «зробив швидко й забув»: хтось у сервісі вирішив підкрутити ліміт, щоб «на локальній машині бачити більше featured»:
// Отримали конфіг із DI (наприклад, через конструктор).
CatalogProperties props = /* отримали з DI */;
// "Невинна" правка: змінюємо конфігурацію просто під час роботи застосунку.
props.setMaxFeaturedCount(999);
// Логи покажуть нове значення — і це виглядатиме "легально".
System.out.println(props.getMaxFeaturedCount()); // 999
А тепер уявіть, що цей самий сервіс живе в застосунку, який обробляє реальні запити. Один запит «підкрутив», інший уже отримав іншу поведінку. Жодних помилок, жодних винятків — просто ваш сервіс став «творчою особистістю».
Є і тонша проблема: стиль JavaBean припускає, що об’єкт спочатку створюється порожнім, а потім покроково заповнюється. Навіть якщо Spring робить це акуратно, сама форма моделі підштовхує до відчуття: «об’єкт може бути напівпорожнім і напівзаповненим». А конфігурація так працювати не повинна. Вона має бути або валідною і повною, або застосунок має чесно впасти на старті.
І останнє: наявність сеттерів зазвичай призводить до того, що в конфігураційну модель починають протягувати «зручні методи», а потім — і бізнес-логіку. Спочатку «а давайте порахуємо featured-курси прямо тут», потім «а давайте тут же зробимо пошук за slug», і ось ваш properties-клас раптом став сервісом, тільки без анотації @Service і без відчуття відповідальності.
3. Незмінна конфігураційна модель
Незмінна модель — це коли об’єкт створюється один раз, отримує всі значення одразу і після цього не може змінити свій стан. Це не релігія final, а дуже прагматична інженерна звичка: якщо дані мають бути стабільними, ми не даємо коду можливості «випадково» зробити їх нестабільними.
У контексті @ConfigurationProperties це означає простий контракт. Boot стартує, читає application.yaml та інші джерела, зв’язує значення й отримує об’єкт-конфігурацію. Далі сервіси та контролери цей об’єкт лише читають. Не «читають і інколи підправляють», не «тимчасово змінюють», а просто читають. Як довідник.
З погляду підтримки та налагодження це майже магія, тільки добра. Коли вам прилітає баг «чому featured-курсів стало 999», ви хоча б знаєте: якщо модель immutable, то це не «хтось десь змінив об’єкт». Отже, або конфіг так задано, або binding так зібрав, або ви самі вистрілили собі в ногу трохи раніше. Але це хоча б обмежений набір варіантів, а не нескінченний серіал.
Важливо розуміти: immutable-модель не забороняє вам змінювати конфігурацію між запусками. Змінювати YAML, активувати інший профіль, передати іншу env var — будь ласка. Але під час одного запуску конфігурація має бути стабільною.
4. Java record для @ConfigurationProperties
Record у Java — це можливість, яка нарешті чесно сказала: «Друзі, інколи вам потрібен клас, який просто зберігає дані, без усієї цієї багатослівної церемонії». Для конфігурації це майже ідеальний збіг: конфігураційний об’єкт саме data-shaped.
Ось той самий CatalogProperties, але як record:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
// Компоненти record — це і є контракт конфігурації.
String title,
int maxFeaturedCount
) {
// Сеттерів немає: після створення об’єкт не можна "підкрутити".
}
Що ми отримуємо «безкоштовно», навіть не написавши жодного рядка логіки?
По-перше, у record немає сеттерів. Це одразу знімає цілий клас проблем. Якщо хтось захоче змінити maxFeaturedCount після старту — йому доведеться створити новий об’єкт. А оскільки цей об’єкт створює Spring і кладе в контейнер, «просто так» змінити його вже не вийде.
По-друге, весь контракт видно в заголовку. Не потрібно гортати вниз і шукати поля, сеттери, геттери. Очі бачать: title і maxFeaturedCount. Точка. Для junior-розробника це особливо цінно: менше шансів загубитися в boilerplate-коді.
По-третє, record автоматично генерує equals(), hashCode(), toString(). Для конфігурації це зручно хоча б тим, що такий об’єкт простіше логувати. Але з логуванням треба бути обережними й не друкувати чутливі дані; у нас catalog-service секретів не зберігає, тому в навчальному проєкті це безпечніше, ніж у реальному житті.
По-четверте, record — це final-тип, а його компоненти — private final поля. Тобто сама структура підштовхує до думки: «це дані, їх не треба змінювати».
І ще одна важлива думка: record не робить ваш проєкт «functional», «reactive» чи занадто розумним. Він робить проєкт читабельним. І це, чесно кажучи, найнедооціненіша фіча сучасної Java.
Невелике порівняння, щоб краще закріпити:
| Властивість | Mutable JavaBean (class + сеттери) | Незмінний record |
|---|---|---|
| Можна змінити значення після старту | Так, легко й непомітно | Ні, сеттерів немає |
| Контракт видно одразу | Ні, потрібно читати клас | Так, у заголовку record |
| Boilerplate | Багато | Майже немає |
| Модель мислення | «Об’єкт змінюється» | «Дані зафіксовані» |
5. Перехід CatalogProperties на record
Тепер прив’яжемо ідею до нашого проєкту. У нас уже є CatalogProperties, які лежать у пакеті config і пов’язані з префіксом app.catalog. Типізоване зв’язування в нас уже є: замість розсипу рядків ми отримуємо в коді нормальні типи. Тепер важливо зробити цю модель ще й стійкою за формою.
У реальному проєкті CatalogProperties поступово стає доволі багатою: заголовок каталогу, прапорці поведінки та список курсів. Але на початку переходу на record зручно зробити мінімальну версію й подивитися на принцип. Наприклад, залишимо title і ліміт. Цього достатньо, щоб побачити сам перехід на record; повний контракт проєкту буде ширшим — із прапорцями, списком курсів і перевірками узгодженості.
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
// Назва каталогу (те, що потім можна віддати у зовнішнє API/сторінку).
String title,
// Ліміт на featured-підбірку (скільки карток показуємо).
int maxFeaturedCount
) {}
І конфіг для цього виглядатиме дуже звично:
app:
catalog:
# Людиночитабельний заголовок каталогу
title: "Spring+ Catalog"
# relaxed binding: max-featured-count -> maxFeaturedCount
max-featured-count: 4
Зверніть увагу на приємний момент: YAML-ключ max-featured-count спокійно зв’яжеться з maxFeaturedCount. Це той самий relaxed binding, який уже робить конфігурацію читабельною для людини, а модель — зручною для Java-коду.
Далі, щоб використовувати properties, ми просто інжектимо їх у сервіс і читаємо значення. І тут ви вперше відчуєте психологічний ефект records: рука потягнеться зробити getMaxFeaturedCount(), але record каже: «Ні-ні, друже, це дані, ось тобі maxFeaturedCount()».
import com.example.catalogservice.config.CatalogProperties;
import org.springframework.stereotype.Service;
@Service
public class FeaturedCourseService {
// Тримаємо посилання на конфігурацію як на дані лише для читання.
private final CatalogProperties properties;
// Конструкторна інʼєкція: Spring віддає вже "зібраний" об’єкт.
public FeaturedCourseService(CatalogProperties properties) {
this.properties = properties;
}
public int featuredLimit() {
// У record немає getXxx(): звертаємося через accessor компонента.
return properties.maxFeaturedCount();
}
}
Тут нам важливий не сам механізм зв’язування, а результат: після зв’язування конфіг перетворюється на об’єкт, який неможливо «підкрутити» сетером. А сервіси навколо нього стають простішими: вони читають готові значення й виконують свою роботу, не перетворюючись на другий шар конфігурації.
Ще один маленький, але корисний психологічний нюанс: коли об’єкт конфігурації незмінний, ви перестаєте думати: «А раптом він зміниться?». І це спрощує розуміння коду сильніше, ніж здається.
6. Вкладені секції: records усередині records
Майже будь-яка конфігурація в живому сервісі досить швидко перестає бути плоскою. Ви додаєте прапорці старту, секцію даних, секцію поведінки, і якщо все тримати одним списком полів, виходить простирадло. А простирадло — це погано: у ньому легко втратити сенс, особливо якщо ви junior і поки не звикли читати великі моделі.
records хороші тим, що дозволяють описувати вкладеність прямо в типах, не втрачаючи структуру YAML. Нижче нам важливий сам прийом групування: якщо секція має власний сенс, її можна винести окремо. Це не означає, що кожен одиночний прапорець зобов’язаний жити у вкладеному recordі.
Наприклад, нехай у каталогу буде вкладена секція startup:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
// Основні дані каталогу
String title,
// Вкладена група налаштувань (startup.*)
StartupProperties startup
) {
// Вкладений record допомагає тримати конфігурацію структурованою.
public record StartupProperties(
// Прапорець: чи друкуємо звіт під час старту застосунку
boolean reportEnabled
) {}
}
Під таку модель YAML читається майже як «псевдокод налаштувань»:
app:
catalog:
# Загальний заголовок каталогу
title: "Spring+ Catalog"
startup:
# Увімкнути стартовий звіт (наприклад, логувати склад курсів)
report-enabled: true
І ось тут з’являється дуже правильна звичка: ви починаєте проєктувати конфігурацію як ієрархію сенсів. Не «ось купа ключів», а «ось налаштування каталогу, всередині них — налаштування старту». У великих сервісах це економить десятки годин на підтримку, тому що знайти потрібне місце стає простіше.
Єдине, про що важливо пам’ятати (і це ще повертатиметься до нас сьогодні): properties-модель має залишатися data-only. Вкладені records — це про структуру, а не про «давайте запхаємо туди бізнес-логіку». У CatalogProperties ми описуємо дані конфігурації, а не те, як саме за ними будувати відповідь API.
7. Аксесори й звичка працювати лише з читанням
Якщо ви раніше жили у світі JavaBean, ваш автопілот любить методи getTitle() і isPublished(). У record інша філософія: компонент title дає метод title(). Це виглядає незвично перші хвилин десять, а потім мозок перестає це помічати й починає навіть радіти, бо шуму в коді менше.
Наприклад, якщо вам потрібно віддати заголовок каталогу на landing page, ви пишете не properties.getTitle(), а properties.title(). Здається дрібницею, але вона постійно нагадує: це дані, а не об’єкт із життєвим циклом, який ми змінюємо.
// Отримали конфігурацію з DI: далі лише читаємо.
CatalogProperties props = /* отримали з DI */;
// Accessor'и record збігаються з іменами компонентів.
System.out.println(props.title()); // Spring+ Catalog
System.out.println(props.maxFeaturedCount()); // 4
Ще одна думка: record захищає від прямої мутації полів, але не робить магію абсолютної незмінності в усіх випадках. Якщо всередині record лежить, наприклад, List<CourseItem>, то сам список може залишатися змінюваним (це залежить від того, що саме створив binder). Тому правильна звичка звучить так: до конфігурації ставимося як до даних лише для читання, навіть якщо технічно Java дозволяє зробити щось погане.
Іноді корисно пам’ятати просте правило: конфіг — це не місце, де ми «виправляємо світ», це місце, де ми «дізнаємося, який світ нам дали».
8. Шлях значення від YAML до сервісу
Щоб не сприймати @ConfigurationProperties як «магічну анотацію», тримайте схему того, що відбувається в нашому catalog-service. Насправді всередині Boot багато кроків, але на рівні курсу нам достатньо цього ланцюжка:
flowchart TD
%% Ланцюжок: джерела конфігурації -> зв’язування -> типізований об’єкт -> використання в коді
A["application.yaml / імпортовані YAML / змінні середовища"] --> B["Spring Boot Binder"]
B --> C["CatalogProperties (record)"]
C --> D["Service / Controller"]
D --> E["Відповіді API та поведінка застосунку"]
Сенс records у цьому ланцюжку простий: вузол C стає контрактом, який зручно читати і складно зіпсувати. Ми не вдаємо, що помилки неможливі — помилки будуть (ми ж живі люди). Але ми зменшуємо кількість способів зробити помилку непомітною й довгограючою.
І це насправді головний інженерний виграш: не красивий синтаксис, а зменшення кількості прихованих станів. Коли конфігурація immutable, застосунок або стартує з зрозумілими значеннями, або не стартує. А от «стартує, але потім хтось змінив поля і воно почало жити своїм життям» — це якраз той режим, від якого ми відходимо.
9. Типові помилки під час переходу на records
Помилка №1: залишити properties-клас mutable «про всяк випадок».
Часто це виглядає невинно: «ну що такого, нехай буде setter, раптом знадобиться». Проблема в тому, що setter майже завжди «знадобиться» у найгіршому сенсі — хтось випадково ним скористається, і у вас з’явиться зміна конфігурації посеред роботи. Для конфігурації здоровіше виходити з моделі: створили один раз — далі читаємо.
Помилка №2: почати складати в properties-модель прикладні обчислення.
Records дозволяють писати методи, і це може спокусити. Спочатку з’являється featuredLimit(), потім publishedCourses(), потім «а давайте тут же шукати за slug». У цей момент конфігураційна модель перестає бути конфігурацією і стає напівсервісом. Конфіг має описувати дані. Логіку фільтрації та пошуку залишаємо сервісам у catalog.service.
Помилка №3: зробити один гігантський record «на весь застосунок».
Коли властивостей стає більше, виникає бажання «щоб усе було в одному місці». На практиці ви отримуєте record на 30–40 компонентів, який неможливо швидко прочитати. Краще зберігати вкладеність і дробити модель за змістом: startup, courses, limits, flags. Тоді YAML і Java-модель збігатимуться за структурою, а читання стане легшим.
Помилка №4: думати, що record автоматично робить незмінним усе всередині.
Record захищає поля (вони final), але якщо всередині лежить колекція, вона може бути змінюваною. Тому важливо тримати дисципліну: конфігурація — це дані лише для читання, і не робити операцій на кшталт properties.courses().clear(). Якщо потрібно посилити гарантію, це можна зробити пізніше акуратними прийомами, але насамперед вирішує саме звичка не мутувати конфіг.
Помилка №5: сприймати record як обов’язковий стиль для будь-яких класів проєкту.
Records — чудовий інструмент, але сьогодні ми використовуємо їх саме тому, що конфігурація за своєю природою data-shaped. Доменна модель (CourseCard, Money) і шар web/service можуть мати свої причини бути class або record по-різному. Не треба перетворювати «records для конфігурації» на «records всюди, бо це модно». Модно — минає, читабельність — залишається.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ