Constructor Binding, Validation, and Immutable Configuration
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:
- If the class has a single constructor with parameters, use constructor binding.
- If the class has only a no-arg constructor, use JavaBean (setter) binding.
- 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:
-
Partial initialization is invisible. If YAML is missing the
passwordproperty,passwordis null. The application starts. TheNullPointerExceptionsurfaces when a tenant tries to authenticate, potentially minutes or hours into production. -
Setters allow mutation. Any code with a reference to
DatabasePropertiescan callsetMaxPoolSize(0). Configuration should be a contract, not a suggestion. -
Zero is valid for int. If
max-pool-sizeis absent,maxPoolSizeis 0. HikariCP creates a pool with zero connections. Requests queue indefinitely with no error message pointing to configuration. -
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:
- Immutability. Record fields are final. No setters exist. The configuration is frozen after construction.
- Complete initialization. Every required field is checked.
@NotBlankonurl,username, andpasswordensures they are present and non-empty. - Range validation.
@Min(1) @Max(200)onmaxPoolSizerejects 0 and 500 with equal precision. - Fail-fast. A missing
passwordin YAML produces aBindValidationExceptionat startup. The application never starts in an invalid state. - Safe defaults.
@DefaultValue("10")onmaxPoolSizeprovides a sensible fallback that is visible in the source code, not buried in a properties file. - Type safety.
Duration connectionTimeoutwith@DefaultValue("30s")handles human-readable duration strings. No manual parsing. Noint timeoutMswith 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.