How do you add custom, cross-field validation to bound config, and how do conversion and validation order interact when designing a config surface?
answer
- bean named configurationPropertiesValidator (static)
- or class-level custom JSR-380 constraint
- binder: resolve -> convert -> validate
- validator sees typed values not strings
- boot fail-fast vs Actuator runtime checks
basics
~20 sRegister a Spring Validator bean named exactly configurationPropertiesValidator (often static) to enforce cross-field rules, or write a class-level custom JSR-380 constraint. The binder converts strings to target types first, then validation runs on the converted object.
solid answer
~40 sBeyond per-field JSR-380 constraints, cross-field invariants (min <= max, mutually-exclusive options) need custom validation. Two idiomatic options: a class-level custom JSR-380 constraint with its own ConstraintValidator, or a Spring org.springframework.validation.Validator exposed as a bean named exactly configurationPropertiesValidator — Boot picks it up and applies it to @ConfigurationProperties beans during binding. That bean is usually declared static so it is instantiated early, before the config beans it validates, avoiding ordering issues. Crucially, the binder performs type conversion first (String to Duration/DataSize/enum/URL, via ApplicationConversionService), then runs validation on the already-converted object — so your validator sees real Duration/DataSize values, not raw strings. Design-wise, decide which invariants belong at boot (fail-fast: required creds, ranges, coherent pairs) versus runtime health checks, keep the config surface typed and cohesive, and standardize units so operators aren't guessing.
code
java · 32 linesimport org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Configuration
class PoolValidationConfig {
// Special bean name: Boot applies it to @ConfigurationProperties beans.
// static -> built early, before the config beans it validates.
@Bean
static Validator configurationPropertiesValidator() {
return new Validator() {
@Override public boolean supports(Class<?> clazz) {
return PoolProperties.class.isAssignableFrom(clazz);
}
@Override public void validate(Object target, Errors errors) {
PoolProperties p = (PoolProperties) target;
if (p.getMin() > p.getMax()) {
errors.rejectValue("min", "pool.min.gt.max",
"pool.min must be <= pool.max");
}
}
};
}
}
// Test the fail-fast wiring:
// new ApplicationContextRunner()
// .withUserConfiguration(PoolValidationConfig.class, PoolProperties.class)
// .withPropertyValues("pool.min=10", "pool.max=5")
// .run(ctx -> assertThat(ctx).hasFailed());go deeper
Aware that custom cross-field validation of config is possible beyond @NotNull/@Min.
Can write a class-level custom constraint and knows conversion precedes validation.
Implements the configurationPropertiesValidator bean correctly (static, supports/validate) and reasons about the resolve→convert→validate pipeline.
Decides which invariants are boot-time fail-fast vs runtime, standardizes the typed config surface + units, and enforces it with ApplicationContextRunner tests in CI.
## Why go beyond field constraints JSR-380 field annotations (`@NotNull`, `@Min`) validate one property in isolation. Real config often has **relationships**: `pool.min <= pool.max`, `retry.enabled` requires `retry.attempts`, exactly one of two URLs must be set. These need **custom or cross-field** validation. ## Option A — the `configurationPropertiesValidator` bean Boot has a special convention: a bean of type `org.springframework.validation.Validator` **named exactly `configurationPropertiesValidator`** is used to validate `@ConfigurationProperties` beans. ```java @Bean public static Validator configurationPropertiesValidator() { return new PoolPropertiesValidator(); } ``` - **Why `static`?** Config beans are created very early; a static `@Bean` factory method lets the validator be built without fully initializing its `@Configuration` class, avoiding "bean requested before the validator exists" ordering problems. - The `Validator.supports(Class)` narrows which beans it applies to; `validate(Object, Errors)` implements the cross-field logic and calls `errors.rejectValue(...)`. - **Caveat**: this single bean name effectively provides *one* app-wide config validator; for many independent rules, prefer class-level constraints (Option B). ## Option B — a class-level custom JSR-380 constraint Define `@interface ValidPoolRange` (class-level, `@Target(TYPE)`) backed by a `ConstraintValidator<ValidPoolRange, PoolProperties>` that compares fields. This composes naturally with `@Validated` and scales to many rules, one per constraint, and is reusable across classes. ## Conversion-then-validation ordering (the deep point) The `@ConfigurationProperties` **binder** runs in this order per property: 1. **Resolve** the raw value from the `Environment`/property sources (with placeholder/relaxed-binding handling). 2. **Convert** it to the target type using Boot's `ApplicationConversionService` (String→`Duration`, String→`DataSize`, String→enum, String→`URL`, delimited String→`List`, etc.). 3. **Validate** the resulting bound object with JSR-380 + any custom validator. Consequences: - Your validator receives **typed** values (`Duration`, `DataSize`, enums), so you compare `Duration`s directly, not strings. - A **conversion failure** (e.g. `"abc"` for an `int`, or `"10 megabytes"` for `DataSize`) is a **bind error at stage 2**, distinct from a JSR-380 violation at stage 3 — but both abort startup. Know the difference when reading stack traces. - To bound a `Duration`, core JSR-380 has no operator; use **Hibernate Validator's** `@DurationMin`/`@DurationMax`, or a custom check. ## Design guidance (principal lens) - **Fail-fast at boot for structural invariants**: required secrets, coherent ranges, mutually-exclusive options — a bad deploy should die loudly with a legible report, not limp along. - **Runtime health/readiness** (Actuator) is for *dynamic* concerns (downstream reachability), not static config shape. - **Type the surface**: use `Duration`/`DataSize`/enums/`URI` instead of `long`/`String` so conversion + validation do the heavy lifting and config self-documents. - **Standardize units** and set explicit `@DurationUnit`/`@DataSizeUnit` defaults so unit-less numbers aren't ambiguous. - **Test it**: an `ApplicationContextRunner` test that feeds bad properties and asserts the context fails is the cheapest guard against regressions in validation wiring.
- Why is the configurationPropertiesValidator @Bean method usually declared static?Config-properties beans are created very early in context startup. A static factory method lets the validator be created without fully initializing its enclosing @Configuration class, avoiding bean-ordering problems where a config bean would be requested before its validator exists.
- In what order do conversion and validation run, and why does it matter?The binder converts the raw string to the target type (Duration/DataSize/enum) first, then validates the converted object. So a custom validator compares real typed values, and a malformed string fails as a conversion/bind error separate from a constraint violation.
- Where does @DurationMin come from and why not @Min for a Duration?@DurationMin/@DurationMax are Hibernate Validator extensions for java.time.Duration; core JSR-380's @Min/@Max target numeric types, not Duration, so they can't bound a Duration.
saying these in an interview costs you the question
- Naming the validator bean anything other than configurationPropertiesValidator and expecting Boot to use it
- Thinking the validator receives raw strings rather than converted Duration/DataSize values
- Putting every invariant at boot-time when some belong in runtime health checks
- Using @Min to bound a Duration