1. Мета: CatalogProperties як межа
До цього етапу в нас уже зібрано весь ланцюжок по частинах: @Value everywhere показав, скільки в коді рядкових ключів, @ConfigurationProperties дав форму моделі, реєстрація зробила її звичайним біном, а binder навчився зв’язувати складні поля. Тепер час скласти все це в живий catalog-service.
Коли ви починаєте рефакторити конфігурацію, легко випадково зробити «красиво в одному місці» й «страшно у пʼяти інших». Тому спочатку домовимося про цільовий результат. Нам потрібен не просто клас із полями, а зрозуміла архітектурна межа: вся гілка app.catalog.* всередині Java-коду читається через один об’єкт. Це зменшує кількість точок, де можна помилитися, і робить поведінку сервісу передбачуваною.
Уявіть, що @Value — це як брати продукти по одному, бігаючи між полицями. Сьогодні ви швидко знайшли хліб, завтра — масло, післязавтра забули, що сир взагалі існує, бо він у іншому кутку магазину. CatalogProperties — це коли ви берете один кошик, і в ньому вже лежить усе, що стосується «нашого каталогу»: заголовок, прапорці поведінки, ліміти і, найголовніше, список курсів як дані.
Практично в catalog-service це означає три речі.
По-перше, ми перестаємо впроваджувати окремі значення в різних місцях через @Value. По-друге, ми перестаємо тягати Environment по проєкту як «універсальний пульт керування всім» (він корисний, але як основний стиль теж швидко розповзається). По-третє, ми будуємо потік даних так, щоб YAML → binder → CatalogProperties → bootstrap (завантаження курсів) → repository/service → controller → JSON.
Нижче — схема того, що саме ми хочемо бачити в підсумку.
flowchart TD
YAML["application.yaml + catalog-data.yaml, app.catalog.*"] --> Binder["Spring Boot Binder @ConfigurationProperties"]
Binder --> Props["CatalogProperties — бін, типізована модель"]
Props --> Seeder["CourseCatalogSeeder — мапінг config → domain"]
Seeder --> Repo["InMemoryCourseCatalogRepository зберігає CourseCard"]
Repo --> Service["CourseCatalogService: фільтри + ліміти + прапорці"]
Service --> Controller["CourseCatalogController — тонкий web-шар"]
Controller --> JSON["JSON-відповіді"]
Відтепер CatalogProperties — не «ще один клас у config-пакеті», а частина каркаса застосунку: як CourseCatalogService або CourseCatalogRepository, тільки про конфігурацію.
2. Модель CatalogProperties без логіки
Хочеться відразу написати ідеальну конфігураційну модель, але в навчальному проєкті важливіше інше: щоб клас читався як проста структура даних. Сьогодні ми свідомо обираємо прозорий стиль POJO з полями та getter/setter, бо так простіше зрозуміти, як саме Spring зв’язує значення з YAML із Java. На цьому кроці нам важлива прозорість, а не найсуворіший варіант моделі.
Почнемо з кореневого класу. Він живе в пакеті config і описує всю гілку app.catalog.*. Зверніть увагу на дві практичні речі: список курсів ініціалізовано порожнім ArrayList, щоб не ловити NullPointerException, а назви полів повторюють зміст налаштувань, а не просто значення.
package com.example.catalogservice.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
// Цей клас — "межа" для всієї гілки app.catalog.*
// Spring Boot сам створить бін і зв'яже значення з YAML за назвою префікса.
@ConfigurationProperties("app.catalog")
public class CatalogProperties {
// Зрозумілий для людини заголовок каталогу (наприклад, для шапки або логів)
private String title;
// Якщо true — сервіс може "замовкнути" або віддавати порожні добірки
private boolean maintenanceMode;
// Ліміт на кількість featured-курсів у видачі
private int maxFeaturedCount;
// Політика за замовчуванням: чи приховувати неопубліковані курси
private boolean defaultPublishedOnly;
// Прапорець: чи друкувати стартовий звіт під час запуску
private boolean startupReportEnabled;
// Важливо: за замовчуванням — порожній список, а не null (щоб код був передбачуваним)
private List<CourseItemProperties> courses = new ArrayList<>();
// гетери й сетери
}
Так, тут багато полів, але це якраз нормальна ситуація: це «паспорт» нашого блоку застосунку. Важливо, щоб цей паспорт не перетворювався на сервіс: ніяких loadCourses() або filterFeatured() всередині CatalogProperties. Усе це — робота bootstrap/service-шару, а properties-клас зберігає лише дані.
Щоб біндер справді зміг записати значення, нам потрібні гетери та сетери. Зазвичай їх багато й вони нудні, але інколи нудьга — це ознака стабільності (як у дорослому житті). Покажу один приклад, решта — за аналогією.
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
Тепер опишемо елемент списку курсів. Тут починається найцікавіше: ми зв’язуємо рядкові значення з YAML із нормальними типами. Трек і рівень — це enum із доменної моделі, дата запуску — LocalDate, ціна — вкладений об’єкт MoneyProperties. Це саме той випадок, коли типізована конфігурація реально економить нерви: ви не пишете ручний парсинг, і IDE не дає вам переплутати «рівень» із «треком».
package com.example.catalogservice.config;
import java.time.LocalDate;
import com.example.catalogservice.catalog.domain.CourseLevel;
import com.example.catalogservice.catalog.domain.CourseTrack;
public class CourseItemProperties {
// Технічний ідентифікатор (внутрішній)
private String id;
// Частина URL / зрозумілий для людини ключ
private String slug;
// Назва курсу
private String title;
// Короткий опис (наприклад, для карток)
private String shortDescription;
// Enum із домену: біндер зв'яже рядок "SPRING" -> CourseTrack.SPRING
private CourseTrack track;
// Enum із домену: "BASIC" -> CourseLevel.BASIC
private CourseLevel level;
// Тривалість у днях (примітив — важливо стежити за дефолтом 0)
private int durationDays;
// Прапорець "обране" для добірок
private boolean featured;
// Чи опубліковано курс (впливає на фільтри та видимість)
private boolean published;
// ISO-дата виду 2026-04-01 буде зв'язана в LocalDate автоматично
private LocalDate launchDate;
// Вкладений об'єкт конфігурації (amount/currency)
private MoneyProperties price;
// гетери й сетери
}
І вкладена структура ціни. Об’єкт простий, як табуретка, і це прекрасно: ціна — це дані, не поведінка.
package com.example.catalogservice.config;
public class MoneyProperties {
// Сума в мінімальних одиницях (наприклад, копійках), щоб не зберігати double
private long amount;
// Валюта як рядок (наприклад, "GBP")
private String currency;
// гетери й сетери
}
Окремий приємний момент: ми повторно використали доменні enum (CourseTrack, CourseLevel) у конфігурації. Це нормально, бо перелічення — стабільна частина домену, і нам не потрібно плодити дублікати в пакеті config. А от CourseCard як доменну модель сьогодні безпосередньо не біндимо, бо доменні моделі часто роблять суворішими й незмінними, а для такого біндингу потрібна більша дисципліна. Сьогодні ми тримаємо все максимально прозоро.
Щоб не загубитися в назвах, корисно один раз візуально зіставити ключі YAML і поля Java. Spring Boot робить relaxed binding, тому формат ключів може бути зручним для YAML (kebab-case), а Java залишається в camelCase.
| Ключ YAML | Поле Java |
|---|---|
| app.catalog.maintenance-mode | maintenanceMode |
| app.catalog.max-featured-count | maxFeaturedCount |
| app.catalog.default-published-only | defaultPublishedOnly |
| app.catalog.startup-report-enabled | startupReportEnabled |
| app.catalog.courses[0].short-description | shortDescription |
| app.catalog.courses[0].launch-date | launchDate |
3. Реєстрація: @ConfigurationPropertiesScan
Тут нам не потрібне друге коло теорії про способи реєстрації. Для catalog-service фіксуємо один робочий вибір: @ConfigurationPropertiesScan на головному класі застосунку. Цього достатньо, щоб CatalogProperties потрапив у ApplicationContext як звичайна залежність і спокійно впроваджувався в сервіси, ранери та bootstrap-компоненти.
package com.example.catalogservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan // Увімкнення пошуку класів @ConfigurationProperties у базовому пакеті
public class CatalogServiceApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogServiceApplication.class, args);
}
}
Цим ми закриваємо питання реєстрації однаково для всього проєкту. Технічно properties-клас можна оживити і через @Component, але як основний стиль це лише змішує component scan із конфігураційним binding і згодом легко призводить до плутанини.
4. YAML та імпорт catalog-data.yaml
Коли з’являється CatalogProperties, YAML перестає бути «набором рядків» і стає конфігурацією з формою. І тут важливо тримати структуру файлів у порядку: нам не потрібен гігантський файл application.yaml, де впереміш живуть і налаштування застосунку, і дані каталогу. Ми вже маємо spring.config.import, тому акуратно розділяємо: базові налаштування — в application.yaml, список курсів — у catalog-data.yaml.
Імпорт тут не робить нічого принципово нового: він просто подає біндеру той самий конфігураційний шар, але розкладений по двох окремих файлах.
Почнемо з application.yaml. Тут — імʼя застосунку, імпорт даних каталогу і прапорці й ліміти поведінки.
spring:
application:
name: catalog-service
config:
# Підключаємо окремий файл із даними каталогу (список courses)
import: "classpath:catalog-data.yaml"
app:
catalog:
# Заголовок, який потім можна використати в логах/стартовому звіті
title: "Spring+ Catalog"
# Прапорці поведінки сервісу
maintenance-mode: false
default-published-only: true
startup-report-enabled: true
# Ліміти видачі
max-featured-count: 4
А тепер catalog-data.yaml, де лежить список курсів. Тут важливо дотримуватися форми: курс — це елемент списку courses, у нього є вкладений об’єкт price. Дати краще писати в ISO-форматі YYYY-MM-DD, щоб біндер спокійно зв’язав це з LocalDate.
app:
catalog:
courses:
# Кожен елемент списку — окремий курс (CourseItemProperties)
- id: "spring-boot"
slug: "spring-boot"
title: "Spring Boot"
short-description: "Основи Boot"
track: "SPRING"
level: "BASIC"
duration-days: 28
featured: true
published: true
# ISO-формат дати, щоб спокійно зв'язалося в java.time.LocalDate
launch-date: "2026-04-01"
# Вкладений об'єкт "price" -> MoneyProperties
price:
amount: 19900
currency: "GBP"
Тут ви можете помітити приємний ефект: YAML стає самодокументованим. У ньому видно структуру даних, і вона збігається з Java-моделлю. Якщо ви відкриваєте CatalogProperties і YAML поруч, ви буквально бачите, як одне відображається в інше.
Ще одна практична порада: намагайтеся, щоб courses визначався в одному місці. Якщо він почне з’являтися і в application.yaml, і в імпортованому файлі, ви ризикуєте отримати сюрпризи з тим, хто переміг за пріоритетом, і чому список не такий, як ви очікували. Це не помилка Boot — це наша помилка дизайну конфігурації.
5. CourseCatalogSeeder: config → домен
Тепер головне питання: у нас є CatalogProperties, у ньому список CourseItemProperties. Але web-шар і сервісний шар працюють із доменною моделлю CourseCard, бо саме її ми віддаємо назовні і навколо неї будуємо логіку каталогу. Отже, десь нам потрібен «перекладач» із config-структури в доменну структуру. Найкраще місце — bootstrap-шар, тобто CourseCatalogSeeder.
Сідер робить дві речі: бере список курсів із properties, перетворює його на список CourseCard і складає в in-memory-репозиторій. Ми не перетворюємо репозиторій на «магічний» компонент, який сам читає YAML — репозиторій залишається сховищем, а сідер — завантажувачем даних на старті.
Почнемо з репозиторію: йому потрібен метод, щоб замінити список даних. Так, це мутація — але локальна, у момент старту. Головне, щоб цей метод не викликався з контролерів «за настроєм».
import java.util.List;
public void replaceAll(List<CourseCard> courses) {
// На старті замінюємо знімок даних цілком (після цього — лише читання)
this.courses = List.copyOf(courses);
}
Тепер сідер. Він буде ApplicationRunner, бо це природна точка «після підняття контексту зробити прикладний стартовий крок». І ми впроваджуємо залежності через конструктор: CatalogProperties і репозиторій.
package com.example.catalogservice.catalog.bootstrap;
import com.example.catalogservice.catalog.domain.CourseCard;
import com.example.catalogservice.catalog.domain.Money;
import com.example.catalogservice.catalog.repository.InMemoryCourseCatalogRepository;
import com.example.catalogservice.config.CatalogProperties;
import com.example.catalogservice.config.CourseItemProperties;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class CourseCatalogSeeder implements ApplicationRunner {
private final CatalogProperties properties;
private final InMemoryCourseCatalogRepository repository;
// Важливо: залежності приходять через конструктор, а не через "пошук конфига"
public CourseCatalogSeeder(CatalogProperties properties, InMemoryCourseCatalogRepository repository) {
this.properties = properties;
this.repository = repository;
}
@Override
public void run(ApplicationArguments args) {
// 1) Беремо конфігураційну модель (properties)
// 2) Перетворюємо її на доменну модель (CourseCard)
// 3) Кладемо в репозиторій одним атомарним "replaceAll"
var courses = properties.getCourses().stream()
.map(this::toDomain) // mapping config -> domain
.toList();
repository.replaceAll(courses);
}
private CourseCard toDomain(CourseItemProperties p) {
// Переносимо вкладену структуру price у доменний Money
Money price = new Money(p.getPrice().getAmount(), p.getPrice().getCurrency());
// Прямолінійна збірка доменного об'єкта з конфігурації
return new CourseCard(
p.getId(),
p.getSlug(),
p.getTitle(),
p.getShortDescription(),
p.getTrack(),
p.getLevel(),
p.getDurationDays(),
p.isFeatured(),
p.isPublished(),
p.getLaunchDate(),
price
);
}
}
Зверніть увагу на важливу методичну думку: CatalogProperties і його вкладені класи — це модель конфігурації, а CourseCard — модель домену. Вони схожі за полями, але призначення в них різне. І поки ми не вводимо окремий DTO-шар, саме сідер стає місцем, де ці два світи «потискають одне одному руки» і розходяться кожен у свої справи.
6. Поведінка сервісу від CatalogProperties
Коли курси вже завантажено, ми можемо отримати другий бонус від типізованої конфігурації: не тільки дані каталогу, а й поведінка сервісу стає керованою через поля CatalogProperties. Це той момент, коли застосунок починає нагадувати справжній сервіс: ви змінюєте YAML — змінюється поведінка, без перекомпіляції, без правок Java-коду, без «ну давайте швиденько захардкодимо».
Нехай у нас є метод, який повертає featured-добірку. У ньому є три місця, де конфігурація впливає на результат. Якщо увімкнено maintenanceMode, ми взагалі нічого не віддаємо. Якщо увімкнено defaultPublishedOnly, ми ховаємо неопубліковані курси. І ми обмежуємо результат maxFeaturedCount.
import java.util.List;
public List<CourseCard> featured() {
// Режим обслуговування: сервіс "мовчить" і не віддає добірки
if (properties.isMaintenanceMode()) {
return List.of();
}
return repository.findAll().stream()
.filter(CourseCard::isFeatured) // беремо тільки featured
.filter(this::passesPublishedDefault) // застосовуємо дефолт "published only"
.limit(properties.getMaxFeaturedCount()) // обмежуємо розмір видачі за конфігом
.toList();
}
І невеликий helper, щоб умова читалася нормально. Тут ми не намагаємося бути «суперрозумними», просто робимо код трохи зрозумілішим.
private boolean passesPublishedDefault(CourseCard course) {
// Якщо defaultPublishedOnly вимкнено — пропускаємо всі курси
// Якщо увімкнено — пропускаємо лише опубліковані
return !properties.isDefaultPublishedOnly() || course.isPublished();
}
Виходить дуже «чесна» логіка: сервіс не знає жодного рядкового ключа. Він знає лише поля properties, і їхній зміст очевидний просто за назвами. Це головна перевага типізованої конфігурації: замість «магії рядків» у вас «магія читабельності». Інколи це навіть краще.
Якщо у вас є загальний метод пошуку курсів із фільтрами (track/level/limit), ви можете застосувати той самий прийом: коли query-параметр publishedOnly не передано, сервіс підставляє значення за замовчуванням із defaultPublishedOnly. Важливо, що дефолт живе в конфігурації, а не в коді контролера, інакше контролер почне поступово «розумнішати», а нам цього не потрібно.
7. StartupSummaryRunner через прапорець
До переходу на типізовану конфігурацію код запуску часто виглядає як «суміш усього»: тут @Value, там Environment, тут якийсь хардкод, а поруч ще й System.out.println про всяк випадок. Після появи CatalogProperties ранер стає зрозумілим: він залежить від однієї моделі конфігурації і виконує свою роботу лише за умови, що увімкнено прапорець startupReportEnabled.
У навчальному проєкті ми поки можемо друкувати в консоль, щоб бачити ефект одразу. Головне — показати, що керування поведінкою йде через properties, а не через «бо сьогодні мені так захотілося».
import com.example.catalogservice.config.CatalogProperties;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
private final CatalogProperties properties;
public StartupSummaryRunner(CatalogProperties properties) {
this.properties = properties;
}
@Override
public void run(ApplicationArguments args) {
// Прапорцем із конфігурації вмикаємо/вимикаємо стартовий звіт
if (!properties.isStartupReportEnabled()) {
return;
}
// Демонстрація: читаємо конфіг через типізований об'єкт, а не через рядкові ключі
System.out.println("Каталог: " + properties.getTitle()); // Каталог: Spring+ Catalog
System.out.println("Кількість курсів у конфігурації: " + properties.getCourses().size()); // Кількість курсів у конфігурації: 1
}
}
Навіть на такому маленькому прикладі видно, чому @Value everywhere дратує: замість одного об’єкта з ясними полями ви б тримали два-три рядкові ключі, і ранер почав би «знати YAML» напряму. Тепер він «знає» лише CatalogProperties і робить роботу рівно настільки, наскільки йому це потрібно.
Окремо зазначу тонкий момент: StartupSummaryRunner друкує properties.getCourses().size(), але в реальності ви можете друкувати кількість курсів у репозиторії (після сідера). Тоді стає важливим порядок виконання ранерів. Якщо ви вже знайомі з @Order, можна зробити сідер «раніше», а summary — «пізніше». Але навіть без цього сам принцип лишається: стартап-поведінка керується типізованою конфігурацією.
8. Типові помилки при @ConfigurationProperties
Помилка №1: додати @ConfigurationProperties, але не зареєструвати клас як бін.
Найчастіша причина null у ранері — не «Spring зламався», а те, що клас із @ConfigurationProperties("app.catalog") взагалі не потрапив у контейнер. Зазвичай забувають @ConfigurationPropertiesScan, @EnableConfigurationProperties або кладуть клас поза областю базового сканування. У результаті біндеру просто ні до чого прив’язувати значення, і ви потім шукаєте проблему не там, де вона реально знаходиться.
Помилка №2: робити @Component на properties-класі як «ремонт за замовчуванням».
Іноді це справді заводить бін, але ціна такого «ремонту» — змішування двох різних моделей реєстрації. У вас виходить клас, який ніби і компонент, і конфігураційні властивості одночасно. На маленькому проєкті це може пройти непомітно, але далі починається плутанина: незрозуміло, як саме створюється бін і чому він узагалі працює. Значно спокійніше й чистіше триматися штатного шляху через @ConfigurationPropertiesScan.
Помилка №3: забути, що біндинг залежить від звичайних Java-accessors.
Якщо у поля немає сетера, сетер названо неправильно або сигнатура не збігається, біндер просто не зможе записати значення. Найнеприємніше тут те, що частина полів може зв’язатися, а частина — лишитися з дефолтами, і помилка маскується під «дивну поведінку застосунку». Особливо небезпечні примітиви: int maxFeaturedCount тихо залишиться дорівнювати 0, і ви почнете розслідувати, чому фільтр нічого не повертає, хоча проблема зовсім не у фільтрі.
Помилка №4: покладатися на дефолти примітивів і не помічати, що конфігурація не зв’язалася.
0, false та інші значення за замовчуванням виглядають «нормально» і тому особливо підступні. Застосунок не падає одразу, а просто починає поводитися не так, як ви очікували. У навчальному проєкті краще явно задавати такі значення в YAML і не залишати критичні налаштування на милість дефолтів самої мови. Тоді проблема «не зв’язалося» стає помітнішою.
Помилка №5: тримати List як null за замовчуванням.
Якщо написати private List<CourseItemProperties> courses; і не ініціалізувати список, за відсутності ключа в YAML ви отримаєте null. А потім десь далі код викличе properties.getCourses().size() і впаде вже далеко від справжньої причини. Для конфігурації майже завжди краще вважати, що список за замовчуванням порожній, а не відсутній. Порожній List робить поведінку передбачуванішою і позбавляє зайвих null-перевірок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ