Skip to main content
spring internals

Constructor Binding, Validation, and Immutable Configuration

8 min read Chapter 21 of 78

Constructor Binding, Validation, and Immutable Configuration

Configuration objects should be immutable. Once Spring Boot binds values from your YAML, environment variables, and system properties, those values should not change for the lifetime of the application. Setter-based binding gives you mutable objects. Constructor binding gives you immutable ones. In Spring Boot 3, constructor binding is the default for any @ConfigurationProperties class with a single parameterized constructor.

Constructor Binding Auto-Detection

Spring Boot 3 changed how it selects the binding strategy. The rules:

  1. If the class has a single constructor with parameters, use constructor binding.
  2. If the class has only a no-arg constructor, use JavaBean (setter) binding.
  3. If the class has multiple constructors, use the one annotated with @ConstructorBinding. If none is annotated, use the no-arg constructor (JavaBean binding).

The @ConstructorBinding annotation moved from org.springframework.boot.context.properties.ConstructorBinding (Boot 2) to org.springframework.boot.context.properties.bind.ConstructorBinding (Boot 3). In Boot 3, you only need it for disambiguation. For single-constructor classes and records, it is unnecessary.

Record-Based @ConfigurationProperties

Java records are the natural fit for constructor-bound configuration. A record has a single canonical constructor, all fields are final, and getters are generated.

Database Configuration for the SaaS Backend

@Validated
@ConfigurationProperties(prefix = "app.database")
public record DatabaseProperties(
    @NotBlank
    String url,

    @NotBlank
    String username,

    @NotBlank
    String password,

    @NotBlank
    @DefaultValue("saas")
    String schema,

    @Min(1)
    @Max(200)
    @DefaultValue("10")
    int maxPoolSize,

    @NotNull
    @DefaultValue("30s")
    Duration connectionTimeout,

    @NotNull
    @DefaultValue("10m")
    Duration idleTimeout
) {}

The YAML:

app:
  database:
    url: jdbc:postgresql://db.internal:5432/saas
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    schema: production
    max-pool-size: 50
    connection-timeout: 15s
    idle-timeout: 5m

Spring Boot binds each YAML property to the corresponding constructor parameter by matching canonical names. max-pool-size matches maxPoolSize. connection-timeout matches connectionTimeout. The Duration type conversion handles the 15s and 5m strings.

JWT Configuration

@Validated
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(
    @NotBlank
    String secret,

    @NotNull
    @DefaultValue("1h")
    Duration accessTokenExpiration,

    @NotNull
    @DefaultValue("7d")
    Duration refreshTokenExpiration,

    @NotBlank
    @DefaultValue("earezki-saas")
    String issuer,

    @Pattern(regexp = "HS256|HS384|HS512")
    @DefaultValue("HS256")
    String algorithm
) {}

Both configuration classes are registered through scanning:

@SpringBootApplication
@ConfigurationPropertiesScan("com.saas.config")
public class SaasApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaasApplication.class, args);
    }
}

And injected where needed:

@Service
public class JwtTokenService {

    private final JwtProperties jwtProperties;

    public JwtTokenService(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
    }

    public String generateAccessToken(String tenantId, String userId) {
        Instant now = Instant.now();
        return Jwts.builder()
            .issuer(jwtProperties.issuer())
            .subject(userId)
            .claim("tenant", tenantId)
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(jwtProperties.accessTokenExpiration())))
            .signWith(Keys.hmacShaKeyFor(jwtProperties.secret().getBytes()),
                      Jwts.SIG.valueOf(jwtProperties.algorithm()))
            .compact();
    }
}

@DefaultValue for Optional Properties

@DefaultValue provides a fallback when no property matches a constructor parameter. It is only available with constructor binding (not setter binding).

@ConfigurationProperties(prefix = "app.rate-limit")
public record RateLimitProperties(
    @DefaultValue("100")
    int requestsPerMinute,

    @DefaultValue("1000")
    int requestsPerHour,

    @DefaultValue("true")
    boolean enabled,

    @DefaultValue
    List<String> excludedPaths
) {}

When @DefaultValue has no value attribute on a collection type (@DefaultValue List<String>), the default is an empty collection. Without the annotation, the collection would be null.

The @DefaultValue annotation works with type conversion. @DefaultValue("30s") on a Duration parameter produces Duration.ofSeconds(30). @DefaultValue("true") on a boolean parameter produces true.

Nested Configuration with @DefaultValue

For nested types, @DefaultValue on a record parameter tells the Binder to create the nested object using its own defaults even when no properties match:

@ConfigurationProperties(prefix = "app.tenant-defaults")
public record TenantDefaultsProperties(
    @DefaultValue
    DatabaseDefaults database,

    @DefaultValue
    CacheDefaults cache
) {
    public record DatabaseDefaults(
        @DefaultValue("10") int maxPoolSize,
        @DefaultValue("30s") Duration connectionTimeout
    ) {}

    public record CacheDefaults(
        @DefaultValue("5m") Duration ttl,
        @DefaultValue("1000") int maxEntries
    ) {}
}

If the YAML contains no app.tenant-defaults section at all, the Binder still creates TenantDefaultsProperties with nested DatabaseDefaults(10, Duration.ofSeconds(30)) and CacheDefaults(Duration.ofMinutes(5), 1000). Without @DefaultValue on the nested parameters, they would be null.

@Validated and JSR-303

Adding @Validated to a @ConfigurationProperties class activates Bean Validation during binding. Spring Boot checks for a Validator in the application context (typically Hibernate Validator, pulled in by spring-boot-starter-validation).

Constraint Annotations

The standard JSR-303 annotations:

@Validated
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(
    @NotBlank
    @Email
    String fromAddress,

    @NotBlank
    String smtpHost,

    @Min(1)
    @Max(65535)
    @DefaultValue("587")
    int smtpPort,

    @NotNull
    @DefaultValue("STARTTLS")
    @Pattern(regexp = "NONE|STARTTLS|SSL")
    String encryption,

    @Min(1)
    @Max(60)
    @DefaultValue("10")
    int connectionTimeoutSeconds
) {}

Custom Constraints

For domain-specific validation, create a custom constraint:

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidCronExpressionValidator.class)
public @interface ValidCronExpression {
    String message() default "Invalid cron expression";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ValidCronExpressionValidator
    implements ConstraintValidator<ValidCronExpression, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true; // @NotNull handles null check
        return CronExpression.isValidExpression(value);
    }
}

Use it in configuration:

@Validated
@ConfigurationProperties(prefix = "app.scheduler")
public record SchedulerProperties(
    @ValidCronExpression
    @DefaultValue("0 0 * * * *")
    String newsAggregationCron,

    @ValidCronExpression
    @DefaultValue("0 0 6 * * MON-FRI")
    String reportGenerationCron
) {}

BindValidationException and Startup Failure

When validation fails, ConfigurationPropertiesBinder wraps the violations in a BindValidationException. Spring Boot catches this and prints a structured error report.

Given this configuration with errors:

app:
  database:
    url: ""
    username: ""
    password: secret
    max-pool-size: -5
    connection-timeout: 15s

The startup fails with:

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

Description:

Binding to target org.springframework.boot.context.properties.bind.BindException:
  Failed to bind properties under 'app.database' to com.saas.config.DatabaseProperties:

    Property: app.database.url
    Value: ""
    Reason: must not be blank

    Property: app.database.username
    Value: ""
    Reason: must not be blank

    Property: app.database.max-pool-size
    Value: -5
    Reason: must be greater than or equal to 1


Action:

Update your application's configuration

Every violated constraint is listed with the property path, current value, and reason. This report appears before any bean is wired, before any database connection is attempted, before any HTTP server starts. The application never reaches a running state with invalid configuration.

Nested Validation

For nested objects, add @Valid to trigger cascading validation:

@Validated
@ConfigurationProperties(prefix = "app")
public record AppProperties(
    @Valid
    @NotNull
    DatabaseProperties database,

    @Valid
    @NotNull
    JwtProperties jwt
) {}

Without @Valid on the nested fields, the Binder binds the nested objects but does not validate their constraints. Validation only cascades through fields explicitly annotated with @Valid.

The Failure Mode

A mutable JavaBean configuration with setters that allows partial initialization:

// BROKEN: mutable, no validation, allows partial initialization
@ConfigurationProperties(prefix = "app.database")
public class DatabaseProperties {
    private String url;
    private String username;
    private String password;
    private int maxPoolSize;
    private Duration connectionTimeout;

    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }

    public int getMaxPoolSize() { return maxPoolSize; }
    public void setMaxPoolSize(int maxPoolSize) { this.maxPoolSize = maxPoolSize; }

    public Duration getConnectionTimeout() { return connectionTimeout; }
    public void setConnectionTimeout(Duration t) { this.connectionTimeout = t; }
}

What goes wrong:

  1. Partial initialization is invisible. If YAML is missing the password property, password is null. The application starts. The NullPointerException surfaces when a tenant tries to authenticate, potentially minutes or hours into production.

  2. Setters allow mutation. Any code with a reference to DatabaseProperties can call setMaxPoolSize(0). Configuration should be a contract, not a suggestion.

  3. Zero is valid for int. If max-pool-size is absent, maxPoolSize is 0. HikariCP creates a pool with zero connections. Requests queue indefinitely with no error message pointing to configuration.

  4. No fail-fast. Every error in this design surfaces at runtime, not at startup. Production debugging replaces upfront validation.

The Correct Pattern

An immutable record with validation that fails fast at startup:

// CORRECT: immutable, validated, fails fast
@Validated
@ConfigurationProperties(prefix = "app.database")
public record DatabaseProperties(
    @NotBlank
    String url,

    @NotBlank
    String username,

    @NotBlank
    String password,

    @NotBlank
    @DefaultValue("public")
    String schema,

    @Min(1)
    @Max(200)
    @DefaultValue("10")
    int maxPoolSize,

    @NotNull
    @DefaultValue("30s")
    Duration connectionTimeout,

    @NotNull
    @DefaultValue("10m")
    Duration idleTimeout
) {}

What this guarantees:

  1. Immutability. Record fields are final. No setters exist. The configuration is frozen after construction.
  2. Complete initialization. Every required field is checked. @NotBlank on url, username, and password ensures they are present and non-empty.
  3. Range validation. @Min(1) @Max(200) on maxPoolSize rejects 0 and 500 with equal precision.
  4. Fail-fast. A missing password in YAML produces a BindValidationException at startup. The application never starts in an invalid state.
  5. Safe defaults. @DefaultValue("10") on maxPoolSize provides a sensible fallback that is visible in the source code, not buried in a properties file.
  6. Type safety. Duration connectionTimeout with @DefaultValue("30s") handles human-readable duration strings. No manual parsing. No int timeoutMs with undocumented units.

The combination of Java records, constructor binding, and Bean Validation produces configuration classes that are correct by construction. Invalid state is not representable at runtime because it cannot survive startup.