In the previous lesson we took CicloUrbana's configuration out of the code, but the solution stopped halfway: the keys are spread across several classes, a mistyped camelCase in a @Value fails silently, nobody checks that the price per minute is positive and the IDE gives no help writing them. This lesson fixes all four with @ConfigurationProperties, the typed way of reading configuration: it groups related properties into immutable objects, binds lists and maps naturally, converts Duration, DataSize and enums without intervention, validates the values at startup with Bean Validation and generates metadata so your IDE can autocomplete. By the end, the whole fare and Ribalta network configuration will live in two validated records, and that will be its permanent home for the rest of the course.

Contents

  1. What @ConfigurationProperties is
  2. @Value versus @ConfigurationProperties
  3. Registering the properties: three ways
  4. Binding to a record and to a class with setters
  5. Nested structures, lists and maps
  6. CicloUrbana's complete configuration
  7. Validation with @Validated and Bean Validation
  8. Type conversion
  9. Metadata for the IDE
  10. Sensitive properties
  11. Common Mistakes and Tips
  12. Exercises

  1. What @ConfigurationProperties is

@ConfigurationProperties binds a group of properties sharing a prefix to the fields of a Java object. Instead of scattering five @Value annotations across four classes, you define an object representing "the fare configuration" and Spring fills it in.

The idea in one picture:

flowchart LR
    Y["application.yaml<br/><br/>ciclourbana:<br/>&nbsp;&nbsp;fare:<br/>&nbsp;&nbsp;&nbsp;&nbsp;unlock: 0.50<br/>&nbsp;&nbsp;&nbsp;&nbsp;price-per-minute: 0.12"] --> B["Spring Boot's Binder<br/>relaxed binding<br/>+ type conversion<br/>+ validation"]
    B --> O["FareProperties<br/>unlock = 0.50 (BigDecimal)<br/>pricePerMinute = 0.12 (BigDecimal)"]
    O --> S["Injected into StandardFare,<br/>FareSelector, ..."]

The resulting object is a bean like any other: it is constructor-injected and used with full type safety.

  1. @Value versus @ConfigurationProperties

Criterion @Value @ConfigurationProperties
Unit of work One loose property A group sharing a prefix
Relaxed binding No: exact match Yes: price-per-minute ≡ pricePerMinute ≡ PRICE_PER_MINUTE
YAML lists No (comma-separated strings only) Yes, naturally
Maps No Yes
Nested objects No Yes, to any depth
Validation (@NotBlank, @Min...) No Yes, with @Validated
Metadata for the IDE No Yes, with the processor
Type conversion Basic Complete (Duration, DataSize, enums, Period...)
SpEL (#{...}) Yes No
Error message on failure One property at a time Every failure at once
Where the configuration lives Spread through the code Centralised in dedicated classes
Recommendation Isolated, occasional values Everything else

@Value's only exclusive capability is SpEL. In exchange it loses everything else. The rule we will follow in CicloUrbana:

If a property is read by a single class and needs no validation, @Value is acceptable. As soon as there are two or more related properties, or two classes reading the same one, or any constraint on the value: @ConfigurationProperties.

  1. Registering the properties: three ways

A class with @ConfigurationProperties does not become a bean on its own: it has to be registered. There are three mechanisms.

Way 1: @ConfigurationPropertiesScan (the recommended one)

You annotate the main class once and Spring scans the base package looking for @ConfigurationProperties classes:

package com.ciclourbana;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan   // looks for @ConfigurationProperties in com.ciclourbana
public class CicloUrbanaApplication {

    public static void main(String[] args) {
        SpringApplication.run(CicloUrbanaApplication.class, args);
    }
}

From then on, any class annotated with @ConfigurationProperties in the package tree is registered automatically. It is the option we will use: one annotation and there is nothing left to remember.

Way 2: @EnableConfigurationProperties

This registers specific classes, by listing them:

@Configuration
@EnableConfigurationProperties({FareProperties.class, NetworkProperties.class})
public class CicloUrbanaConfig { }

It is more verbose, but it has one virtue: it is explicit. It is the usual form inside an autoconfiguration or a starter, where the user's component scan does not reach. We will use it that way in lesson 02-06.

Way 3: a plain stereotype

@Component
@ConfigurationProperties(prefix = "ciclourbana.fare")
public class FareProperties { /* ... */ }

It works, but only with mutable classes (with setters): a record cannot be a @Component because it needs constructor binding. It is the least advisable form.

Way Verbosity When to use it
@ConfigurationPropertiesScan Minimal Applications: once and done
@EnableConfigurationProperties Medium Autoconfigurations and starters
@Component on the class Minimal Almost never: it rules out records

  1. Binding to a record and to a class with setters

Spring Boot 3 supports two binding styles.

Constructor binding: an immutable record (recommended)

package com.ciclourbana.rentals;

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

import java.math.BigDecimal;

/**
 * Configuration of the Ribalta network's fares.
 * Prefix: ciclourbana.fare
 */
@ConfigurationProperties(prefix = "ciclourbana.fare")
public record FareProperties(

        /** Fixed unlock charge, in euros. */
        BigDecimal unlock,

        /** Charge per minute of use, in euros. */
        BigDecimal pricePerMinute
) {
}

With this YAML:

ciclourbana:
  fare:
    unlock: 0.50
    price-per-minute: 0.12

An important detail in Spring Boot 3: @ConstructorBinding is no longer needed if the class has a single constructor with parameters, which is always the case for a record. In Boot 2 it had to be written explicitly; you will see plenty of older code carrying it. If a class has several constructors, @ConstructorBinding marks which one to use, and from Boot 3 onwards it is annotated on the constructor, not on the class.

Setter binding: a mutable class

package com.ciclourbana.rentals;

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

import java.math.BigDecimal;

@ConfigurationProperties(prefix = "ciclourbana.fare")
public class FareProperties {

    private BigDecimal unlock = new BigDecimal("0.50");         // default value
    private BigDecimal pricePerMinute = new BigDecimal("0.12");

    public BigDecimal getUnlock() {
        return unlock;
    }

    public void setUnlock(BigDecimal unlock) {
        this.unlock = unlock;
    }

    public BigDecimal getPricePerMinute() {
        return pricePerMinute;
    }

    public void setPricePerMinute(BigDecimal pricePerMinute) {
        this.pricePerMinute = pricePerMinute;
    }
}

Side by side:

Criterion record (constructor) Class with setters
Immutability Yes No
Verbosity Minimal High
Default values In the compact constructor or with @DefaultValue By initialising the field
Undefined properties Arrive as null Keep their initial value
Modifiable at runtime No Yes (Spring Cloud Config, see 07-05)
Recommendation By default Only if you need mutability

Default values in a record are declared with @DefaultValue:

@ConfigurationProperties(prefix = "ciclourbana.fare")
public record FareProperties(
        @DefaultValue("0.50") BigDecimal unlock,
        @DefaultValue("0.12") BigDecimal pricePerMinute
) {
}

Now, if the YAML does not define ciclourbana.fare.unlock, the value will be 0.50 instead of null.

Using it in CicloUrbana

package com.ciclourbana.rentals;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component
@Primary
public class StandardFare implements FareCalculator {

    private final FareProperties properties;

    // A single parameter instead of two @Value annotations
    public StandardFare(FareProperties properties) {
        this.properties = properties;
    }

    @Override
    public BigDecimal calculate(Duration duration) {
        BigDecimal minutes = BigDecimal.valueOf(Math.max(1, duration.toMinutes()));
        return properties.unlock()
                .add(properties.pricePerMinute().multiply(minutes))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String name() {
        return "standard";
    }
}

Note the gain: the constructor goes from two parameters annotated with fragile string literals to one typed object. In a test, new StandardFare(new FareProperties(new BigDecimal("0.50"), new BigDecimal("0.12"))) is direct and needs no Spring.

  1. Nested structures, lists and maps

This is where @ConfigurationProperties parts company with @Value for good.

Nested objects

They are declared as nested records:

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        String city,
        Fare fare,
        Network network
) {
    public record Fare(BigDecimal unlock, BigDecimal pricePerMinute) { }

    public record Network(int minimumCapacity, int batteryThreshold) { }
}
ciclourbana:
  city: Ribalta
  fare:
    unlock: 0.50
    price-per-minute: 0.12
  network:
    minimum-capacity: 8
    battery-threshold: 20

Access is properties.fare().unlock(). You can nest as many levels as you need; in practice more than three becomes awkward.

Lists

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        List<String> featuredStations,
        List<MaintenanceWindow> maintenanceWindows
) {
    public record MaintenanceWindow(String day, LocalTime from, LocalTime to) { }
}
ciclourbana:
  featured-stations:
    - Main Square
    - University
  maintenance-windows:
    - day: TUESDAY
      from: "03:00"
      to: "05:00"
    - day: THURSDAY
      from: "03:00"
      to: "04:30"

In .properties the same list is written with indices, which illustrates why we chose YAML:

ciclourbana.maintenance-windows[0].day=TUESDAY
ciclourbana.maintenance-windows[0].from=03:00
ciclourbana.maintenance-windows[0].to=05:00
ciclourbana.maintenance-windows[1].day=THURSDAY

A warning: lists are not merged across property sources. If application.yaml defines three featured stations and an environment variable defines one, the result is one, not four. The highest-priority source replaces the whole list.

Maps

This is the most powerful case, and the one that solves lesson 02-02's FareSelector elegantly:

@ConfigurationProperties(prefix = "ciclourbana")
public record CicloUrbanaProperties(
        Map<String, BigDecimal> faresByUser
) {
}
ciclourbana:
  fares-by-user:
    standard: 0.12
    student: 0.08
    senior: 0.05

And the map's value can itself be an object:

public record CicloUrbanaProperties(
        Map<String, FareProfile> faresByUser
) {
    public record FareProfile(
            BigDecimal unlock,
            BigDecimal pricePerMinute,
            int freeMinutes
    ) { }
}
ciclourbana:
  fares-by-user:
    standard:
      unlock: 0.50
      price-per-minute: 0.12
      free-minutes: 0
    student:
      unlock: 0.00
      price-per-minute: 0.08
      free-minutes: 15
    senior:
      unlock: 0.00
      price-per-minute: 0.05
      free-minutes: 30

With this, adding a fare type no longer requires code: three extra lines of YAML are enough. It is a qualitative leap over the solution in lesson 02-02, where each fare needed its own class.

Two warnings about maps: keys are not relaxed (if you write grant-student, the key is literally grant-student, not grantStudent), and if a key contains special characters it must be wrapped in brackets: ciclourbana.fares-by-user.[key.with.dots].price-per-minute.

  1. CicloUrbana's complete configuration

We gather everything into two properties classes, which will be the project's definitive ones.

package com.ciclourbana.rentals;

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

import java.math.BigDecimal;
import java.util.Map;

/**
 * Configuration of the Ribalta network's fare system.
 * Prefix: ciclourbana.fares
 */
@ConfigurationProperties(prefix = "ciclourbana.fares")
public record FareProperties(

        /** Default fixed unlock charge, in euros. */
        @DefaultValue("0.50") BigDecimal unlock,

        /** Default charge per minute, in euros. */
        @DefaultValue("0.12") BigDecimal pricePerMinute,

        /** Fare profiles per user type, indexed by their identifier. */
        Map<String, FareProfile> byUserType
) {

    /** Pricing terms for one user type. */
    public record FareProfile(
            @DefaultValue("0.00") BigDecimal unlock,
            BigDecimal pricePerMinute,
            @DefaultValue("0") int freeMinutes
    ) { }
}
package com.ciclourbana.stations;

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

import java.time.Duration;
import java.util.List;

/**
 * Operational configuration of Ribalta's station network.
 * Prefix: ciclourbana.network
 */
@ConfigurationProperties(prefix = "ciclourbana.network")
public record NetworkProperties(

        /** Name of the city, used in reports and in the startup greeting. */
        @DefaultValue("Ribalta") String city,

        /** Minimum docks required to register a station. */
        @DefaultValue("8") int minimumCapacity,

        /** Battery percentage below which a bike is taken out of service. */
        @DefaultValue("20") int batteryThreshold,

        /** Maximum length of a rental before a surcharge applies. */
        @DefaultValue("2h") Duration maximumRentalDuration,

        /** Stations shown as featured in the mobile app. */
        @DefaultValue({"Main Square", "University"}) List<String> featuredStations
) {
}

And the matching YAML:

# src/main/resources/application.yaml
spring:
  application:
    name: ciclourbana

server:
  port: 8080
  shutdown: graceful

logging:
  level:
    com.ciclourbana: DEBUG

ciclourbana:
  network:
    city: Ribalta
    minimum-capacity: 8
    battery-threshold: 20
    maximum-rental-duration: 2h
    featured-stations:
      - Main Square
      - University
  fares:
    unlock: 0.50
    price-per-minute: 0.12
    by-user-type:
      standard:
        unlock: 0.50
        price-per-minute: 0.12
        free-minutes: 0
      student:
        price-per-minute: 0.08
        free-minutes: 15
      senior:
        price-per-minute: 0.05
        free-minutes: 30

Now the fare selector leans on the configuration instead of on classes:

package com.ciclourbana.rentals;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Set;

/**
 * Works out the amount of a rental from the fare profiles defined in
 * the configuration. Adding a user type no longer requires writing a
 * class: adding it to the YAML is enough.
 */
@Service
public class FareSelector {

    private static final Logger log = LoggerFactory.getLogger(FareSelector.class);

    private final FareProperties fares;

    public FareSelector(FareProperties fares) {
        this.fares = fares;
        log.info("Configured fare profiles: {}", availableTypes());
    }

    public Set<String> availableTypes() {
        return fares.byUserType().keySet();
    }

    public BigDecimal calculate(String userType, Duration duration) {
        FareProperties.FareProfile profile = fares.byUserType().get(userType);
        if (profile == null) {
            throw new IllegalArgumentException("Unknown user type: " + userType
                    + ". Available: " + availableTypes());
        }

        long billable = Math.max(0, duration.toMinutes() - profile.freeMinutes());
        return profile.unlock()
                .add(profile.pricePerMinute().multiply(BigDecimal.valueOf(billable)))
                .setScale(2, RoundingMode.HALF_UP);
    }
}
c.c.rentals.FareSelector : Configured fare profiles: [standard, student, senior]

A design note: the FareCalculator interface and its implementations from lesson 02-02 are still useful for fares with logic of their own (a high-season fare that depends on the date, say, or a promotional one with complex rules). What we have done is move into configuration what was only data. Telling one from the other is a good design criterion: if the difference between two cases is a few numbers, it is configuration; if it is an algorithm, it is code.

  1. Validation with @Validated and Bean Validation

A negative price per minute, a minimum capacity of zero or an empty list of fares should stop the application starting. @ConfigurationProperties integrates with Jakarta Bean Validation to achieve that declaratively.

First, the dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

This is the starter we announced in the table in lesson 01-04; we will also use it, a great deal, in lesson 03-04 to validate the API's input.

Now the annotations:

package com.ciclourbana.rentals;

import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;

import java.math.BigDecimal;
import java.util.Map;

@Validated                                     // <-- turns validation on
@ConfigurationProperties(prefix = "ciclourbana.fares")
public record FareProperties(

        @NotNull
        @PositiveOrZero(message = "The unlock charge cannot be negative")
        @DefaultValue("0.50") BigDecimal unlock,

        @NotNull
        @DecimalMin(value = "0.01", message = "The price per minute must be greater than 0")
        @DefaultValue("0.12") BigDecimal pricePerMinute,

        @NotEmpty(message = "At least one fare profile must be defined")
        Map<String, @Valid FareProfile> byUserType   // @Valid: validates each value
) {

    public record FareProfile(

            @NotNull @PositiveOrZero
            @DefaultValue("0.00") BigDecimal unlock,

            @NotNull
            @DecimalMin(value = "0.01", message = "The price per minute must be positive")
            BigDecimal pricePerMinute,

            @Min(value = 0, message = "Free minutes cannot be negative")
            @DefaultValue("0") int freeMinutes
    ) { }
}

And for the network:

@Validated
@ConfigurationProperties(prefix = "ciclourbana.network")
public record NetworkProperties(

        @NotBlank(message = "The network's city must be given")
        @DefaultValue("Ribalta") String city,

        @Min(value = 4, message = "A station needs at least 4 docks")
        @Max(value = 100, message = "No Ribalta station goes beyond 100 docks")
        @DefaultValue("8") int minimumCapacity,

        @Min(0) @Max(100)
        @DefaultValue("20") int batteryThreshold,

        @NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
        @DefaultValue("2h") Duration maximumRentalDuration,

        @NotEmpty @DefaultValue({"Main Square", "University"})
        List<@NotBlank String> featuredStations
) {
}

The most useful constraints:

Annotation Applies to Checks
@NotNull Anything It is not null
@NotBlank String Not null, not empty and not whitespace only
@NotEmpty String, collections, maps Not null and not empty
@Min / @Max Integers Range
@Positive / @PositiveOrZero Numbers Sign
@DecimalMin / @DecimalMax BigDecimal, decimals Range with decimal precision
@Size(min, max) Strings and collections Length or number of elements
@Pattern(regexp) String Regular expression
@Email String Email format
@Valid Nested objects and collection elements Validates in cascade
@DurationMin / @DurationMax Duration Time range (from Spring Boot)

The @Valid inside the generic —Map<String, @Valid FareProfile>— is indispensable: without it, FareProfile's constraints are never evaluated. It is the most common omission when validating nested structures.

The startup failure

With this invalid configuration:

ciclourbana:
  network:
    city: ""
    minimum-capacity: 2
    battery-threshold: 150
  fares:
    by-user-type:
      student:
        price-per-minute: -0.05
        free-minutes: -3

Startup fails with a complete report, not one item at a time:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.ciclourbana.stations.NetworkProperties failed:

    Property: ciclourbana.network.city
    Value: ""
    Reason: The network's city must be given

    Property: ciclourbana.network.minimum-capacity
    Value: "2"
    Reason: A station needs at least 4 docks

    Property: ciclourbana.network.battery-threshold
    Value: "150"
    Reason: must be less than or equal to 100

Action:

Update your application's configuration

This message is the main reason for using @ConfigurationProperties with validation: it says which property, which value and why it is wrong, and it says it for all of them at once. Compare it with @Value's Could not resolve placeholder and the difference is enormous.

And the most valuable property of all this: the error happens at startup, not when a Ribalta citizen tries to pay for a rental at a negative fare.

  1. Type conversion

Spring Boot's binder automatically converts the file's text into the declared Java type. The most-used cases:

Duration

@DefaultValue("2h")   Duration maximumRentalDuration;
@DefaultValue("30s")  Duration dockWaitTimeout;
@DefaultValue("500ms") Duration maximumLatency;
ciclourbana:
  network:
    maximum-rental-duration: 2h         # 2 hours
    dock-wait-timeout: 30s              # 30 seconds
    sync-interval: PT15M                # ISO-8601 format is valid too
Suffix Unit Example
ns nanoseconds 500ns
us microseconds 200us
ms milliseconds 500ms
s seconds 30s
m minutes 15m
h hours 2h
d days 7d
(none) as per @DurationUnit, milliseconds by default 5000

If you prefer to write plain numbers with no suffix, @DurationUnit fixes the unit:

@DurationUnit(ChronoUnit.MINUTES)
@DefaultValue("120") Duration maximumRentalDuration;   // 120 means 120 minutes
ciclourbana:
  network:
    maximum-rental-duration: 120    # 120 minutes, not 120 milliseconds

Recommendation: always use the explicit suffix (2h) rather than @DurationUnit. It is self-documenting and does not require reading the Java code to interpret the file.

There is an equivalent @PeriodUnit for java.time.Period (days, months, years), useful for billing terms.

DataSize

For file or memory sizes:

@DefaultValue("5MB")  DataSize maximumIncidentPhotoSize;
@DefaultValue("512KB") DataSize maximumReportSize;
ciclourbana:
  incidents:
    maximum-photo-size: 5MB

Suffixes: B, KB, MB, GB, TB. With no suffix they are read as bytes, unless you use @DataSizeUnit. You will use it for real in module 3 when configuring incident photo uploads.

Enums

package com.ciclourbana.stations;

/** Operational status of a station on the network. */
public enum StationStatus {
    OPERATIONAL, MAINTENANCE, OUT_OF_SERVICE
}
@DefaultValue("OPERATIONAL") StationStatus defaultStatus;
ciclourbana:
  network:
    default-status: maintenance           # case-insensitive
    # these also work: MAINTENANCE, Maintenance, out-of-service

Enum conversion applies relaxation too: out-of-service, OUT_OF_SERVICE and outOfService all resolve to OUT_OF_SERVICE. If the value matches no constant, startup fails with a message that lists the valid values, which is excellent for the operator.

Collections and other types

List<String> featuredStations;          // natural YAML list
Set<String> tags;                       // no duplicates
Map<String, Integer> quotasByDistrict;  // map
LocalTime closingTime;                  // "23:30"
LocalDate seasonStart;                  // "2026-06-01"
Charset reportEncoding;                 // "UTF-8"
Locale defaultLocale;                   // "en-GB"
Resource invoiceTemplate;               // "classpath:templates/invoice.html"
Class<?> implementation;                // fully qualified class name
BigDecimal price;                       // exact decimal, mandatory for money

Your own converters

If you need a type Spring cannot convert, register a Converter:

package com.ciclourbana.common;

import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

/** Converts "RB-0142" into a Plate object. */
@Component
@ConfigurationPropertiesBinding   // <-- essential: it makes it visible to the binder
public class PlateConverter implements Converter<String, Plate> {

    @Override
    public Plate convert(String source) {
        return Plate.of(source);
    }
}

The @ConfigurationPropertiesBinding annotation is the key: without it, the converter exists as a bean but the binder does not use it.

  1. Metadata for the IDE

When you type server.po in application.yaml, IntelliJ or VS Code suggest server.port and show you its description and default value. That works because Spring Boot publishes metadata in a JSON file inside the jar. Your properties can do the same.

The annotation processor

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

<optional>true</optional> matters: the processor is only needed at compile time; it must not propagate to whoever depends on your artefact.

At compile time it generates target/classes/META-INF/spring-configuration-metadata.json from your @ConfigurationProperties classes and their Javadoc comments:

./mvnw clean compile
cat target/classes/META-INF/spring-configuration-metadata.json
{
  "groups": [
    {
      "name": "ciclourbana.network",
      "type": "com.ciclourbana.stations.NetworkProperties",
      "sourceType": "com.ciclourbana.stations.NetworkProperties"
    }
  ],
  "properties": [
    {
      "name": "ciclourbana.network.minimum-capacity",
      "type": "java.lang.Integer",
      "description": "Minimum docks required to register a station.",
      "sourceType": "com.ciclourbana.stations.NetworkProperties",
      "defaultValue": 8
    },
    {
      "name": "ciclourbana.network.battery-threshold",
      "type": "java.lang.Integer",
      "description": "Battery percentage below which a bike is taken out of service.",
      "sourceType": "com.ciclourbana.stations.NetworkProperties",
      "defaultValue": 20
    }
  ]
}

Notice where the description field comes from: the Javadoc comment on the record component. That is the best reason to document your properties: the comment does not stay in the code, it shows up in the autocompletion of whoever configures the application.

Additional metadata by hand

For what the processor cannot infer —allowed values, dynamically declared properties, deprecation markers— there is a file you write yourself:

// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "ciclourbana.network.default-status",
      "type": "com.ciclourbana.stations.StationStatus",
      "description": "Status new stations are registered with.",
      "defaultValue": "OPERATIONAL"
    },
    {
      "name": "ciclourbana.fares.flat-fare",
      "type": "java.math.BigDecimal",
      "description": "Monthly flat fare. Replaced by ciclourbana.fares.subscription.",
      "deprecation": {
        "level": "error",
        "reason": "Replaced by the subscription model.",
        "replacement": "ciclourbana.fares.subscription.monthly-price"
      }
    }
  ],
  "hints": [
    {
      "name": "ciclourbana.network.featured-stations",
      "values": [
        { "value": "Main Square", "description": "24-dock station in the centre." },
        { "value": "North Station", "description": "30-dock station." },
        { "value": "River Park", "description": "18-dock station." },
        { "value": "University", "description": "36-dock station on the south campus." }
      ]
    }
  ]
}

The two blocks solve different needs:

  • deprecation makes the IDE strike the property through and show the alternative. With "level": "error" it signals that it no longer works at all. It is the correct way to retire a property without silently breaking users.
  • hints offers suggested values with descriptions. When you type ciclourbana.network.featured-stations: the IDE proposes Ribalta's four stations.

This file is merged with the automatically generated one; it does not replace it.

  1. Sensitive properties

We pick up the thread from the previous lesson. Credentials do not go into the repository; now we look at how to stop them leaking by other routes as well.

Do not log them

The most common failure: an automatic toString() that includes the password.

// WRONG: a record's toString() includes EVERY component
@ConfigurationProperties(prefix = "ciclourbana.gateway")
public record GatewayProperties(String url, String apiKey) { }
log.info("Gateway configuration: {}", properties);
// -> GatewayProperties[url=https://payments.ribalta.example, apiKey=sk_live_9f3a2b1c8d7e]
// The key has just been written into the log file, the aggregator and the backups

The fix: override toString().

package com.ciclourbana.common;

import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "ciclourbana.gateway")
public record GatewayProperties(

        /** Base URL of the municipal payment gateway. */
        @NotBlank String url,

        /** API key. It must never be logged or committed. */
        @NotBlank String apiKey
) {

    /** Hides the API key in any textual output. */
    @Override
    public String toString() {
        return "GatewayProperties[url=" + url + ", apiKey=" + mask(apiKey) + "]";
    }

    private static String mask(String value) {
        if (value == null || value.length() < 8) {
            return "****";
        }
        // Keeps the first 4 characters: enough to identify the key
        return value.substring(0, 4) + "****" + value.substring(value.length() - 2);
    }
}
Gateway configuration: GatewayProperties[url=https://payments.ribalta.example, apiKey=sk_l****7e]

Masking in Actuator

The /actuator/env endpoint exposes the whole configuration. Spring Boot automatically masks keys containing password, secret, key, token, credentials or vcap_services, and in Spring Boot 3 that endpoint is not exposed by default. You can extend the list:

management:
  endpoint:
    env:
      show-values: when-authorized      # never 'always' in production
    configprops:
      show-values: when-authorized
  endpoints:
    web:
      exposure:
        include: health,info            # do NOT casually expose env or configprops

Actuator is covered in lesson 07-01; what matters now is knowing it exists and that exposing it carelessly publishes your entire configuration.

Summary of the rules

Rule Why
The value never in the repository Git does not forget (lesson 02-04)
Placeholder with no default (${API_KEY}) Fails at startup if missing
Masked toString() Prevents the leak through logs
Actuator with no env or configprops exposed Prevents the leak over HTTP
Rotate the credential if it leaks Deleting it from the code does not invalidate it

Common Mistakes and Tips

Forgetting to register the properties class. Without @ConfigurationPropertiesScan, @EnableConfigurationProperties or @Component, the class is not a bean and startup fails with NoSuchBeanDefinitionException. It is, by a distance, mistake number one with @ConfigurationProperties.

Putting @ConfigurationProperties on a class with no setters and no parameterised constructor. Every field stays null with no error at all. With a record it cannot happen; with mutable classes, it can.

Forgetting @Valid on a nested object or inside a collection's generic. The inner constraints are never evaluated and an invalid configuration sails through startup. Remember Map<String, @Valid FareProfile>.

Putting @Validated on the nested class instead of on the root. @Validated goes on the class annotated with @ConfigurationProperties; the cascade inwards is produced by @Valid.

Expecting lists to merge across sources. They do not: the highest-priority source replaces the whole list.

Using double for amounts. 0.1 + 0.2 is not 0.3 in binary floating point. With money, always BigDecimal.

Writing the prefix in uppercase or with underscores. The prefix on @ConfigurationProperties must be lowercase kebab-case: ciclourbana.fares, not cicloUrbana.Fares. Spring Boot rejects it explicitly.

Not including the spring-boot-configuration-processor. It breaks nothing, but you lose autocompletion and documentation in the IDE, which is one of the mechanism's best advantages. And remember: if you add the processor with the IDE open, you will need to recompile and, in IntelliJ, sometimes reimport the Maven project.

Tip: one properties class per functional area. FareProperties in .rentals, NetworkProperties in .stations. Placing them next to their functionality, rather than in a generic config package, keeps the cohesion we settled on in lesson 01-04.

Tip: document every component with Javadoc. It is not ceremony: that text ends up in the IDE autocompletion of whoever configures the application.

Tip: always validate, even when it feels excessive. Every constraint you add turns a possible production error into a thirty-second startup failure.

Tip: do not duplicate configuration in the code. If NetworkProperties.minimumCapacity is 8, StationService must read that property, not carry its own if (capacity < 8). That is exactly what we will fix in the first exercise.

Exercises

Exercise 1: migrate StationService to the configuration

StationService still carries the rule if (station.capacity() < 8) with the 8 hard-coded. Inject NetworkProperties and use minimumCapacity(). Also add a validation that prevents registering a station whose name is not among the featured ones when the network is in maintenance mode (use a new boolean property ciclourbana.network.featured-only, defaulting to false). Check the behaviour by overriding the property from the command line.

Exercise 2: incident properties with validation and types

Create IncidentProperties with the prefix ciclourbana.incidents including: maximum-photo-size (DataSize, 5MB by default, between 100KB and 20MB), resolution-deadline (Duration, 48h by default, at least 1 hour), default-priority (a Priority enum with LOW, MEDIUM, HIGH), alert-recipients (a non-empty list of emails validated with @Email). Write a CommandLineRunner that dumps the configuration and check the error message with invalid values.

Exercise 3: metadata and secret masking

Add the spring-boot-configuration-processor to pom.xml, document every component of NetworkProperties with Javadoc and verify that they appear in the generated JSON. Then create GatewayProperties with url and api-key, with the key read from an environment variable with no default value and a masked toString(), and add an additional-spring-configuration-metadata.json with hints for ciclourbana.network.featured-stations.


Solutions

Solution 1

package com.ciclourbana.stations;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMax;
import org.springframework.boot.convert.DurationMin;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;
import java.util.List;

@Validated
@ConfigurationProperties(prefix = "ciclourbana.network")
public record NetworkProperties(

        /** Name of the city the network operates in. */
        @NotBlank @DefaultValue("Ribalta") String city,

        /** Minimum docks required to register a station. */
        @Min(4) @Max(100) @DefaultValue("8") int minimumCapacity,

        /** Battery percentage below which a bike is taken out of service. */
        @Min(0) @Max(100) @DefaultValue("20") int batteryThreshold,

        /** Maximum length of a rental before a surcharge applies. */
        @NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24)
        @DefaultValue("2h") Duration maximumRentalDuration,

        /** Stations featured in the mobile app. */
        @NotEmpty @DefaultValue({"Main Square", "University"})
        List<@NotBlank String> featuredStations,

        /** If true, only featured stations may be registered. */
        @DefaultValue("false") boolean featuredOnly
) {
}
package com.ciclourbana.stations;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

@Service
public class StationService {

    private static final Logger log = LoggerFactory.getLogger(StationService.class);

    private final StationRepository stationRepository;
    private final NetworkProperties network;

    public StationService(StationRepository stationRepository, NetworkProperties network) {
        this.stationRepository = stationRepository;
        this.network = network;
    }

    public List<Station> listAll() {
        return stationRepository.findAll().stream()
                .sorted(Comparator.comparing(Station::name))
                .toList();
    }

    public Optional<Station> findById(Long id) {
        return stationRepository.findById(id);
    }

    public Station register(Station station) {
        // The threshold is no longer hard-coded: it comes from the configuration
        if (station.capacity() < network.minimumCapacity()) {
            throw new IllegalArgumentException(
                    "A station in " + network.city() + " requires at least "
                            + network.minimumCapacity() + " docks; received: "
                            + station.capacity());
        }

        if (network.featuredOnly() && !network.featuredStations().contains(station.name())) {
            throw new IllegalStateException(
                    "The network is restricted to featured stations; '"
                            + station.name() + "' is not one");
        }

        Station saved = stationRepository.save(station);
        log.info("Station registered in {}: {} ({} docks)",
                network.city(), saved.name(), saved.capacity());
        return saved;
    }

    public int totalNetworkCapacity() {
        return stationRepository.findAll().stream()
                .mapToInt(Station::capacity)
                .sum();
    }

    public long count() {
        return stationRepository.count();
    }
}

Verification:

# Normal: the 4 demo stations are loaded
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Loaded 4 stations, 108 docks in total

# Featured only: "North Station" and "River Park" are rejected
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.network.featured-only=true
# IllegalStateException: The network is restricted to featured stations;
#   'North Station' is not one

# A stricter threshold: "River Park" (18) passes, but a 16-dock one would not
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --ciclourbana.network.minimum-capacity=20
# IllegalArgumentException: A station in Ribalta requires at least 20 docks;
#   received: 18

Comment: notice that the error message is built from the configuration values. It is a small detail with a big effect: whoever reads the log understands immediately which rule was applied and with what threshold, without opening the code.

Tip: network.featuredStations().contains(...) is a linear search over a list. With four elements it is irrelevant, but if the list grew it would be worth converting it to a Set just once —in a @PostConstruct on the service, for instance, applying what we learned in lesson 02-03.

Solution 2

package com.ciclourbana.incidents;

/** Attention priority of an incident reported by a user. */
public enum Priority {
    LOW, MEDIUM, HIGH
}
package com.ciclourbana.incidents;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMin;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;
import java.util.List;

/**
 * Configuration of the Ribalta network's incident system.
 * Prefix: ciclourbana.incidents
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.incidents")
public record IncidentProperties(

        /** Maximum size of the photo attached when reporting an incident. */
        @NotNull @DefaultValue("5MB") DataSize maximumPhotoSize,

        /** Committed deadline for resolving an incident. */
        @NotNull @DurationMin(hours = 1) @DefaultValue("48h") Duration resolutionDeadline,

        /** Priority assigned to incidents that do not state one. */
        @NotNull @DefaultValue("MEDIUM") Priority defaultPriority,

        /** Emails of the maintenance team who receive the alert. */
        @NotEmpty List<@Email String> alertRecipients
) {

    /**
     * Bean Validation does not cover DataSize ranges, so we validate it
     * in the compact constructor: it runs during binding.
     */
    public IncidentProperties {
        if (maximumPhotoSize != null) {
            long bytes = maximumPhotoSize.toBytes();
            if (bytes < DataSize.ofKilobytes(100).toBytes()
                    || bytes > DataSize.ofMegabytes(20).toBytes()) {
                throw new IllegalArgumentException(
                        "ciclourbana.incidents.maximum-photo-size must be between "
                                + "100KB and 20MB; received: " + maximumPhotoSize);
            }
        }
    }
}
ciclourbana:
  incidents:
    maximum-photo-size: 5MB
    resolution-deadline: 48h
    default-priority: medium              # relaxed: resolves to MEDIUM
    alert-recipients:
      - [email protected]
      - [email protected]
package com.ciclourbana.incidents;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class IncidentConfigNotice implements CommandLineRunner {

    private static final Logger log =
            LoggerFactory.getLogger(IncidentConfigNotice.class);

    private final IncidentProperties properties;

    public IncidentConfigNotice(IncidentProperties properties) {
        this.properties = properties;
    }

    @Override
    public void run(String... args) {
        log.info("Incidents | max photo: {} ({} bytes) | deadline: {} ({} h) | "
                        + "default priority: {} | alerts to: {}",
                properties.maximumPhotoSize(),
                properties.maximumPhotoSize().toBytes(),
                properties.resolutionDeadline(),
                properties.resolutionDeadline().toHours(),
                properties.defaultPriority(),
                properties.alertRecipients());
    }
}
Incidents | max photo: 5242880 (5242880 bytes) | deadline: PT48H (48 h) |
  default priority: MEDIUM | alerts to: [[email protected], [email protected]]

And with invalid values:

java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.incidents.resolution-deadline=30m \
  --ciclourbana.incidents.alert-recipients=this-is-not-an-email
Binding to target com.ciclourbana.incidents.IncidentProperties failed:

    Property: ciclourbana.incidents.resolution-deadline
    Value: "30m"
    Reason: must be greater than or equal to 1 hours

    Property: ciclourbana.incidents.alert-recipients[0]
    Value: "this-is-not-an-email"
    Reason: must be a well-formed email address

Comment: the exercise combines the three type-conversion capabilities —DataSize, Duration and an enum with relaxation— with cascading validation inside a list (List<@Email String>). The record's compact constructor is the idiomatic place for validations Bean Validation does not cover: it runs during binding and its exception is folded into the same error report.

Frequent mistake: writing resolution-deadline: 48 with no suffix. It would be read as 48 milliseconds and would fail the minimum-of-one-hour validation... fortunately. Without that validation, you would have had a 48 ms resolution deadline without noticing.

Solution 3

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>
./mvnw clean compile
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json | head -40
{
    "groups": [
        {
            "name": "ciclourbana.network",
            "type": "com.ciclourbana.stations.NetworkProperties",
            "sourceType": "com.ciclourbana.stations.NetworkProperties"
        }
    ],
    "properties": [
        {
            "name": "ciclourbana.network.minimum-capacity",
            "type": "java.lang.Integer",
            "description": "Minimum docks required to register a station.",
            "sourceType": "com.ciclourbana.stations.NetworkProperties",
            "defaultValue": 8
        }
    ]
}

The gateway class:

package com.ciclourbana.common;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * Credentials for Ribalta's municipal payment gateway.
 * The API key is NEVER committed: it arrives in an environment variable.
 */
@Validated
@ConfigurationProperties(prefix = "ciclourbana.gateway")
public record GatewayProperties(

        /** Base URL of the council's payment service. */
        @NotBlank @Pattern(regexp = "https://.*",
                message = "The gateway must use HTTPS") String url,

        /** API key. It is masked in toString() and must never be logged. */
        @NotBlank String apiKey
) {

    @Override
    public String toString() {
        return "GatewayProperties[url=" + url + ", apiKey=" + mask(apiKey) + "]";
    }

    private static String mask(String value) {
        if (value == null || value.length() < 8) {
            return "****";
        }
        return value.substring(0, 4) + "****" + value.substring(value.length() - 2);
    }
}
ciclourbana:
  gateway:
    url: https://payments.ribalta.example/api/v1
    # No default value: if the variable is missing, the startup fails
    api-key: ${CICLOURBANA_GATEWAY_API_KEY}
// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "ciclourbana.gateway.api-key",
      "type": "java.lang.String",
      "description": "Gateway API key. It must arrive in the CICLOURBANA_GATEWAY_API_KEY environment variable; it is never committed."
    }
  ],
  "hints": [
    {
      "name": "ciclourbana.network.featured-stations",
      "values": [
        { "value": "Main Square", "description": "24 docks, historic centre." },
        { "value": "North Station", "description": "30 docks, transport interchange." },
        { "value": "River Park", "description": "18 docks, green area." },
        { "value": "University", "description": "36 docks, south campus." }
      ]
    },
    {
      "name": "ciclourbana.network.default-status",
      "values": [
        { "value": "OPERATIONAL", "description": "The station accepts rentals." },
        { "value": "MAINTENANCE", "description": "Temporarily closed." },
        { "value": "OUT_OF_SERVICE", "description": "Closed indefinitely." }
      ]
    }
  ]
}

Final test:

# Without the variable: it fails, and that is correct
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Could not resolve placeholder 'CICLOURBANA_GATEWAY_API_KEY'

# With the variable, checking that the log does not reveal it
CICLOURBANA_GATEWAY_API_KEY='sk_live_9f3a2b1c8d7e' \
  java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Gateway configured: GatewayProperties[url=https://payments.ribalta.example/api/v1, apiKey=sk_l****7e]

Comment: the @Pattern(regexp = "https://.*") on the URL is a detail worth copying. It stops anyone, through configuration alone, from pointing the payment gateway at an unencrypted endpoint, and it does so at startup, not once the first credit card has already gone over the wire in the clear.

On the masking: keeping the first four characters is a deliberate compromise. It lets an operator identify which key is in use (sk_live_ versus sk_test_) without revealing the key. Masking 100% is safer but makes it impossible to diagnose a deployment with the wrong credential.

Conclusion

CicloUrbana's configuration has reached its final form. You know that @ConfigurationProperties binds a group of properties sharing a prefix to a typed object, and you know the eleven differences that make it superior to @Value in everything but SpEL. You know how to register it with @ConfigurationPropertiesScan in an application and with @EnableConfigurationProperties in a starter —you will need that in the next lesson. You know how to bind to an immutable record, which in Spring Boot 3 no longer needs @ConstructorBinding, and how to give defaults with @DefaultValue. You have mastered the structures @Value cannot reach: nested objects, lists and maps of objects, with the practical consequence that adding a fare to the Ribalta network went from writing a class to writing three lines of YAML. You know how to validate with @Validated and Bean Validation, including cascading with @Valid inside generics, and you have seen the startup report that lists every failure with its property, value and reason. You know the automatic conversion of Duration, DataSize, enums and collections, and how to register a converter of your own with @ConfigurationPropertiesBinding. You know how to generate metadata so the IDE autocompletes your properties from your own Javadoc, and how to extend it by hand with hints and deprecation markers. And you know how to protect a secret along the three routes by which it leaks: the repository, the log and Actuator.

The project now has FareProperties (with its map of profiles per user type), NetworkProperties (minimum capacity, battery threshold, maximum duration and featured stations), IncidentProperties and GatewayProperties, all validated, and a StationService that applies configurable rules instead of constants.

There is one piece of the container left to open, and it is the most characteristic one in Spring Boot. Since the first lesson we have said that autoconfiguration "detects what is on the classpath and registers the appropriate beans", and we have accepted that as a black box. We add spring-boot-starter-web and Tomcat, Jackson and the DispatcherServlet appear without a line being written. How exactly does it decide? And why is declaring your own bean enough to make Spring Boot step aside? The module's last lesson, Autoconfiguration and Starters from the Inside, answers that by reading the real mechanism —the AutoConfiguration.imports file, the AutoConfigurationImportSelector, the conditional annotations— and learning to debug it with the autoconfiguration report. And we will finish by building our own starter, ciclourbana-fares-spring-boot-starter, with the typed configuration we have just written.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved