JavaRush /Курсы /Spring Boot /Immutable properties на Ja...

Immutable properties на Java records

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

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 везде, потому что модно”. Модно — проходит, читаемость — остаётся.

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