This setup is common. You have a @ConfigurationProperties class with @Validated on it. Inside it is a nested object, and one field on that inner object has a constraint. The goal is simple: if someone forgets to set username, the application should refuse to start. It starts anyway. The log shows a normal startup, the context comes up, and username is null. So the service did start, technically.
The missing piece is one annotation. The Spring Boot externalized configuration docs say so directly:
> "To cascade validation to nested properties the associated field must be annotated with @Valid."
If @Valid is on the nested field, the failure happens inside SpringApplication.run, before any of your code reads the value. Without it, the inner constraint is never checked. Below, both cases are run with their exit codes and the exact text Boot prints. There's also a third case that looks like a fix and doesn't fix anything.
Versions
The experiment ran on Spring Boot 4.1.1, the newest GA release as of 1 October 2026 (4.2 is still at milestone stage). The Boot BOM brings in Spring Framework 7.0.9, Hibernate Validator 9.1.3.Final, and jakarta.validation-api 3.1.1. The runtime was Temurin 25.0.4 with Maven 3.9.12. The system requirements page says Boot 4.1.1 needs at least Java 17 and supports versions up to and including Java 26, so JDK 25 is within range.
If your question is which source supplied a value, see External configuration in Spring Boot 4.1.
The fixture
The fixture is a small Maven project with no web server. The POM has only the base starter and the validation starter:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>nested-valid-config</artifactId>
<version>0.0.1</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The base application.yml only turns off the web server:
spring:
main:
web-application-type: none
The application registers the properties, starts the context, prints username, and exits. Here, the "first use" of the value is the println right after run:
package academy.devdojo.nestedvalid;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class NestedValidApp {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(NestedValidApp.class, args);
AppProperties props = ctx.getBean(AppProperties.class);
String username = props.getNested() == null ? "<nested-null>" : String.valueOf(props.getNested().getUsername());
System.out.println("STARTED nested.username=" + username);
SpringApplication.exit(ctx);
}
}
The properties class has the same shape as the official validation example. It has @Validated on the type and a nested object created in the field initializer, with only a getter. This is the version with @Valid, variant B:
package academy.devdojo.nestedvalid;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties("app")
@Validated
public class AppProperties {
@Valid
private final Nested nested = new Nested();
public Nested getNested() {
return nested;
}
public static class Nested {
@NotBlank
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
}
Variant A is the same class without that one line:
private final Nested nested = new Nested();
Three YAML profiles cover the cases that matter. application-omitted.yml doesn't have the key at all:
# omitted nested key on purpose
application-invalid.yml has the key, but its value is empty:
app:
nested:
username: ""
application-valid.yml has a real value:
app:
nested:
username: "devdojo"
The invalid profile is the tricky one. The key is there and the value is quoted, so username: looks configured during a PR review even though it holds nothing. The fixture therefore uses @NotBlank rather than @NotNull, because "" passes @NotNull without complaint.
Running it: without @Valid, with @Valid, and with @Validated in the wrong place
These commands were run for each variant:
mvn -q -B -DskipTests package
java -jar target/nested-valid-config-0.0.1.jar --spring.profiles.active=omitted
java -jar target/nested-valid-config-0.0.1.jar --spring.profiles.active=invalid
java -jar target/nested-valid-config-0.0.1.jar --spring.profiles.active=valid
| Profile | A: no @Valid | B: @Valid on the field | C: @Validated on Nested, no @Valid |
|---|---|---|---|
| omitted | exit 0, STARTED nested.username=null | exit 1, must not be blank | exit 0, STARTED nested.username=null |
| invalid | exit 0, STARTED nested.username= | exit 1, must not be blank | exit 0, STARTED nested.username= |
| valid | exit 0, STARTED nested.username=devdojo | exit 0, STARTED nested.username=devdojo | exit 0, STARTED nested.username=devdojo |
Variant A: green across the board
Without @Valid, all three profiles exit 0. With omitted, the process prints STARTED nested.username=null. With invalid, it prints STARTED nested.username= and nothing after the equals sign. The @NotBlank is in the code, @Validated is on the type, and the validation starter is on the classpath. The inner object is still never validated.
@Validated makes Boot validate AppProperties during binding, and that's all it does. AppProperties has no constraints on its own fields. Without @Valid, nothing tells the validator to go down into Nested, which is where the @NotBlank is. In Jakarta Bean Validation, only @Valid does that: "@Valid is used to express validation traversal of an association". The @Validated side is covered in the validation section of the Boot docs.
Variant B: startup fails
With @Valid on the field, omitted and invalid both exit 1 during SpringApplication.run, and neither prints STARTED. The exception in the chain is org.springframework.boot.context.properties.ConfigurationPropertiesBindException, and Boot's FailureAnalyzer summarizes it. For the omitted profile:
Binding to target academy.devdojo.nestedvalid.AppProperties failed: Property: app.nested.username Value: "null" Reason: must not be blank
For the invalid profile:
Property: app.nested.username Value: "" Reason: must not be blank
The message gives the full property name (app.nested.username), the value it received, and the constraint that failed. The valid profile still exits 0 with STARTED nested.username=devdojo, so the annotation stops only the bad configurations.
Why does omitted fail when the key isn't in the YAML at all? Because Nested is already created in the field initializer. The object exists and its username is null, so validation cascades into it and @NotBlank rejects the value.
Variant C: @Validated on the inner class
Once people notice validation isn't reaching the inner object, the usual fix is to add @Validated to the nested class too:
private final Nested nested = new Nested();
// ...
@Validated
public static class Nested {
The results matched variant A exactly. omitted exits 0 with username=null, and invalid exits 0 with an empty username. It's a real annotation in a sensible-looking place. It compiles, and it changes nothing. Spring's @Validated is a variant of @Valid built for validation groups and method-level validation. It doesn't mark an association for Bean Validation to traverse. That's the job of jakarta.validation.Valid, placed on the field.
Pitfall: nested: {} doesn't test validation
A fourth YAML file with an empty object also seems like an obvious test:
app:
nested: {}
In this fixture, it exited 1 in all three variants, including A, which has no @Valid. The message:
Property app.nested Value "" Reason: java.lang.IllegalStateException: No setter found for property: nested
That's a binding error, not a Bean Validation error. The nested field is final and has only a getter. The {} reaches the binder as a value for app.nested, and the binder tries to assign it to the property. With no setter, the assignment fails. If you use {} to "prove" that @Valid works, the test fails for the wrong reason, and you may decide the cascade is active when it isn't. To test validation, use a missing key and an invalid value, like the omitted and invalid profiles.
Where the problem shows up
With @Valid, bad configuration shows up at startup, before any traffic arrives. The process exits with a non-zero code, and the log contains ConfigurationPropertiesBindException plus one FailureAnalyzer line in the form Property: ... Value: ... Reason: ... for each violation. Watch for that pair, the exit code and the exception in the startup log, during deployment. It already tells you which key is wrong. Without @Valid, the same configuration gets through startup, and the error only appears when some code uses username. That could be a NullPointerException in a method nobody thought depended on configuration, or a 400 that someone ends up investigating days later.
What was not tested
The test used a JavaBean with a pre-created nested object. Records and constructor binding weren't run. The docs say nested members of a constructor-bound class are also bound through their constructors, but @Valid on a record component wasn't tested with this fixture. If your project uses records, run the same three profiles before assuming the results carry over.
One more case was left out of the experiment but is worth knowing: a nested object that isn't created up front. The spec says that during cascading, "null references are ignored". If nested can be null, @Valid has no object to traverse and the missing configuration passes. To reject a missing object in that setup, put @NotNull on the parent's field next to @Valid. Finally, if spring-boot-starter-validation isn't on the classpath, @Validated on the type doesn't run Jakarta constraints.
Recommendation
DevDojo wouldn't ship a @ConfigurationProperties tree that has constraints on nested types but no @Valid on the fields leading to them. When the value is correct, the annotation costs nothing, as the valid profile shows with exit 0 in all three variants. When the value is wrong, the failure moves to startup.
The exception is a nested object with no constraints, where @Valid would have nothing to check. As soon as someone adds a constraint there, @Valid belongs in the same commit. A few code-review signs suggest it was forgotten: a STARTED line followed by a null or empty value from configuration, @Validated on an inner class presented as the fix, or a configuration test that uses {} and concludes validation works.
Next step
Find the @ConfigurationProperties classes in your project that have fields of your own types. Confirm that spring-boot-starter-validation is in the POM, and add @Valid to every nested field whose type has constraints inside it. Then run the application locally with two profiles equivalent to omitted and invalid. Both should exit 1 as described above. If either one prints that it started, the cascade still isn't reaching the inner object.