Spring BootCustom Configuration Properties
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=5

Nested 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-mode

Lists 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: 1

Validation 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 URL

Java 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);
    }
}