Type-Safe Configuration with @ConfigurationProperties

Introduction

This tutorial explains @ConfigurationProperties, Spring Boot's way of binding a whole group of related settings to one typed Java object. We will start with the problems of scattering many @Value annotations through an application. We will then bind a group of shop settings - strings, numbers, a nested object, a list, a map, a duration and a data size - to a Java record, and inject it into a service. Next we will give properties default values, validate them at startup with Bean Validation, and see the error Spring Boot prints when a value is wrong. We will then look at the configuration metadata that gives IDEs auto-completion for your own properties, bind properties to a class from a library, and finish by comparing @ConfigurationProperties with @Value.

(The configuration files and property sources used here are explained in application.properties vs application.ymlClick to read tutorial: application.properties vs application.yml in Spring Boot.)

The problem with many @Value annotations

In the tutorial on SpEL and @ValueClick to read tutorial: Spring Expression Language (SpEL) and @Value Explained, each setting was injected with its own @Value("${...}"). That is fine for one or two values, but for a real feature with ten settings it gets awkward -
  • The property names are strings repeated wherever a setting is used, so a typo is only found at runtime.
  • Settings that belong together - the shop's name, page size, currencies, contact details - are scattered across constructors and classes.
  • There is no single place that documents which settings a feature has, their types and their defaults.
  • Validating the values - "the page size must be between 1 and 100" - has to be done by hand.
@ConfigurationProperties solves all four by mapping a property prefix, such as shop, to a class whose fields are the settings.

Binding settings to a record

The configuration file holds the shop settings, including a duration and a data size written in Spring Boot's friendly formats -
shop.properties - the settings to bind
shop.name=JavaBrahman Books
shop.currencies=USD,EUR,INR
shop.contact.email=orders@example.com
shop.contact.phone=+44 20 7946 0000
shop.delivery-days.GB=2
shop.delivery-days.IN=5
shop.session-timeout=30m
shop.max-upload-size=10MB
The class that receives them is a record annotated with @ConfigurationProperties("shop"). Each component's name matches a property after the prefix, and nested settings become a nested record -
ShopProperties.java - the shop settings as a record
//ShopProperties.java
package com.javabrahman.boot.props;

import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.util.unit.DataSize;

/**
 * Settings of the online shop.
 *
 * @param name name of the shop, shown on every page
 * @param pageSize number of books listed per page
 * @param currencies currencies the shop accepts
 * @param contact contact details shown on the help page
 * @param deliveryDays expected delivery time in days, per country code
 * @param sessionTimeout how long an idle shopping session is kept
 * @param maxUploadSize largest file a customer may upload
 */
@ConfigurationProperties("shop")
public record ShopProperties(
    String name,
    @DefaultValue("20") int pageSize,
    List<String> currencies,
    Contact contact,
    Map<String, Integer> deliveryDays,
    Duration sessionTimeout,
    DataSize maxUploadSize) {

  /**
   * Contact details.
   *
   * @param email address for orders
   * @param phone phone number for orders
   */
  public record Contact(String email, String phone) {
  }
}
A service uses the settings through ordinary constructor injection, and the application switches on the scanning of @ConfigurationProperties classes -
Injecting ShopProperties into a service
//ShopPropertiesApp.java
package com.javabrahman.boot.props;

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

@Service
class StorefrontService {
  private final ShopProperties shop;

  StorefrontService(ShopProperties shop) {
    this.shop = shop;
  }

  String banner() {
    return shop.name() + " - " + shop.pageSize() + " books per page, prices in " + String.join("/", shop.currencies())
        + ", delivery to India in " + shop.deliveryDays().get("IN") + " days";
  }
}

@SpringBootApplication
@ConfigurationPropertiesScan   // finds @ConfigurationProperties classes in this package and below
public class ShopPropertiesApp {
  public static void main(String[] args) {
    try (var context = SpringApplication.run(ShopPropertiesApp.class, args)) {
      ShopProperties shop = context.getBean(ShopProperties.class);
      System.out.println(context.getBean(StorefrontService.class).banner());
      System.out.println("contact        = " + shop.contact());
      System.out.println("sessionTimeout = " + shop.sessionTimeout() + " (" + shop.sessionTimeout().toMinutes() + " minutes)");
      System.out.println("maxUploadSize  = " + shop.maxUploadSize() + " (" + shop.maxUploadSize().toKilobytes() + " KB)");
    }
  }
}
 OUTPUT of the above code
JavaBrahman Books - 20 books per page, prices in USD/EUR/INR, delivery to India in 5 days
contact        = Contact[email=orders@example.com, phone=+44 20 7946 0000]
sessionTimeout = PT30M (30 minutes)
maxUploadSize  = 10485760B (10240 KB)

Explanation of the code

  • @ConfigurationProperties("shop") binds every property under shop. to the record. Relaxed binding maps page-size to pageSize and delivery-days to deliveryDays.
  • Because a record has a single constructor, Spring Boot uses constructor binding: it converts each value and calls the constructor. The result is immutable - nothing can change the settings after startup.
  • shop.page-size is not in the file, so @DefaultValue("20") supplied it.
  • Spring Boot converted the text to rich types: USD,EUR,INR to a List, the delivery-days.* keys to a Map, 30m to a Duration of 30 minutes, and 10MB to a DataSize of 10 megabytes (10240 KB - Spring uses binary units). Durations also accept ms, s, h and d, and ISO-8601 text such as PT30M.
  • @ConfigurationPropertiesScan registers every @ConfigurationProperties class in its package and below as a bean. The alternative is to list classes explicitly with @EnableConfigurationProperties(ShopProperties.class).
  • StorefrontService depends on one typed object instead of many strings. In a unit test, it can be given a new ShopProperties(...) directly.

Validating settings at startup

Wrong settings are best caught when the application starts, not when a customer hits the broken feature. Annotating the properties class with @Validated and its components with Bean Validation constraints makes Spring Boot validate them during binding. It needs the spring-boot-starter-validation dependency, which brings Hibernate Validator -
MailProperties.java - settings with validation constraints
//MailProperties.java
package com.javabrahman.boot.validated;

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

@Validated
@ConfigurationProperties("mail")
public record MailProperties(
    @NotBlank String host,
    @Min(1) @Max(65535) int port,
    @Email String from) {
}
An application that binds MailProperties
//MailApp.java
package com.javabrahman.boot.validated;

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

@SpringBootApplication
@EnableConfigurationProperties(MailProperties.class)
public class MailApp {
  public static void main(String[] args) {
    try (var context = SpringApplication.run(MailApp.class, args)) {
      System.out.println("Started with " + context.getBean(MailProperties.class));
    } catch (Exception e) {
      System.out.println("Startup failed: " + e.getClass().getSimpleName());
    }
  }
}
First with valid settings on the command line, then with a port that is out of range and a malformed sender address -  OUTPUT with valid settings
Started with MailProperties[host=smtp.example.com, port=587, from=shop@example.com]
 OUTPUT with invalid settings
2026-11-06T13:45:44.725+05:30  WARN 29516 --- [           main] s.c.a.AnnotationConfigApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.boot.context.properties.ConfigurationPropertiesBindException: Error creating bean with name 'mail-com.javabrahman.boot.validated.MailProperties': Could not bind properties to 'MailProperties' : prefix=mail, ignoreInvalidFields=false, ignoreUnknownFields=true
2026-11-06T13:45:44.754+05:30  INFO 29516 --- [           main] .s.b.a.l.ConditionEvaluationReportLogger :

Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-11-06T13:45:44.801+05:30 ERROR 29516 --- [           main] o.s.b.d.LoggingFailureAnalysisReporter   :

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

Description:

Binding to target com.javabrahman.boot.validated.MailProperties failed:

    Property: mail.port
    Value: "70000"
    Origin: "mail.port" from property source "commandLineArgs"
    Reason: must be less than or equal to 65535

    Property: mail.from
    Value: "shop-at-example.com"
    Origin: "mail.from" from property source "commandLineArgs"
    Reason: must be a well-formed email address


Action:

Update your application's configuration

Startup failed: ConfigurationPropertiesBindException

Explanation of the output

  • With valid values the application started, and the record printed its components.
  • With invalid values, binding failed and the application did not start. Spring Boot's failure analyzer printed an APPLICATION FAILED TO START report that names each invalid property, the value given, where the value came from (here, the command line), and the constraint it broke - followed by an Action line saying what to do.
  • Before the report, Spring logged a WARN line saying that the context refresh was cancelled, and a hint about the condition evaluation report. The report itself is logged at ERROR level, which is why these lines carry timestamps. The example's own catch block then printed the final line.
  • Constraints on a nested record are checked too if the nested component is annotated with @Valid.

Configuration metadata and IDE auto-completion

When you type server. in an application.properties file, IntelliJ IDEA, VS Code and Eclipse suggest Spring Boot's properties with their types, defaults and descriptions. They read this from configuration metadata: a JSON file at META-INF/spring-configuration-metadata.json inside each jar. Your own @ConfigurationProperties classes get the same support from spring-boot-configuration-processor, an annotation processor that writes the file while the code compiles. Choosing Spring Configuration Processor on Spring Initializr adds it to the Maven compiler plugin's annotation processor path -
pom.xml - the configuration processor, as Spring Initializr configures it
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <executions>
    <execution>
      <id>default-compile</id>
      <phase>compile</phase>
      <goals>
        <goal>compile</goal>
      </goals>
      <configuration>
        <annotationProcessorPaths>
          <path>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
          </path>
        </annotationProcessorPaths>
      </configuration>
    </execution>
  </executions>
</plugin>
The processor path matters on newer JDKs: since JDK 23, javac no longer runs annotation processors just because they are on the classpath, so a processor added only as an ordinary dependency would silently do nothing. (With plain javac, -proc:full switches classpath processors back on, which is how the examples in this tutorial were compiled.) The compiler wrote this file next to the classes -  OUTPUT - the generated META-INF/spring-configuration-metadata.json
{
  "groups": [
    {
      "name": "mail",
      "type": "com.javabrahman.boot.validated.MailProperties",
      "sourceType": "com.javabrahman.boot.validated.MailProperties"
    },
    {
      "name": "shop",
      "type": "com.javabrahman.boot.props.ShopProperties",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    },
    {
      "name": "shop.contact",
      "type": "com.javabrahman.boot.props.ShopProperties$Contact",
      "sourceType": "com.javabrahman.boot.props.ShopProperties",
      "sourceMethod": "contact()"
    }
  ],
  "properties": [
    {
      "name": "mail.from",
      "type": "java.lang.String",
      "sourceType": "com.javabrahman.boot.validated.MailProperties"
    },
    {
      "name": "mail.host",
      "type": "java.lang.String",
      "sourceType": "com.javabrahman.boot.validated.MailProperties"
    },
    {
      "name": "mail.port",
      "type": "java.lang.Integer",
      "sourceType": "com.javabrahman.boot.validated.MailProperties",
      "defaultValue": 0
    },
    {
      "name": "shop.contact.email",
      "type": "java.lang.String",
      "description": "address for orders",
      "sourceType": "com.javabrahman.boot.props.ShopProperties$Contact"
    },
    {
      "name": "shop.contact.phone",
      "type": "java.lang.String",
      "description": "phone number for orders",
      "sourceType": "com.javabrahman.boot.props.ShopProperties$Contact"
    },
    {
      "name": "shop.currencies",
      "type": "java.util.List<java.lang.String>",
      "description": "currencies the shop accepts",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    },
    {
      "name": "shop.delivery-days",
      "type": "java.util.Map<java.lang.String,java.lang.Integer>",
      "description": "expected delivery time in days, per country code",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    },
    {
      "name": "shop.max-upload-size",
      "type": "org.springframework.util.unit.DataSize",
      "description": "largest file a customer may upload",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    },
    {
      "name": "shop.name",
      "type": "java.lang.String",
      "description": "name of the shop, shown on every page",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    },
    {
      "name": "shop.page-size",
      "type": "java.lang.Integer",
      "description": "number of books listed per page",
      "sourceType": "com.javabrahman.boot.props.ShopProperties",
      "defaultValue": 20
    },
    {
      "name": "shop.session-timeout",
      "type": "java.time.Duration",
      "description": "how long an idle shopping session is kept",
      "sourceType": "com.javabrahman.boot.props.ShopProperties"
    }
  ],
  "hints": [],
  "ignored": {
    "properties": []
  }
}

Explanation of the output

  • The groups section lists each properties class and its prefix - mail, shop, and shop.contact for the nested record.
  • The properties section lists every property with its kebab-case name, its Java type, and a description taken from the @param Javadoc of the record. The defaultValue of shop.page-size came from @DefaultValue("20"); mail.port shows 0 because a primitive int defaults to zero. MailProperties has no Javadoc, so its properties have no descriptions - a reason to document properties classes.
  • An IDE that finds this file in your project offers auto-completion and shows the descriptions as documentation, and it warns about unknown property names - which catches typos before the application even runs.

Binding properties to a library class

@ConfigurationProperties can also go on a @Bean method. Spring Boot then binds the prefix to the object the method returns, by calling its setters - a way to configure a class from a library that has no Spring annotations of its own. Spring Boot's own spring.datasource.* properties work this way for the connection pool -
Binding a prefix to a third-party object returned by a @Bean method
@Bean
@ConfigurationProperties("reports.http")   // reports.http.connect-timeout=2s calls setConnectTimeout(...)
ReportClientSettings reportClientSettings() {
  return new ReportClientSettings();       // a JavaBean from a library, with getters and setters
}
The same setter-based binding also works for your own classes written as JavaBeans - a class with a no-argument constructor and setters - instead of records. Records (or classes with a single constructor) are preferred, because the bound object is immutable and cannot be half-filled.

@ConfigurationProperties vs @Value

  • Grouping and type safety: @ConfigurationProperties binds a whole prefix to one typed object; @Value injects one value at a time by a string key.
  • Relaxed binding: fully supported by @ConfigurationProperties (page-size, pageSize, SHOP_PAGESIZE); @Value should always use the canonical kebab-case name.
  • Validation: built in with @Validated; with @Value, it is manual.
  • Metadata and IDE support: generated for @ConfigurationProperties; not for @Value.
  • SpEL expressions: supported only by @Value (#{...}).
  • When to use which: use @ConfigurationProperties for any group of related settings - which is most configuration in a real application - and keep @Value for a single, isolated value or when you need an expression.

Summary

In this tutorial we first saw the problems of using many @Value annotations. We then bound a group of shop settings to an immutable record with @ConfigurationProperties, with a nested record, a list, a map, a Duration written as 30m, a DataSize written as 10MB and a default value, and injected it into a service. Next we validated settings with @Validated and saw the startup failure report for invalid values, which even names where each value came from. We then generated configuration metadata with the configuration processor for IDE auto-completion, bound properties to a library object through a @Bean method, and lastly compared @ConfigurationProperties with @Value. The next tutorial gives the same application different settings per environment: Spring Boot profilesClick to read tutorial: Spring Boot Profiles and Environment-Specific Configuration.