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
- What
@ConfigurationPropertiesis @Valueversus@ConfigurationProperties- Registering the properties: three ways
- Binding to a
recordand to a class with setters - Nested structures, lists and maps
- CicloUrbana's complete configuration
- Validation with
@Validatedand Bean Validation - Type conversion
- Metadata for the IDE
- Sensitive properties
- Common Mistakes and Tips
- Exercises
- What
@ConfigurationProperties is
@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/> fare:<br/> unlock: 0.50<br/> 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.
@Value versus @ConfigurationProperties
@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,
@Valueis 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.
- 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 |
- Binding to a
record and to a class with setters
record and to a class with settersSpring 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:
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.
- 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: 20Access 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=THURSDAYA 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
) {
}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: 30With 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.
- 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: 30Now 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);
}
}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.
- Validation with
@Validated and Bean Validation
@Validated and Bean ValidationA 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: -3Startup 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 configurationThis 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.
- 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 minutesRecommendation: 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;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
}ciclourbana:
network:
default-status: maintenance # case-insensitive
# these also work: MAINTENANCE, Maintenance, out-of-serviceEnum 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 moneyYour 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.
- 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:
{
"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:
deprecationmakes 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.hintsoffers suggested values with descriptions. When you typeciclourbana.network.featured-stations:the IDE proposes Ribalta's four stations.
This file is merged with the automatically generated one; it does not replace it.
- 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 backupsThe 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);
}
}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 configpropsActuator 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: 18Comment: 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-emailBinding 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 addressComment: 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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
