Todos os artigos

// Knowledge.log — 技術記事

@Valid em @ConfigurationProperties aninhado: o @NotNull que passa no startup

Sem @Valid no campo nested, @NotBlank não dispara no bind e o Boot 4.1.1 sobe. Com @Valid, SpringApplication.run falha no startup.

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
PerfilA: sem @ValidB: @Valid no campoC: @Validated em Nested, sem @Valid
omittedexit 0, STARTED nested.username=nullexit 1, must not be blankexit 0, STARTED nested.username=null
invalidexit 0, STARTED nested.username=exit 1, must not be blankexit 0, STARTED nested.username=
validexit 0, STARTED nested.username=devdojoexit 0, STARTED nested.username=devdojoexit 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.

javaspring-bootvalidation

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos