1. Constructor binding в immutable-конфигурации Spring Boot
Когда мы переходим на immutable properties-модель, мы как будто меняем стиль общения с приложением. Раньше это было «вот тебе пустой объект — заполни его, как сможешь, сеттерами», а теперь — «вот форма заказа: пока не заполнили все поля, заказ не оформляется». Для конфигурации это особенно важно: она должна быть данными, а не «постепенно наполняемым состоянием».
В старом mutable-стиле (класс + сеттеры) Spring Boot мог действовать по схеме «создать объект → вызвать setXxx(...) для каждого свойства». Это называется binding через JavaBeans-подход. Он рабочий, но у него есть побочный эффект: объект формально может существовать в полу-заполненном виде, а если где-то в коде вы начнёте его менять — то и в «пост-стартап» период.
С record так не получится. У record нет сеттеров, а значения задаются один раз в момент создания. Поэтому Spring Boot делает то, что логично: берёт итоговые значения свойств из Environment и вызывает конструктор CatalogProperties(...). Это и есть constructor binding: объект конфигурации собирается «целиком и сразу».
Небольшая схема того, что происходит в голове у Boot, — сильно упрощённая, но полезная:
flowchart TD
A["Property sources: YAML, profiles, env vars, CLI args"] --> B["Boot Binder"]
B --> C["Конвертация типов: String → int/boolean/Duration/..."]
C --> D["Вызов конструктора / record components"]
D --> E["Готовый immutable объект: CatalogProperties"]
E --> F["DI: сервисы получают его через конструктор"]
Тут важная мысль: Binder не «мутирует» ваш объект, он его создаёт. А значит, любые «значения по умолчанию», которые вам нужны, тоже должны быть сформулированы так, чтобы объект можно было собрать предсказуемо.
2. Constructor binding и record
Слова constructor binding легко спутать с constructor injection. И это нормальная путаница: оба звучат как «что-то с конструктором», а мозг у начинающего разработчика и так занят тем, чтобы не перепутать @Service с @Servlet. Давайте разложим спокойно и по делу.
Constructor injection — это DI-история: как Spring отдаёт зависимости вашему классу (например, CourseCatalogService получает CatalogProperties в конструкторе).
Constructor binding — это config-история: как Spring Boot создаёт сам CatalogProperties, когда читает app.catalog.* из конфигурации.
В record constructor binding особенно прозрачен, потому что record components и есть контракт данных. Вот минимальная версия:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog") // Говорим Boot, что это конфигурация с префиксом app.catalog
public record CatalogProperties(
String title, // Значение будет взято из app.catalog.title
int maxFeaturedCount // Значение будет взято из app.catalog.max-featured-count (relaxed binding)
) {}
Если в конфигурации есть:
app:
catalog:
title: "Spring+ Catalog" # Заголовок каталога
max-featured-count: 4 # Лимит для featured-блока (kebab-case в YAML)
то Boot «мысленно» делает примерно это:
// Как будто binder вычислил итоговые значения и просто вызвал конструктор record
new CatalogProperties("Spring+ Catalog", 4);
И дальше этот объект становится bean’ом в контексте, и любой сервис может получить его через обычную DI-инъекцию.
Плюс record решает одну очень практическую проблему: имена компонентов у record известны всегда (это часть языка). У обычных классов с конструктором имена параметров иногда теряются при компиляции (если не сохранять parameter names), и тогда binder начинает грустить. С record binder почти никогда не грустит — он видит имена title и maxFeaturedCount как часть типа.
3. Relaxed binding: YAML и параметры конструктора
Если смотреть на YAML, можно подумать: «какой ещё maxFeaturedCount, я же писал max-featured-count». И вот здесь появляется один из самых полезных и дружелюбных механизмов Spring Boot — relaxed binding. Он нужен, чтобы вы могли писать свойства читаемо (обычно в kebab-case), а в Java иметь нормальные имена (camelCase).
Смысл relaxed binding такой: Boot умеет считать, что эти варианты — «одно и то же имя», просто записанное разными стилями:
| Где | Пример | Во что маппится в Java |
|---|---|---|
| YAML (kebab-case) | |
|
| .properties стиль | |
|
| ENV переменная | |
|
| CLI аргумент | |
|
Обратите внимание, что «на самом деле» binder делает две вещи: сначала он нормализует имя свойства, потом сопоставляет его с именем параметра конструктора (или record component). Для нас как для разработчиков полезно держать в голове правило: в Java вы именуете компоненты нормально, в YAML вы пишете нормально, а Boot как переводчик между двумя мирами.
Небольшой пример из нашего catalog-service, где особенно заметна ценность этого переводчика:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog") // Префикс для всех свойств ниже
public record CatalogProperties(
String title, // app.catalog.title
boolean defaultPublishedOnly // app.catalog.default-published-only
) {}
В YAML это будет:
app:
catalog:
title: "Spring+ Catalog" # Читаемый заголовок
default-published-only: true # Настройка в kebab-case
И это выглядит человечески: YAML читается почти как предложение, а Java остаётся Java.
4. Отсутствующие свойства и «тихий ноль»
Когда вы впервые делаете immutable-конфигурацию, самая неприятная неожиданность выглядит так: вы забыли добавить свойство — приложение стартовало — и… ведёт себя странно. Никаких ошибок, просто «что-то не то». Это не магия Spring, это обычная логика типов, особенно примитивов.
Представим, что у вас есть такой record:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.catalog") // Читаем настройки из app.catalog.*
public record CatalogProperties(
String title, // Если отсутствует — будет null
int maxFeaturedCount // Если отсутствует — будет 0 (дефолт примитива)
) {}
А в YAML вы случайно забыли max-featured-count. Что делать binder’у? Ему нужно вызвать конструктор, а конструктор требует int. int не может быть null. Поэтому получается очень «тихая» ситуация: binder подставляет 0 (потому что 0 — дефолт примитива), и объект успешно создаётся.
И вот это — главный источник «странных багов по конфигурации» у новичков. Потому что 0 для лимита «сколько featured-курсов показать» — это вполне валидное число, но почти наверняка не то, что вы хотели.
Чтобы почувствовать это руками, можно вывести значение на старте (в нашем проекте для этого уже подходит startup-раннер, который мы заводили раньше):
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component // Делаем раннер bean’ом, чтобы он выполнился на старте приложения
public class StartupSummaryRunner implements ApplicationRunner {
private final CatalogProperties properties;
public StartupSummaryRunner(CatalogProperties properties) {
// Конфигурация также инжектится через конструктор (это уже constructor injection, не binding)
this.properties = properties;
}
@Override
public void run(org.springframework.boot.ApplicationArguments args) {
// Демонстрация: если maxFeaturedCount не задан в YAML, здесь окажется 0
System.out.println("maxFeaturedCount = " + properties.maxFeaturedCount()); // maxFeaturedCount = 0
}
}
Тут важный вывод: constructor binding сам по себе не гарантирует «осмысленность» значений. Он гарантирует «объект можно собрать», но не гарантирует, что вы не собрали его из случайных нулей и null.
С String-полями похожая история, только вместо 0 вы получите null. И это тоже будет «тихо», пока не случится NPE где-нибудь в неожиданном месте.
Отсюда вытекает здравый принцип проектирования конфигурации: если значение действительно опционально, ему нужен понятный fallback; если значение обязательное, лучше не маскировать его отсутствие случайными дефолтами языка.
5. @DefaultValue: явные значения по умолчанию рядом с параметром
Как только вы увидели «тихий ноль», рука сама тянется написать что-то вроде int maxFeaturedCount = 4;. Но в record так не сделаешь, а даже если бы сделали через кастомный конструктор — получилось бы уже не «модель данных», а «модель с логикой». Spring Boot даёт для этого более аккуратный инструмент: @DefaultValue.
Идея очень простая: вы говорите binder’у, что делать, если свойство отсутствует. Причём делаете это прямо рядом с тем параметром, к которому это относится. Это похоже на «значение по умолчанию» в форме регистрации: если пользователь не заполнил поле «город», мы подставим «Токио». Только у нас пользователь — это конфиг.
Пример для нашего лимита featured-курсов:
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties("app.catalog")
public record CatalogProperties(
String title,
@DefaultValue("4") int maxFeaturedCount // Если app.catalog.max-featured-count отсутствует, используем 4
) {}
Теперь, если в YAML нет max-featured-count, binder подставит строку "4", сконвертирует её в int, и вызовет конструктор как new CatalogProperties(title, 4).
Полезный момент: @DefaultValue принимает строку, но Boot умеет конвертировать её в целевой тип теми же правилами, что и обычные свойства. Поэтому это работает не только для чисел и булевых, но и для типизированных значений вроде Duration.
Например, если вы добавили в конфигурацию небольшую настройку тайминга, то можно сделать так:
import java.time.Duration;
import org.springframework.boot.context.properties.bind.DefaultValue;
public record StartupReportProperties(
@DefaultValue("2s") Duration delay // Если delay не задан, считаем, что задержка 2 секунды
) {}
И тогда в YAML можно было бы (при желании) написать delay: 5s, а если не написать — будет 2s. Здесь важно, что формат "2s" — это тот же формат, который вы бы использовали в конфигурации.
Ещё одна ключевая деталь: явное значение из конфигурации всегда важнее default. То есть если вы всё-таки зададите:
app:
catalog:
max-featured-count: 10 # Явно заданное значение перекрывает @DefaultValue("4")
то будет 10, а не 4. @DefaultValue — это именно «план Б», а не «мне всё равно, что там в YAML».
6. Где держать дефолты: в YAML или в Java
На этом месте обычно появляется соблазн: «О! Давайте сделаем defaults в Java, а YAML будет крошечным». А потом возникает второй соблазн: «А давайте ещё и в YAML продублируем, чтобы точно было видно». И вот тут рождается легендарный конфигурационный зверь — два источника правды, который питается вашим временем и нервами.
Хорошая конфигурационная модель обычно выбирает осознанное место для дефолтов, а не «везде понемногу». В реальности у дефолтов есть два нормальных дома: либо в коде рядом с параметром (@DefaultValue), либо в YAML как часть базовой конфигурации проекта.
Удобно сравнить так:
| Подход | Когда уместен | Плюсы | Минусы |
|---|---|---|---|
| Default в Java (@DefaultValue) | Когда значение опционально и должно быть одинаковым «везде» | Контракт рядом с типом, меньше шума в YAML | Можно спрятать важный факт от того, кто читает только YAML |
| Default в YAML (application.yaml) | Когда вы хотите, чтобы базовый конфиг был «самодостаточным» | Всё видно в конфиге, удобно менять без перекомпиляции | YAML распухает, легко плодить дубли по профилям |
| Без default вообще | Когда значение реально обязательно | Ошибка неполного конфига всплывает раньше | Нужно дисциплинированно задавать это значение при запуске |
В нашем учебном проекте catalog-service логика обычно такая: флаги и лимиты, которые «разумны по умолчанию», удобно держать прямо в модели через @DefaultValue. А то, что описывает содержимое каталога (список курсов, их цены и даты) — это уже данные, которые должны жить в YAML, потому что это «содержание приложения», а не «инженерная настройка».
Самое главное правило здесь звучит скучно, но работает как швейцарские часы: default должен быть осмысленным. Если вы пишете @DefaultValue("123") просто потому, что «ну надо же что-то подставить», вы не задаёте дефолт — вы прячете будущую проблему.
7. Обновляем CatalogProperties в catalog-service
Теперь соберём это в реальный шаг внутри проекта. Ниже — не весь конфигурационный контракт catalog-service, а уже более близкий к нему срез: на нём удобно увидеть, как рядом живут обязательные поля, defaults и список курсов. Проверки корректности и инварианты будут накладываться поверх этой же модели, а не на какой-то отдельный «другой конфиг».
Пример вполне «здоровой» версии CatalogProperties с дефолтами для опциональных флагов и лимитов:
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties("app.catalog") // Все поля ниже — из app.catalog.*
public record CatalogProperties(
String title, // Обычно обязательное поле: если не задано, дальше можно легко словить NPE/пустой UI
@DefaultValue("false")
boolean maintenanceMode, // Если свойство отсутствует — maintenance выключен
@DefaultValue("4")
int maxFeaturedCount, // Если свойство отсутствует — показываем 4 элемента в подборке
@DefaultValue("true")
boolean defaultPublishedOnly, // Безопасное поведение по умолчанию: показывать только опубликованное
@DefaultValue("true")
boolean startupReportEnabled, // По умолчанию можно оставлять включённым для диагностики в учебном проекте
List<CourseItem> courses // Список обычно задаётся в YAML как "данные приложения"
) {}
Этого уже достаточно, чтобы увидеть, как constructor binding собирает корневой объект целиком. Само качество значений — отдельный вопрос: его будем закрывать validation’ом и инвариантами.
Обратите внимание на смысл, а не на аннотации. Мы делаем две вещи.
Во-первых, мы говорим: «в обычном режиме maintenance выключен». Если его не указали — всё равно выключен. Это удобно, потому что случайное включение maintenance-mode куда опаснее, чем случайное выключение.
Во-вторых, мы задаём базовые значения, которые влияют на поведение каталога. Например, defaultPublishedOnly = true означает: если клиент не уточнил фильтр, мы будем показывать только опубликованные курсы. Это безопасное поведение для read-only каталога.
Теперь сервис, который использует эти настройки, может быть заметно проще. Например, если вы ограничиваете featured-подборку, вам больше не нужно думать «а что, если лимит 0 из-за того, что свойство забыли?». Вы либо получите значение из конфигурации, либо внятный дефолт:
import org.springframework.stereotype.Service;
@Service // Обычный сервис, который использует уже собранные CatalogProperties
public class FeaturedCourseService {
private final CatalogProperties properties;
public FeaturedCourseService(CatalogProperties properties) {
// Тут мы получаем готовый bean с настройками (объект уже создан binder'ом ранее)
this.properties = properties;
}
public int featuredLimit() {
// Единая точка получения лимита: либо значение из YAML, либо @DefaultValue
return properties.maxFeaturedCount();
}
}
Если вы не задали max-featured-count, метод вернёт 4. Если задали 10 — вернёт 10. Поведение не «плавает» от того, что кто-то забыл строчку в YAML.
И ещё один тонкий момент: @DefaultValue не защищает от явно заданных странных значений. Если вы напишете в YAML max-featured-count: 0, то будет 0. Default — это fallback на случай отсутствия значения, а не «охранник смысла». Поэтому defaults и качество значений — это две разные темы, и их важно не смешивать в голове.
8. Типичные ошибки с @DefaultValue
Ошибка №1: путать constructor binding и constructor injection.
Очень распространённая путаница: студент слышит «конструктор» и думает, что это про DI. В результате он пытается инжектить Environment в CatalogProperties или наоборот — ожидает, что @DefaultValue как-то влияет на обычные сервисы. Держите простую границу: binding — это создание объекта конфигурации из свойств, injection — это раздача готовых объектов по приложению.
Ошибка №2: оставлять примитивы без @DefaultValue, а потом удивляться «магическим нулям».
Если поле int или boolean и свойство отсутствует, вы часто не получите ошибку — вы получите 0/false. Это не «баг Spring», это базовая математика Java. В properties-модели примитив без default — это приглашение к тихим сюрпризам.
Ошибка №3: ставить defaults на обязательные поля «чтобы приложение хоть как-то стартовало».
Если вы поставили @DefaultValue("Catalog") на title, то приложение, конечно, стартует. Но вы сами себе подложили мину: в одном окружении вы хотели "Spring+ Catalog", а в другом получили "Catalog", потому что где-то забыли конфиг. Если поле действительно важно, лучше не подменять его умолчанием.
Ошибка №4: дублировать один и тот же default и в Java, и в YAML.
Когда @DefaultValue("4") стоит в коде, и в YAML написано max-featured-count: 4, вы получаете вопрос без ответа: «а какое из этих мест является настоящим?» Самое неприятное, что через месяц кто-то поменяет YAML на 6, а в коде забудет — и начнётся классический спор «почему в одном запуске 4, а в другом 6». Лучше один источник правды.
Ошибка №5: думать, что @DefaultValue — это проверка корректности.
Default не спасает, если значение указано, но оно «плохое». Если в YAML поставить max-featured-count: -10, default не включится — значение же есть. @DefaultValue решает проблему отсутствия, а не проблему смысла. Поэтому выбирать дефолты надо аккуратно, а значения — задавать осмысленно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ