Spring Boot
Custom Configuration Properties
Custom Configuration Properties let you define your own type-safe, validated, IDE-autocompleted configuration keys using @ConfigurationProperties. Instead of scattering @Value annotations across beans, you bind an entire prefix of properties to a plain Java class — giving you a single, structured, testable home for all application-specific configuration.
Why @ConfigurationProperties Over @Value
@Value works well for a single injected scalar. It breaks down for anything more complex: nested objects, lists, maps, validation, or reuse across multiple beans. @ConfigurationProperties addresses all of these.
With @ConfigurationProperties you define a plain Java class annotated with a prefix. Spring Boot binds all matching properties from any source — application.yml, environment variables, command-line arguments — into the class at startup. The result is a strongly-typed, validated, centrally-defined configuration object that any bean can inject.
The practical advantages over @Value: type conversion is automatic, Bean Validation annotations work directly on fields, IDE auto-completion is generated by the annotation processor, the class is independently unit-testable, and refactoring a property name is a single-file change.
Basic @ConfigurationProperties Class
The minimal setup: a class with @ConfigurationProperties, registered with @ConfigurationPropertiesScan, and backed by application.yml entries under the matching prefix.
Java
// 1. Define the class:
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String name;
private String version = "1.0.0"; // field default used when key is absent
private boolean debugMode = false;
private int maxRetries = 3;
// Standard getters and setters (required for binding):
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public boolean isDebugMode() { return debugMode; }
public void setDebugMode(boolean debugMode) { this.debugMode = debugMode; }
public int getMaxRetries() { return maxRetries; }
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
}
// 2. Register — one of three equivalent approaches:
// Option A: @ConfigurationPropertiesScan on the main class (preferred):
@SpringBootApplication
@ConfigurationPropertiesScan
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
// Option B: @EnableConfigurationProperties on any @Configuration class:
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig { }
// Option C: @Component directly on the properties class:
@ConfigurationProperties(prefix = "app")
@Component
public class AppProperties { }
// 3. Inject and use anywhere:
@Service
@RequiredArgsConstructor
public class InfoService {
private final AppProperties appProperties;
public String getInfo() {
return appProperties.getName() + " v" + appProperties.getVersion();
}
}Backing YAML
The application.yml structure maps directly to the @ConfigurationProperties class — nested objects become inner classes, lists and maps have natural YAML syntax. Environment variables follow the relaxed binding convention.
yaml
# application.yml — binds to AppProperties (prefix = "app"):
app:
name: MyApplication
version: 2.1.0
debug-mode: false # kebab-case maps to debugMode (relaxed binding)
max-retries: 3
# Override with environment variables (relaxed binding):
# APP_NAME=MyApplication
# APP_VERSION=2.1.0
# APP_DEBUG_MODE=false
# APP_MAX_RETRIES=3
# Override with command-line arguments:
# java -jar myapp.jar --app.name=MyApplication --app.max-retries=5Nested Objects
Nested configuration groups are modelled as static inner classes. Spring Boot binds the nested prefix automatically — no extra annotation needed on the inner class.
Java
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String name;
private final Jwt jwt = new Jwt(); // initialize — never null
private final Mail mail = new Mail();
private final Features features = new Features();
// Nested class — bound to app.jwt.*:
public static class Jwt {
private String secret;
private long expiration = 86400;
private String issuer;
// getters + setters
}
// Nested class — bound to app.mail.*:
public static class Mail {
private String from = "noreply@myapp.com";
private String replyTo;
private boolean enabled = true;
// getters + setters
}
// Nested class — bound to app.features.*:
public static class Features {
private boolean payments = false;
private boolean notifications = true;
private boolean maintenanceMode = false;
// getters + setters
}
// getters for all fields (setters only needed for mutable top-level fields):
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Jwt getJwt() { return jwt; }
public Mail getMail() { return mail; }
public Features getFeatures() { return features; }
}Nested Objects YAML
The YAML that backs the nested AppProperties — showing how each inner class maps to a nested prefix, and how environment variables address nested keys.
yaml
app:
name: MyApplication
jwt:
secret: ${APP_JWT_SECRET}
expiration: 86400
issuer: https://myapp.com
mail:
from: noreply@myapp.com
reply-to: support@myapp.com
enabled: true
features:
payments: true
notifications: true
maintenance-mode: false
# Environment variable equivalents (relaxed binding):
# APP_JWT_SECRET → app.jwt.secret
# APP_JWT_EXPIRATION → app.jwt.expiration
# APP_MAIL_FROM → app.mail.from
# APP_FEATURES_PAYMENTS → app.features.payments
# APP_FEATURES_MAINTENANCE_MODE → app.features.maintenance-modeLists and Maps
@ConfigurationProperties binds YAML lists to Java List fields and YAML maps to Map fields with no extra configuration. This is one of the clearest advantages over @Value, which requires SpEL workarounds for collections.
Java
@ConfigurationProperties(prefix = "app")
public class AppProperties {
// List<String> — bound to app.allowed-origins:
private List<String> allowedOrigins = new ArrayList<>();
// List of objects — bound to app.notification-channels:
private List<NotificationChannel> notificationChannels = new ArrayList<>();
// Map<String, String> — bound to app.external-services:
private Map<String, String> externalServices = new HashMap<>();
// Map<String, ServiceConfig> — bound to app.services:
private Map<String, ServiceConfig> services = new HashMap<>();
public static class NotificationChannel {
private String type;
private boolean enabled;
private int priority;
// getters + setters
}
public static class ServiceConfig {
private String url;
private int timeout = 5000;
private int retryCount = 3;
// getters + setters
}
// getters + setters for all fields
}
# application.yml:
app:
allowed-origins:
- https://myapp.com
- https://admin.myapp.com
- http://localhost:3000
notification-channels:
- type: email
enabled: true
priority: 1
- type: sms
enabled: true
priority: 2
- type: push
enabled: false
priority: 3
external-services:
inventory: https://inventory-service:8082
pricing: https://pricing-service:8083
shipping: https://shipping-service:8084
services:
inventory:
url: https://inventory-service:8082
timeout: 3000
retry-count: 2
pricing:
url: https://pricing-service:8083
timeout: 2000
retry-count: 1Validation with @Validated
Add @Validated to a @ConfigurationProperties class to enable Bean Validation (JSR-380) on bound properties. Spring Boot validates all @Validated @ConfigurationProperties beans at startup — misconfiguration causes a descriptive failure immediately, not a NullPointerException at 3am.
Java
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
@NotBlank(message = "app.name must not be blank")
private String name;
@Min(value = 1, message = "app.max-retries must be at least 1")
@Max(value = 10, message = "app.max-retries must not exceed 10")
private int maxRetries = 3;
@Valid // cascade validation into the nested object
@NotNull
private final Jwt jwt = new Jwt();
@Valid
@NotNull
private final Mail mail = new Mail();
public static class Jwt {
@NotBlank(message = "app.jwt.secret is required")
@Size(min = 32, message = "app.jwt.secret must be at least 32 characters")
private String secret;
@Positive(message = "app.jwt.expiration must be positive")
private long expiration = 86400;
@NotBlank
@Pattern(regexp = "https?://.+", message = "app.jwt.issuer must be a valid URL")
private String issuer;
// getters + setters
}
public static class Mail {
@NotBlank
@Email(message = "app.mail.from must be a valid email address")
private String from;
private boolean enabled = true;
// getters + setters
}
// getters + setters
}
// On misconfiguration, Spring Boot fails at startup with a clear message:
// ***************************
// APPLICATION FAILED TO START
// ***************************
// Description:
// Binding to target AppProperties failed:
//
// Property: app.jwt.secret
// Value: ""
// Reason: app.jwt.secret must be at least 32 characters
//
// Property: app.jwt.issuer
// Value: "not-a-url"
// Reason: app.jwt.issuer must be a valid URLJava Records (Spring Boot 2.6+)
Java records work as @ConfigurationProperties classes from Spring Boot 2.6 onwards. Records are immutable by definition — Spring Boot uses the canonical constructor for binding rather than setters. This is the most concise and modern approach.
Java
// Flat record — immutable, no boilerplate:
@ConfigurationProperties(prefix = "app")
public record AppProperties(
@NotBlank String name,
@DefaultValue("1.0.0") String version,
@DefaultValue("false") boolean debugMode,
@DefaultValue("3") int maxRetries,
JwtProperties jwt,
MailProperties mail
) { }
// Nested records:
public record JwtProperties(
@NotBlank String secret,
@DefaultValue("86400") long expiration,
@NotBlank String issuer
) { }
public record MailProperties(
@Email @NotBlank String from,
@DefaultValue("true") boolean enabled
) { }
// application.yml is identical — the binding rules don't change for records.
// Inject exactly the same way:
@Service
@RequiredArgsConstructor
public class TokenService {
private final AppProperties appProperties;
public long getExpirationSeconds() {
return appProperties.jwt().expiration(); // record accessor, not getExpiration()
}
}
// NOTE: @DefaultValue provides fallback values for record components.
// Without it, a missing property throws a binding exception at startup.IDE Auto-Completion with the Annotation Processor
Adding the spring-boot-configuration-processor dependency generates a metadata file at compile time. This file powers IDE auto-completion, type hints, and documentation tooltips for your custom property keys in application.yml.
XML
<!-- pom.xml — annotation processor (optional=true keeps it off the runtime classpath): -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
// build.gradle (Kotlin DSL):
dependencies {
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}
// After adding and rebuilding, your custom properties get:
// - Auto-completion in application.yml and application.properties
// - Type information shown in the IDE (int, boolean, List<String>)
// - Javadoc on your class/field shown as the IDE tooltip description
// - Deprecation warnings if you annotate with @DeprecatedConfigurationProperty
// Add Javadoc to drive the IDE tooltip:
@ConfigurationProperties(prefix = "app")
public class AppProperties {
/**
* Display name of the application, shown in the Actuator /info endpoint.
*/
private String name;
/**
* Maximum number of retry attempts for outbound HTTP calls.
* Must be between 1 and 10.
*/
private int maxRetries = 3;
}
// Manual metadata file for extra hints (optional):
// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
"properties": [
{
"name": "app.name",
"type": "java.lang.String",
"description": "Display name of the application."
},
{
"name": "app.max-retries",
"type": "java.lang.Integer",
"description": "Maximum retry attempts for outbound HTTP calls.",
"defaultValue": 3
}
]
}Unit Testing @ConfigurationProperties
Because @ConfigurationProperties classes are plain Java objects, they can be unit tested in complete isolation — no Spring context required. Use @ConfigurationPropertiesTest for integration-style tests that exercise the full binding and validation pipeline.
Java
// Unit test — no Spring context, instantiate and set directly:
class AppPropertiesTest {
@Test
void defaultValues_areApplied() {
AppProperties props = new AppProperties();
props.setName("TestApp");
assertThat(props.getMaxRetries()).isEqualTo(3);
assertThat(props.getVersion()).isEqualTo("1.0.0");
assertThat(props.isDebugMode()).isFalse();
}
@Test
void jwtExpiration_defaultIsOneDay() {
AppProperties.Jwt jwt = new AppProperties.Jwt();
assertThat(jwt.getExpiration()).isEqualTo(86400L);
}
}
// Integration test — full binding + validation pipeline:
@SpringBootTest(properties = {
"app.name=TestApp",
"app.jwt.secret=test-secret-that-is-at-least-32-chars!!",
"app.jwt.issuer=https://test.myapp.com",
"app.mail.from=test@myapp.com"
})
class AppPropertiesIntegrationTest {
@Autowired
private AppProperties appProperties;
@Test
void bindsCorrectly() {
assertThat(appProperties.getName()).isEqualTo("TestApp");
assertThat(appProperties.getJwt().getIssuer()).isEqualTo("https://test.myapp.com");
assertThat(appProperties.getMail().getFrom()).isEqualTo("test@myapp.com");
}
}
// Test that validation fires for a missing required property:
@SpringBootTest(properties = {
"app.name=TestApp"
// app.jwt.secret intentionally omitted
})
class AppPropertiesValidationTest {
@Test
void missingJwtSecret_failsStartup() {
// context load fails — Spring Boot throws BindValidationException
assertThatThrownBy(() ->
new SpringApplicationBuilder(MyApplication.class)
.properties("app.name=Test")
.run()
).hasCauseInstanceOf(BindValidationException.class);
}
}