CodeGym /Courses /Spring Boot /Immutable properties with Java records

Immutable properties with Java records

Spring Boot
Level 18, Lesson 0
Available

1. Configuration as data

When we say “application configuration,” it is very easy to imagine some kind of “control panel” where you can turn knobs while the app is already running. In reality, in most Spring Boot services configuration is a snapshot: at startup the application reads values, binds them to types, and then lives with that set as a matter of biography. Like a date of birth: sure, you can try to “change” it, but the world will only get stranger.

For our catalog-service, configuration is almost a “database” in its own right: a list of courses, limits for the featured selection, behavior flags. That means the configuration model is a contract that must be predictable and readable. If that contract can be changed accidentally after startup, we get an application whose behavior starts to sound like “well... it just does that sometimes.” And trust me, that is the worst genre of backend literature.

There is one more practical detail: a Spring Boot application is multithreaded. Your controller handles requests in parallel, services are called from different threads, and the configuration is used “everywhere.” A config that someone mutates by accident turns into a lottery. And yes, lotteries are great, just usually not in production and not on deadlines.

So today’s idea is simple: the configuration model should be read-only, meaning immutable after Boot has bound it and the context has been started.

2. Risks of mutable properties with setters

The classic @ConfigurationProperties style from the “old school” (or from the code generator in your head) looks like a JavaBean: private fields, getXxx(), setXxx(). It works, and Spring Boot will happily populate it. The problem is not that it is “wrong,” but that it leaves too much freedom where freedom is not needed.

Let us look at a mini-version of CatalogProperties in mutable style (simplified, with only two fields — what matters here is the shape, not the final structure):

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog")
public class CatalogProperties {

    // These are configuration values that Spring Boot will populate when the application starts.
    private String title;
    private int maxFeaturedCount;

    // Getters/setters create the JavaBean style.
    // This is where the risk comes from: the object can be changed after the application starts.
    public String getTitle() { return title; }

    // A setter gives any code the ability to "tweak" the config while the app is running.
    public void setTitle(String title) { this.title = title; }

    public int getMaxFeaturedCount() { return maxFeaturedCount; }

    // In practice, these are exactly the setters that turn config into a "lottery."
    public void setMaxFeaturedCount(int maxFeaturedCount) { this.maxFeaturedCount = maxFeaturedCount; }
}

If you look at this as a developer who is just getting used to Spring, your brain says: “Well, okay, just a normal POJO.” And that is where the dangerous part begins: you get used to the fact that an object can be changed. And not only by your code, but by any other component that received a reference to CatalogProperties.

Imagine a realistic “done quickly and forgotten” scenario where someone in a service decides to “tweak” the limit so they can “see more featured items locally”:

// We got the config from DI (for example, through the constructor).
CatalogProperties props = /* got from DI */;

// An "innocent" change: we modify the configuration right while the application is running.
props.setMaxFeaturedCount(999);

// The logs will show the new value — and it will look "legit."
System.out.println(props.getMaxFeaturedCount()); // 999

Now imagine that this same service lives in an application handling real requests. One request “tweaks” it, and another request already gets different behavior. No errors, no exceptions — your service has just become a “creative personality.”

There is a subtler problem too: the JavaBean style assumes that an object is created empty first and then filled step by step. Even if Spring does this carefully, the model shape itself encourages the feeling that “the object can be half-empty and half-filled.” But configuration should not work like that. Configuration should be either valid and complete, or the application should honestly fail at startup.

And one last thing: having setters usually leads to “convenient methods” being pushed into the configuration model, and then — business logic as well. First it is “let’s calculate featured courses right here,” then “let’s search by slug right here too,” and suddenly your properties class has become a service, only without the @Service annotation and without any sense of responsibility.

3. An immutable configuration model

An immutable model means the object is created once, receives all values at once, and after that cannot change its state. This is not the religion of final, but a very pragmatic engineering habit: if data must be stable, we do not give code the ability to make it unstable “by accident.”

In the context of @ConfigurationProperties, this means a simple contract. Boot starts, reads application.yaml and other sources, binds values, and gets a configuration object. From there, services and controllers only read it. Not “read and sometimes adjust,” not “temporarily change,” just read. Like a reference book.

From the standpoint of maintenance and debugging, this is almost magic — the good kind. When a bug report comes in saying “why did featured courses jump to 999,” at least you know that if the model is immutable, this is not “someone somewhere changed the object.” That means either the config was set that way, or binding assembled it that way, or you shot yourself in the foot earlier. But that is still a limited set of possibilities, not an endless series.

It is important to understand that an immutable model does not forbid you from changing configuration between runs. Change the YAML, activate a different profile, pass a different env var — absolutely. But during one run, the configuration should stay stable.

4. A Java record for @ConfigurationProperties

Records in Java are the language finally saying honestly: “Guys, sometimes you need a class that just holds data, without all that verbose ceremony.” For configuration, that is almost a perfect match: a configuration object is exactly “data-shaped.”

Here is the same CatalogProperties, but as a record:

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        // Record components are the configuration contract itself.
        String title,
        int maxFeaturedCount
) {
    // No setters: once created, the object cannot be "tweaked."
}

What do we get “for free,” without writing a single line of logic?

First, a record has no setters. That immediately removes an entire class of problems. If someone wants to change maxFeaturedCount after startup, they will have to create a new object. And since that object is created by Spring and placed in the container, it cannot just be changed casually.

Second, the entire contract is visible in the header. No need to scroll down looking for fields, setters, and getters. Your eyes see: title and maxFeaturedCount. Period. For a junior developer, that is especially valuable: fewer chances to get lost in boilerplate code.

Third, a record automatically generates equals(), hashCode(), toString(). For configuration, that is useful at least because such an object is easier to log (but you need to be careful with logging and not print sensitive data; our catalog-service does not store secrets, so in an educational project this is safer than in real life).

Fourth, a record is a final type, and its components are private final fields. So the structure itself nudges you toward the idea that “these are data, they should not be changed.”

And one more important thought: a record does not make your project “functional” or “reactive” or “too smart.” It makes your project readable. And honestly, that is the most underrated feature of modern Java.

A short comparison to make it stick:

Property Mutable JavaBean (class + setters) Immutable record
Can values be changed after startup Yes, easily and silently No, there are no setters
Is the contract visible immediately No, you need to read the class Yes, in the record header
Boilerplate A lot Almost none
Mindset “The object changes” “The data is fixed”

5. Converting CatalogProperties to a record

Now let us connect the idea to our project. We already have CatalogProperties living in the config package and bound to the app.catalog prefix. We already have typed binding: instead of a pile of strings, we get proper types in code. Now it is important to make this model structurally stable as well.

In a real project, CatalogProperties gradually becomes fairly rich: catalog title, behavior flags, and a list of courses. But at the start of the transition to a record, it is convenient to make a minimal version and look at the principle. For example, keep title and limit. That is enough to see the transition to a record itself; the full project contract will be broader — with flags, a list of courses, and consistency checks.

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        // Catalog title (the thing you can later expose through an external API/page).
        String title,

        // Limit for the featured selection (how many cards we show).
        int maxFeaturedCount
) {}

And the config for this will look very familiar:

app:
  catalog:
    # Human-readable catalog title
    title: "Spring+ Catalog"

    # relaxed binding: max-featured-count -> maxFeaturedCount
    max-featured-count: 4

Notice the nice detail: the YAML key max-featured-count binds cleanly to maxFeaturedCount. That is the relaxed binding that already makes configuration readable for humans and the model convenient for Java code.

Then, to use the properties, we simply inject them into a service and read the values. And this is the moment when you first feel the “psychological effect of records”: your hand reaches for getMaxFeaturedCount(), but the record says: “No, no, friend, this is data, here is maxFeaturedCount().”

import com.example.catalogservice.config.CatalogProperties;
import org.springframework.stereotype.Service;

@Service
public class FeaturedCourseService {

    // We keep a reference to configuration as read-only data.
    private final CatalogProperties properties;

    // Constructor injection: Spring gives us an already "assembled" object.
    public FeaturedCourseService(CatalogProperties properties) {
        this.properties = properties;
    }

    public int featuredLimit() {
        // A record has no getXxx(): we use the component accessor.
        return properties.maxFeaturedCount();
    }
}

What matters here is not the binding mechanism itself, but the result: after binding, the config becomes an object that cannot be “tweaked” with a setter. And the services around it become simpler: they read ready-made values and do their job without turning into a “second layer of configuration.”

Another small but useful psychological nuance: when the configuration object is immutable, you stop thinking “what if it changes?”. And that simplifies understanding code more than it seems.

6. Nested sections: records inside records

Almost any configuration in a live service very quickly stops being “flat.” You add startup flags, a data section, a behavior section, and if everything is kept in one list of fields, you end up with a giant sheet. And a giant sheet is bad: it is easy to lose meaning in it, especially if you are a junior and are not yet used to reading large models.

Records are good because they let you describe nesting directly in types without losing the YAML structure. What matters here is the grouping technique itself: if a section has its own meaning, you can extract it separately. That does not mean every single flag must live inside a nested record.

For example, let the catalog have a nested startup section:

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.catalog")
public record CatalogProperties(
        // Main catalog data
        String title,

        // Nested settings group (startup.*)
        StartupProperties startup
) {
    // A nested record helps keep configuration structured.
    public record StartupProperties(
            // Flag: do we print a report at application startup
            boolean reportEnabled
    ) {}
}

The YAML for this model reads almost like “configuration pseudocode”:

app:
  catalog:
    # General catalog title
    title: "Spring+ Catalog"
    startup:
      # Enable the startup report (for example, log the course composition)
      report-enabled: true

And this is where a very healthy habit appears: you start designing configuration as a hierarchy of meanings. Not “here is a bunch of keys,” but “here are the catalog settings, and inside them there are startup settings.” In large services, this saves dozens of hours on maintenance, because “finding the right place” becomes much easier.

The only thing to remember here (and this will come back to us later today) is that the properties model must remain data-only. Nested records are about structure, not about “let us stuff business logic into them.” In CatalogProperties we describe configuration data, not how exactly to build an API response from it.

7. Accessors and the read-only habit

If you previously lived in the JavaBean world, your autopilot likes getTitle() and isPublished() methods. Record has a different philosophy: the title component gives you a title() method. It feels unfamiliar for the first ten minutes, and then your brain stops noticing and even starts enjoying it, because there is less “noise” in the code.

For example, if you need to return the catalog title on a landing page, you write not properties.getTitle(), but properties.title(). It is a small thing, but it constantly reminds you: “this is data, not an object with a lifecycle that we keep mutating.”

// We got the configuration from DI: from here on, we only read it.
CatalogProperties props = /* got from DI */;

// Record accessors match the component names.
System.out.println(props.title());            // Spring+ Catalog
System.out.println(props.maxFeaturedCount()); // 4

Another thought: a record protects you from direct field mutation, but it does not make “absolute immutability” magic in every case. If a record contains, for example, a List<CourseItem>, the list itself may still be mutable (it depends on what exactly the binder created). So the right habit is: treat configuration as read-only data, even if technically Java lets you do something bad.

It is sometimes useful to remember a simple rule: config is not the place where we “fix the world”; it is the place where we “find out what world we were given.”

8. The path from YAML to the service

To avoid treating @ConfigurationProperties as a “magic annotation,” keep in mind the path that happens in our catalog-service. In reality, Boot has many steps inside, but at the course level this chain is enough:

flowchart TD
    %% Chain: configuration sources -> binding -> typed object -> use in code
    A["application.yaml / imported yaml / env vars"] --> B["Spring Boot Binder"]
    B --> C["CatalogProperties (record)"]
    C --> D["Service / Controller"]
    D --> E["API responses and application behavior"]

The point of records in this chain is simple: node C becomes a contract that is easy to read and hard to mess up. We are not pretending that errors are impossible — errors will happen (we are human, after all). But we reduce the number of ways to make an error invisible and long-lasting.

And that is really the main engineering win: not “pretty syntax,” but fewer hidden states. When configuration is immutable, the application either starts with clear values or does not start at all. But “it starts, and then someone changed the fields and it began living its own life” is exactly the mode we are moving away from.

9. Typical mistakes when moving to records

Mistake #1: leaving the properties class mutable “just in case.”
This often looks harmless: “what is the big deal, let there be a setter, maybe it will come in handy.” The problem is that a setter almost always “comes in handy” in the worst possible sense — someone will accidentally use it, and you will end up with a configuration change in the middle of execution. For configuration, it is healthier to start from the model “created once — then read it.”

Mistake #2: starting to put application-level calculations into the properties model.
Records allow you to write methods, and that can be tempting. First comes featuredLimit(), then publishedCourses(), then “let us search by slug right here too.” At that point the configuration model stops being configuration and becomes a half-service. Config should describe data. Leave filtering and search logic to services in catalog.service.

Mistake #3: making one giant record “for the whole application.”
As properties grow, there is a temptation to “keep everything in one place.” In practice, you end up with a record with 30–40 components that cannot be read quickly. It is better to preserve nesting and split the model by meaning: startup, courses, limits, flags. Then the YAML and the Java model will match structurally, and reading them will become easier.

Mistake #4: thinking that a record automatically makes everything inside immutable.
A record protects the fields (they are final), but if it contains a collection, that collection may be mutable. That is why it is important to keep the “configuration is read-only” discipline and not do things like properties.courses().clear(). If you need to strengthen the guarantee, you can do that later with careful techniques, but first and foremost it is the habit of not mutating the config that matters.

Mistake #5: treating record as the mandatory style for every class in the project.
Records are an excellent tool, but today we use them precisely because configuration is “data-shaped” by nature. The domain model (CourseCard, Money) and the web/service layer may have their own reasons to be class/record in different ways. Do not turn “records for configuration” into “records everywhere because it is trendy.” Trends pass — readability stays.

1
Task
Spring Boot, level 18, lesson 0
Locked
Immutable storefront configuration
Immutable storefront configuration
1
Task
Spring Boot, level 18, lesson 0
Locked
Nested immutable landing page configuration
Nested immutable landing page configuration
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION