JavaRush /Курси /Spring Boot /CatalogProperties у ...

CatalogProperties у catalog-service

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

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-перевірок.

1
Задача
Spring Boot, 17 рівень, 4 лекція
Недоступна
Featured-курси з центрального `CatalogProperties`
Featured-курси з центрального `CatalogProperties`
1
Задача
Spring Boot, 17 рівень, 4 лекція
Недоступна
Окремий файл даних і стартове зведення каталогу
Окремий файл даних і стартове зведення каталогу
1
Опитування
Конфігурація Spring, рівень 17, лекція 4
Недоступний
Конфігурація Spring
Зв’язування налаштувань у Boot
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ