JavaRush /Курсы /Spring Boot /Constructor binding и @Defa...

Constructor binding и @DefaultValue

Spring Boot
18 уровень , 1 лекция
Открыта

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)
max-featured-count
maxFeaturedCount
.properties стиль
maxFeaturedCount
maxFeaturedCount
ENV переменная
APP_CATALOG_MAX_FEATURED_COUNT
maxFeaturedCount
CLI аргумент
--app.catalog.max-featured-count=10
maxFeaturedCount

Обратите внимание, что «на самом деле» 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 решает проблему отсутствия, а не проблему смысла. Поэтому выбирать дефолты надо аккуратно, а значения — задавать осмысленно.

1
Задача
Spring Boot, 18 уровень, 1 лекция
Недоступна
Значения по умолчанию для флагов режима
Значения по умолчанию для флагов режима
1
Задача
Spring Boot, 18 уровень, 1 лекция
Недоступна
`Duration` по умолчанию и явное переопределение
`Duration` по умолчанию и явное переопределение
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ