O cenário é comum. Você tem uma classe @ConfigurationProperties com @Validated em cima, um objeto aninhado dentro dela e uma constraint no campo desse objeto interno. A ideia é que, se alguém esquecer de configurar o username, a aplicação nem suba. Só que ela sobe. O log mostra o startup normal, o contexto fica pronto e o username está null. Tecnicamente, o serviço iniciou bem.
O que falta é uma anotação. A documentação de configuração externa do Spring Boot diz isso com todas as letras:
> "To cascade validation to nested properties the associated field must be annotated with @Valid."
Com @Valid no campo aninhado, a falha acontece dentro de SpringApplication.run, antes de qualquer código seu usar o valor. Sem @Valid, a constraint interna simplesmente não é avaliada. A seguir estão os dois lados executados, com exit code e o texto que o Boot imprime, além de um terceiro caso que parece correção e não corrige nada.
Versões
O experimento usou Spring Boot 4.1.1, a GA mais recente em 01/10/2026 (a 4.2 ainda está em milestone). O BOM do Boot traz Spring Framework 7.0.9, Hibernate Validator 9.1.3.Final e jakarta.validation-api 3.1.1. O runtime foi Temurin 25.0.4, com Maven 3.9.12. Pela página de requisitos, o Boot 4.1.1 exige pelo menos Java 17 e é compatível até o Java 26, então o JDK 25 está dentro da matriz.
Se a dúvida é qual fonte forneceu o valor, isso está em Configuração externa no Spring Boot 4.1.
O fixture
É um projeto Maven pequeno, sem servidor web. O POM tem só o starter base e o de validação:
<?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>
O application.yml base só desliga o servidor web:
spring:
main:
web-application-type: none
A aplicação registra as properties, sobe o contexto, imprime o username e encerra. Neste fixture, o "primeiro uso" do valor é esse println logo depois do 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);
}
}
A classe de properties segue o formato do exemplo oficial de validação: @Validated no tipo e um objeto aninhado já instanciado no campo, exposto só por getter. Esta é a versão com @Valid, a variante 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;
}
}
}
A variante A é a mesma classe sem aquela linha:
private final Nested nested = new Nested();
Os três perfis de YAML cobrem os casos que interessam. O application-omitted.yml não tem a chave:
# omitted nested key on purpose
O application-invalid.yml tem a chave, mas com valor vazio:
app:
nested:
username: ""
O application-valid.yml tem um valor de verdade:
app:
nested:
username: "devdojo"
O perfil invalid merece atenção. A chave existe, o valor está entre aspas e, numa revisão de PR, username: parece configurado. Só que não tem nada ali. Por isso o fixture usa @NotBlank em vez de @NotNull, porque "" passa no @NotNull.
Rodando: sem @Valid, com @Valid e com @Validated no lugar errado
Para cada variante, os comandos foram estes:
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
| Perfil | A: sem @Valid | B: @Valid no campo | C: @Validated em Nested, sem @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 |
Variante A: tudo verde
Sem @Valid, os três perfis terminam com exit 0. No omitted, o processo imprime STARTED nested.username=null. No invalid, imprime STARTED nested.username=, com o vazio depois do sinal de igual. O @NotBlank está no código, o @Validated está no tipo, o starter de validação está no classpath, e mesmo assim nada é validado no objeto interno.
O @Validated faz o Boot validar AppProperties durante o bind, e é só isso que ele faz. AppProperties não tem constraint nenhuma nos próprios campos, e sem @Valid nada manda o validador descer para Nested, que é onde o @NotBlank está. Na Jakarta Bean Validation, essa descida é papel exclusivo do @Valid: "@Valid is used to express validation traversal of an association". O lado do @Validated está descrito na seção de validação da documentação do Boot.
Variante B: o startup falha
Com @Valid no campo, omitted e invalid terminam com exit 1 durante SpringApplication.run. Nenhum dos dois chega a imprimir STARTED. A exceção na cadeia é org.springframework.boot.context.properties.ConfigurationPropertiesBindException, e o FailureAnalyzer do Boot resume o problema. No perfil omitted:
Binding to target academy.devdojo.nestedvalid.AppProperties failed: Property: app.nested.username Value: "null" Reason: must not be blank
No perfil invalid:
Property: app.nested.username Value: "" Reason: must not be blank
O texto aponta a propriedade pelo nome completo (app.nested.username), mostra o valor recebido e informa a constraint violada. O perfil valid continua com exit 0 e STARTED nested.username=devdojo, então a anotação só barra o que está errado.
Um detalhe sobre o omitted: ele falha mesmo sem a chave no YAML porque o Nested já foi instanciado no campo. O objeto existe e o username dentro dele é null, então a cascata entra e o @NotBlank rejeita.
Variante C: @Validated na classe interna
Quando se percebe que a validação não está descendo, a correção mais comum é colocar @Validated também na classe aninhada:
private final Nested nested = new Nested();
// ...
@Validated
public static class Nested {
O resultado foi idêntico ao da variante A: omitted com exit 0 e username=null, invalid com exit 0 e username vazio. É uma anotação de verdade, colocada num lugar plausível, que compila e não muda nada. O @Validated do Spring é uma variante do @Valid voltada a grupos de validação e a validação em nível de método. Ele não marca uma associação para o Bean Validation percorrer. Quem faz isso é o jakarta.validation.Valid, no campo.
Armadilha: nested: {} não é o caso de validação
Também parece natural testar um quarto YAML, com o objeto vazio:
app:
nested: {}
Nesse fixture, ele falhou com exit 1 nas três variantes, inclusive na A, que não tem @Valid. O texto:
Property app.nested Value "" Reason: java.lang.IllegalStateException: No setter found for property: nested
Isso é um erro de bind e não tem relação com Bean Validation. O campo nested é final, tem só getter, e o {} chega ao binder como um valor para app.nested que ele tenta atribuir à propriedade. Sem setter, a atribuição falha. Se você usar {} para "provar" que o @Valid funciona, o teste vai falhar pelo motivo errado e você pode concluir que a cascata está ativa quando ela não está. Para testar validação, use a chave ausente e o valor inválido, como nos perfis omitted e invalid.
Onde o problema aparece
Com @Valid, a configuração errada aparece no startup, antes de qualquer tráfego: o processo sai com código diferente de zero e o log traz ConfigurationPropertiesBindException, com uma linha Property: ... Value: ... Reason: ... do FailureAnalyzer para cada violação. Esse par, exit code e exceção no log de startup, é o sinal a observar no deploy, e ele já diz qual chave está errada. Sem @Valid, a mesma configuração passa pelo startup e o erro só aparece quando algum código usa o username. Pode ser um NullPointerException num método que ninguém achou que dependia de configuração, ou um 400 que alguém vai investigar dias depois.
Limites do que foi testado
O teste usou JavaBean com objeto aninhado pré-instanciado. O estilo com records e constructor binding não foi executado. A documentação diz que os membros aninhados de uma classe com constructor binding também são preenchidos pelo construtor, mas o caso com @Valid em record component não passou por este fixture. Se o seu projeto usa records, rode os mesmos três perfis antes de supor que o resultado é igual.
Um caso também ficou de fora do experimento, mas vale conhecer: o objeto aninhado sem pré-instanciação. A spec diz que, na cascata, "null references are ignored". Se o nested puder ser null, o @Valid não tem objeto para percorrer e a ausência passa. Para recusar ausência nesse formato, a constraint @NotNull precisa estar no campo do pai, ao lado do @Valid. Por fim, sem o spring-boot-starter-validation no classpath, o @Validated no tipo não executa as constraints Jakarta.
Recomendação
A DevDojo não colocaria em produção uma árvore @ConfigurationProperties com constraints em tipos aninhados e sem @Valid no campo que leva até eles. A anotação não custa nada quando o valor está certo, como mostra o perfil valid com exit 0 nas três variantes, e quando o valor está errado ela leva a falha para o startup.
A exceção é um objeto aninhado sem nenhuma constraint, em que o @Valid não teria o que validar. Assim que alguém adicionar uma constraint ali, o @Valid precisa entrar no mesmo commit. Há alguns sinais de que isso foi esquecido: um STARTED seguido de um valor null ou vazio vindo de configuração, um @Validated em classe interna tratado como correção, ou um teste de configuração que usa {} e conclui que a validação funciona.
Próximo passo
Procure as classes @ConfigurationProperties do seu projeto que têm campos de tipo próprio. Confirme que spring-boot-starter-validation está no POM e coloque @Valid em cada campo aninhado que tenha constraints internas. Depois, rode a aplicação localmente com dois perfis equivalentes a omitted e invalid. Os dois devem terminar com o exit 1 descrito acima. Se algum deles imprimir que subiu, a cascata ainda não está chegando onde deveria.