1. Що переносимо в YAML
Якщо подивитися на живий catalog-service, то в ньому вже є вебшар, сервіс, репозиторій і стартова логіка через ApplicationRunner. У кожному такому шарі легко завести «зручні маленькі константи»: ліміт featured-курсів, типовий фільтр published-only, заголовок каталогу, прапорець maintenance-mode. Вони виглядають безневинно — рівно доти, доки ви не захочете змінити їх без зміни логіки (наприклад, щоб у локальному запуску була інша назва або інший ліміт).
Але поки ці ключі живуть лише в YAML, самі по собі вони ще нічого не змінюють. Справжній виграш починається тоді, коли той самий набір значень починає керувати реальними біномами catalog-service.
Важливо вловити критерій: у конфіг ми переносимо значення, які справді можуть змінюватися між запусками, але не потребують переписування алгоритму. Наприклад, «за замовчуванням показувати лише опубліковані курси» — це налаштування поведінки, а «як саме фільтрувати за track і level» — уже логіка; її в YAML не виносять. Інакше ви ризикуєте винести в конфіг половину застосунку, а потім героїчно намагатися «програмувати YAML» (спойлер: YAML не хоче бути мовою програмування — він хоче бути YAML).
Щоб було наочно, зафіксуємо невеликий «контракт» налаштувань, які переносимо сьогодні. Він буде не філософським, а прикладним — і безпосередньо зав’язаний на поведінку наявних класів.
| Налаштування | Приклад ключа | Тип | Де впливає в проєкті |
|---|---|---|---|
| Заголовок каталогу | app.catalog.title | String | Підсумок запуску (і далі — будь-які ідеї для «про застосунок»/landing) |
| Режим обслуговування | app.catalog.maintenance-mode | boolean | Сервіс може повертати порожні відповіді або «заморожувати» видачу |
| Ліміт featured | app.catalog.max-featured-count | int | Ендпоінт /api/catalog/featured не повертає нескінченний список |
| Типово — лише published | app.catalog.default-published-only | boolean | Коли параметр запиту publishedOnly не передано |
| Чи ввімкнено звіт під час старту | app.catalog.startup-report-enabled | boolean | StartupSummaryRunner вирішує: друкувати звіт чи мовчати |
Список спеціально маленький. Сьогодні ми не чіпаємо «контент каталогу» (список курсів) і не будуємо складні структури конфігурації — це буде пізніше. Зараз нам потрібен простий, відчутний крок: перестати зберігати змінні параметри всередині Java-класів.
2. application.yaml як контракт налаштувань
До цього місця базовий application.yaml уже зафіксовано: spring.application.name і набір ключів під app.catalog.*. Переписувати весь файл удруге немає сенсу — важливіше тримати в голові, який із наявних класів починає читати ці значення.
CourseCatalogService забирає maintenance-mode, max-featured-count і default-published-only. StartupSummaryRunner — startup-report-enabled, title і spring.application.name. Тобто конфіг перестає бути «абстрактним YAML про проєкт узагалі» і стає дуже конкретним контрактом між ключем і поведінкою класу.
Саме це й відрізняє корисний конфіг від блокнота зі рядками. Поки ключі не прив’язані до поведінки, вони просто лежать у файлі. Щойно клас починає отримувати їх через залежності, конфігурація стає частиною поведінки застосунку під час виконання.
3. @Value: як Spring підставляє значення в біни
Анотація @Value — це мінімалістичний місток між «світом конфігурації» і «світом Java-об’єктів». Вона дозволяє сказати Spring: «Коли будеш створювати цей бін — візьми підсумкове значення за ключем ... із конфігураційного середовища, сконвертуй у потрібний тип і передай у конструктор». Тобто це не «магія у вакуумі», а цілком конкретний момент життя застосунку: створення біна в ApplicationContext.
Дуже важлива домовленість для курсу (і для нормального життя загалом): @Value краще ставити на параметри конструктора, а не на поля. Так ви зберігаєте стиль constructor injection, а клас залишається чесним: його залежності й налаштування видно прямо в конструкторі, а не заховані десь посеред файлу.
Нижче — найпростіший приклад, який показує і @Value, і значення за замовчуванням. Зверніть увагу на синтаксис ${ключ:дефолт}. Якщо ключа в конфігурації немає, Spring візьме дефолт. Якщо дефолту немає, а ключ не знайдено — застосунок завершиться з помилкою на старті (і це, чесно кажучи, добре: краще впасти одразу, ніж тихо працювати «незрозуміло як»).
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class DemoConfigReader {
// Налаштування приходить ззовні: з application.yaml / env / system properties
private final int limit;
public DemoConfigReader(@Value("${app.catalog.max-featured-count:4}") int limit) {
// Якщо ключ не задано, буде використано значення за замовчуванням (4)
this.limit = limit;
}
public int limit() {
return limit;
}
}
Ще один важливий бонус: Spring уміє робити базову конвертацію типів. Якщо ви просите int або boolean, а в YAML лежить число або true/false, то ви отримаєте коректне значення без ручного Integer.parseInt(...). Саме тому @Value так зручний як «перший крок» — він дає швидкий результат і не потребує окремої архітектури конфігурації.
4. Рефакторимо CourseCatalogService
Сервісний шар — ідеальне місце, щоб показати цінність конфігурації без архітектурних перекосів. Контролер має залишатися тонким, репозиторій — джерелом даних, а сервіс — місцем, де застосовується політика видачі. Ліміти й прапорці поведінки — це якраз «політика», і її зручно задавати через конфігурацію. Так ми не ховаємо налаштування у вебшарі й не розмазуємо їх по проєкту.
Уявімо типову версію «до»: ліміт featured захардкожений просто в класі, і якщо завтра ви захочете зробити «у локальному запуску 2, у dev-середовищі 5», вам доведеться правити код. Це й є мініприклад того, як зашиті константи створюють зайву зв’язність.
До (спрощено):
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
// "Константа в коді" — щоб змінити, потрібно змінювати Java і перекомпілювати/перекочувати сервіс
private static final int MAX_FEATURED_COUNT = 4;
public List<String> featuredSlugs() {
return List.of("spring-boot", "spring-core"); // просто приклад
}
}
Тепер зробімо «після»: перенесемо параметри в конструктор, а значення читатимемо через @Value. Почнемо з полів і конструктора, щоб не перевантажувати приклад.
Файл: src/main/java/.../catalog/service/CourseCatalogService.java (фрагмент: поля й конструктор)
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
// Обмеження видачі featured-курсів беремо з конфігурації
private final int maxFeaturedCount;
// Прапорець "обслуговування": за true можна повертати порожні відповіді
private final boolean maintenanceMode;
// Значення за замовчуванням для publishedOnly, якщо параметр запиту не задано
private final boolean defaultPublishedOnly;
public CourseCatalogService(
@Value("${app.catalog.max-featured-count:4}") int maxFeaturedCount,
@Value("${app.catalog.maintenance-mode:false}") boolean maintenanceMode,
@Value("${app.catalog.default-published-only:true}") boolean defaultPublishedOnly) {
// Тут "контракт" стає явним: налаштування приходять у конструктор
this.maxFeaturedCount = maxFeaturedCount;
this.maintenanceMode = maintenanceMode;
this.defaultPublishedOnly = defaultPublishedOnly;
}
}
Так, поки це виглядає як «три @Value підряд». Це нормально на цьому кроці: ми вчимося переносити значення з коду в YAML. Для кількох параметрів такий варіант іще читабельний. Але якщо конфігурація почне розростатися, цей стиль швидко стане шумним — тримайте це відчуття в голові вже зараз. Сьогодні нам важливіше відчути, як змінюється мислення: сервіс перестав «сам вирішувати» свої параметри й почав отримувати їх ззовні.
Тепер додамо метод featured(...) (або аналогічний), який реально обмежує видачу. Покажемо приклад акуратно, без зайвих деталей предметної моделі.
Файл: CourseCatalogService.java (фрагмент: featured-видача)
import java.util.List;
public List<CourseCard> featured(List<CourseCard> allCourses) {
// Якщо ввімкнено maintenance-mode — "заморожуємо" видачу
if (maintenanceMode) {
return List.of();
}
// Ліміт беремо з maxFeaturedCount, який прийшов із YAML через @Value
return allCourses.stream()
.filter(CourseCard::featured)
.limit(maxFeaturedCount)
.toList();
}
Якщо ви зараз подумали «стоп, а звідки allCourses?», то ви мислите правильно: у реальному проєкті сервіс візьме дані з репозиторію. Просто щоб не роздувати приклад, ми залишили «джерело даних» за кадром. Логіка, яка нас цікавить, ось вона: за ввімкненого maintenance-mode сервіс віддає порожній результат, а ліміт featured береться з YAML.
Тепер другий практичний момент: дефолт publishedOnly. У вебшарі зазвичай параметр publishedOnly можуть і не передати. І тоді маємо два варіанти: або зашити поведінку в код, або зробити дефолт налаштовуваним. Ми якраз хочемо другий варіант.
Нехай сервіс приймає Boolean publishedOnly, де null означає «користувач параметр не надіслав». І тоді ми застосовуємо defaultPublishedOnly із конфігу.
Файл: CourseCatalogService.java (фрагмент: published-дефолт)
public boolean resolvePublishedOnly(Boolean publishedOnly) {
// null = параметр був відсутній у запиті, отже використовуємо налаштування з конфігурації
if (publishedOnly != null) {
return publishedOnly;
}
return defaultPublishedOnly;
}
Це маленький метод, але він показує ідею: поведінка «за замовчуванням» — це не магічна константа в коді, а частина конфігурації застосунку. І її можна змінювати без перекомпіляції.
Щоб це реально використовувалося у вебшарі, контролеру достатньо приймати nullable-параметр (якщо він іще не так зроблений). Це не нова тема, просто акуратна форма для нашого сценарію.
Файл: src/main/java/.../catalog/web/CourseCatalogController.java (фрагмент)
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
private final CourseCatalogService service;
public CourseCatalogController(CourseCatalogService service) {
this.service = service;
}
@GetMapping("/api/catalog/courses")
public List<CourseCard> courses(@RequestParam(required = false) Boolean publishedOnly) {
// publishedOnly може бути null, якщо query param не передали
boolean finalPublishedOnly = service.resolvePublishedOnly(publishedOnly);
return List.of(); // тут у вас буде реальна видача, це лише фрагмент
}
}
Зверніть увагу: навіть тут ми не читаємо конфіг у контролері. Контролер лише приймає вхід і передає його в сервіс. Налаштування — усередині сервісу, тобто там, де їм і місце.
5. Рефакторимо StartupSummaryRunner
Startup-логіка — чудове місце, щоб відчути, що конфігурація керує застосунком ще до першого HTTP-запиту. Раніше ми додавали ApplicationRunner, щоб робити короткий звіт про старт. Але якщо цей звіт завжди ввімкнено, він швидко почне дратувати (особливо коли ви часто перезапускаєте застосунок). Тому прапорець startup-report-enabled — дуже життєва настройка: він не змінює «що вміє сервіс», але змінює «як він поводиться під час старту».
У цьому блоці ми акуратно впровадимо два значення: app.catalog.startup-report-enabled і app.catalog.title. І заодно покажемо, що можна читати й spring.application.name — просто щоб поєднати «наші налаштування» і «налаштування Boot» в одній картині.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
// Керуємо друком звіту під час старту через конфіг
private final boolean enabled;
// Заголовок каталогу беремо з app.catalog.title
private final String title;
// Дістаємо стандартне налаштування Spring Boot: spring.application.name
private final String applicationName;
public StartupSummaryRunner(
@Value("${app.catalog.startup-report-enabled:true}") boolean enabled,
@Value("${app.catalog.title:Catalog}") String title,
@Value("${spring.application.name:application}") String applicationName) {
// Усі налаштування й залежності видно прямо в конструкторі
this.enabled = enabled;
this.title = title;
this.applicationName = applicationName;
}
@Override
public void run(ApplicationArguments args) {
// Якщо прапорець вимкнено — просто мовчки виходимо
if (!enabled) {
return;
}
System.out.println(applicationName + " started"); // catalog-service запущено
System.out.println("Catalog title = " + title); // Заголовок каталогу = Spring+ Catalog
}
}
Тут важливо одразу кілька моментів. По-перше, ми використовуємо дефолти прямо в плейсхолдерах. Це допомагає пережити ситуацію «ключа ще немає, але клас уже написаний». По-друге, якщо ключі є в YAML, дефолти просто не знадобляться. По-третє, поведінка тепер змінюється одним рядком у application.yaml: можна вимкнути startup report, не торкаючись Java-коду взагалі.
Перевіряємо вплив конфігурації
Коли ви робите такий рефакторинг, мозок іноді підсовує сумнів: «А раптом це просто анотації для краси, і нічого не змінюється?» Найшвидший спосіб розвіяти сумнів — змінити YAML і побачити, що застосунок почав поводитися інакше. Не десь «усередині Spring», а у вашому коді: інший ліміт, інша назва, інше стартове повідомлення.
Уявіть, що ви змінюєте в application.yaml ліміт featured-курсів і вимикаєте звіт під час старту.
Файл: application.yaml (змінений фрагмент)
app:
catalog:
max-featured-count: 2
startup-report-enabled: false
Після перезапуску застосунку ви побачите, що StartupSummaryRunner більше нічого не друкує. А якщо ваш ендпоінт /api/catalog/featured будується через max-featured-count, то видача стане коротшою — і це буде видно прямо в браузері або HTTP-клієнті. Саме такі перевірки формують правильну звичку: конфігурація — це частина поведінки, а не «окремий папірець поруч».
Для кількох параметрів такого маршруту через application.yaml і @Value вистачає з головою: видно, як зовнішній ключ справді змінює поведінку сервісів і раннерів. Але тут важливо не зробити хибний висновок, ніби вся конфігурація Boot — це «один YAML і пара @Value». Сам application.yaml — лише одне джерело значень, а коли налаштувань стає багато, розсип окремих @Value по класах уже заважає бачити конфігурацію як цілісну модель.
6. Типові помилки з YAML і @Value
Помилка №1: залишають значення і в коді, і в YAML (подвійне джерело істини).
Це найпідступніша проблема, бо спочатку здається, що так надійніше. Насправді виходить двовладдя: ви змінюєте application.yaml, але код продовжує використовувати стару константу — або навпаки. Лікується просто: якщо значення стало конфігураційним, у коді не має залишатися його зашитої копії, окрім хіба що дефолту в ${...:default} (і то свідомо).
Помилка №2: ставлять @Value на поле й поступово перетворюють клас на їжака з анотацій.
Полева ін’єкція виглядає коротше, але вона приховує залежності й ламає загальний стиль constructor injection. За кілька тижнів у вас буде клас, де згори десять полів із @Value, а конструктор порожній, і зрозуміти, що потрібно цьому об’єкту для життя, стає неприємно. Якщо тримати @Value на параметрах конструктора, клас читається значно чесніше.
Помилка №3: пишуть ключі абияк і без спільного префікса.
Сьогодні ми тримаємося app.catalog.*, і це не примха. Якщо почати додавати catalogTitle, catalog.title, myapp.catalogTitle, feature.limit — конфіг швидко перетворюється на «ринок у неділю». Один префікс на предметну область і один стиль ключів — це не бюрократія, а здоровий глузд.
Помилка №4: забувають про дефолт там, де він потрібен, і ловлять падіння на старті «Could not resolve placeholder …».
Сама по собі помилка корисна: застосунок чесно каже, що йому не дали потрібного параметра. Але новачок часто сприймає це як «Spring знову свариться незрозуміло на що». Якщо параметр обов’язковий — чудово, нехай падає. Якщо параметр м’який і має розумне значення за замовчуванням — задайте його через ${key:default} і позбавте себе зайвої крихкості на першому кроці.
Помилка №5: починають читати один і той самий ключ у п’яти різних класах.
Це виглядає безневинно, доки ключів мало. Потім ви змінюєте ключ або його зміст — і шукаєте по проєкту всі місця, де він використовується. Поки ключів мало, це ще терпимо, але важливо відчути: @Value — хороший мінімальний міст, але він погано масштабується, якщо перетворюється на основний стиль усієї конфігурації. Тому ми використовуємо його точково: для кількох базових параметрів поведінки, а не для всього підряд.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ