1. Конфигурация как данные
Когда мы говорим «конфигурация приложения», очень легко представить себе какой-то «пульт управления», где можно крутить ручки уже во время работы. В реальности в большинстве Spring Boot-сервисов конфигурация — это снимок: при старте приложение читает значения, связывает их с типами и дальше живёт с этим набором как с фактом биографии. Как дата рождения: можно, конечно, попробовать “поменять”, но мир от этого станет только страннее.
Для нашего catalog-service конфигурация — вообще почти «база данных»: список курсов, лимиты для featured-подборки, флаги поведения. Это означает, что конфигурационная модель — это контракт, который должен быть предсказуемым и читаемым. Если контракт можно случайно изменить после старта, мы получаем приложение, поведение которого начинает напоминать «ну… оно иногда так делает». А это, поверьте, худший жанр backend-литературы.
Есть ещё один практический момент: Spring Boot-приложение — многопоточное. Ваш controller обрабатывает запросы параллельно, сервисы вызываются в разных потоках, а конфигурация при этом используется «везде». Конфиг, который кто-то случайно мутирует, превращается в лотерею. И да, лотерея — это классно, но обычно не в проде и не в дедлайнах.
Поэтому сегодняшняя мысль простая: конфигурационная модель должна быть 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; }
// Setter даёт любому коду возможность "подкрутить" конфиг во время работы.
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. Immutable конфигурационная модель
Immutable-модель (неизменяемая) — это когда объект создаётся один раз, получает все значения сразу и после этого не может изменить своё состояние. Это не религия final, а очень прагматичная инженерная привычка: если данные должны быть стабильными, мы не даём коду возможности «случайно» сделать их нестабильными.
В контексте @ConfigurationProperties это означает простой контракт. Boot стартует, читает application.yaml и другие источники, «связывает» значения и получает объект-конфигурацию. Дальше сервисы и контроллеры этот объект только читают. Не “читают и иногда подправляют”, не “временно меняют”, а просто читают. Как справочник.
С точки зрения поддержки и отладки это почти магия, только хорошая. Когда вам прилетает баг «почему featured-курсов стало 999», вы хотя бы знаете: если модель immutable, то это не “кто-то где-то поменял объект”. Значит, либо конфиг так задан, либо binding так собрал, либо вы сами выстрелили себе в ногу чуть раньше. Но это хотя бы ограниченный набор вариантов, а не бесконечный сериал.
Важно понимать: immutable-модель не запрещает вам менять конфигурацию между запусками. Менять YAML, активировать другой профиль, передать другой env var — пожалуйста. Но во время одного запуска конфигурация должна быть стабильной.
4. Java record для @ConfigurationProperties
Records в 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 не делает ваш проект “функциональным” или “реактивным” или “слишком умным”. Он делает проект читаемым. И это, честно говоря, самая underrated-фича современного Java.
Небольшое сравнение (чтобы закрепилось):
| Свойство | Mutable JavaBean (class + setters) | Immutable record |
|---|---|---|
| Можно поменять значения после старта | Да, легко и незаметно | Нет, сеттеров нет |
| Контракт видно сразу | Нет, нужно читать класс | Да, в заголовке record |
| Boilerplate | Много | Почти нет |
| Модель мышления | «Объект меняется» | «Данные фиксированы» |
5. Перевод CatalogProperties на record
Теперь привяжем идею к нашему проекту. У нас уже есть CatalogProperties, которые лежат в пакете config и связаны с префиксом app.catalog. Typed binding у нас уже есть: вместо россыпи строк мы получаем в коде нормальные типы. Теперь важно сделать эту модель ещё и устойчивой по форме.
В реальном проекте 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 {
// Держим ссылку на конфигурацию как на read-only данные.
private final CatalogProperties properties;
// Конструкторная инъекция: Spring отдаёт уже "собранный" объект.
public FeaturedCourseService(CatalogProperties properties) {
this.properties = properties;
}
public int featuredLimit() {
// У record нет getXxx(): обращаемся через accessor компонента.
return properties.maxFeaturedCount();
}
}
Здесь нам важен не сам механизм binding’а, а результат: после связывания конфиг превращается в объект, который невозможно “подкрутить” сеттером. А сервисы вокруг него становятся проще: они читают готовые значения и выполняют свою работу, не превращаясь в «второй слой конфигурации».
Ещё один маленький, но полезный психологический нюанс: когда объект конфигурации неизменяемый, вы перестаёте думать “а вдруг он поменяется?”. И это упрощает понимание кода сильнее, чем кажется.
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. Accessors и привычка read-only
Если вы раньше жили в мире 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). Поэтому правильная привычка звучит так: к конфигурации относимся как к read-only данным, даже если технически Java позволяет сделать что-то плохое.
Иногда полезно помнить простое правило: конфиг — это не место, где мы “исправляем мир”, это место, где мы “узнаём, какой мир нам дали”.
8. Путь значения из YAML до сервиса
Чтобы не воспринимать @ConfigurationProperties как “магическую аннотацию”, держите схему того, что происходит в нашем catalog-service. В реальности внутри Boot много шагов, но на уровне курса нам достаточно этой цепочки:
flowchart TD
%% Цепочка: источники конфигурации -> биндинг -> типизированный объект -> использование в коде
A["application.yaml / imported yaml / env vars"] --> 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 автоматически делает immutable всё внутри.
Record защищает поля (они final), но если внутри лежит коллекция, она может быть изменяемой. Поэтому важно держать дисциплину “конфигурация read-only” и не делать операций вроде properties.courses().clear(). Если нужно усилить гарантию, это можно сделать позже аккуратными приёмами, но в первую очередь решает именно привычка не мутировать конфиг.
Ошибка №5: воспринимать record как обязательный стиль для любых классов проекта.
Records — отличный инструмент, но сегодня мы используем их именно потому, что конфигурация по природе “data-shaped”. Доменная модель (CourseCard, Money) и слой web/service могут иметь свои причины быть class/record по-разному. Не надо превращать “records для конфигурации” в “records везде, потому что модно”. Модно — проходит, читаемость — остаётся.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ