Todos os artigos

// Knowledge.log — 技術記事

Jackson no Spring Boot 4.1: o campo extra no JSON que some no bind e a API não recusa

Boot 4.1.1/Jackson 3.1.5: extra no JSON some no bind e a API devolve 200. fail-on-unknown-properties vira 400; mail antigo também é unknown.

Um caso comum: o contrato da API renomeou mail para email. O DTO no servidor já foi atualizado, mas um cliente continua mandando {"mail":"a@b.c"} e, às vezes, um campo a mais que ninguém pediu. O Spring Boot faz o bind sem reclamar, o email chega null no controller e a resposta é 200. O 200 é verdadeiro do ponto de vista do HTTP e falso do ponto de vista do pedido.

Isso não é bug do Boot. É o comportamento padrão do Jackson 3: propriedade desconhecida no JSON é ignorada. O problema é que, durante um rename, o nome antigo é justamente uma propriedade desconhecida.

A ideia aqui é rodar o mesmo JSON em três configurações, com @WebMvcTest e MockMvcTester: o default do Boot, a propriedade spring.jackson.deserialization.fail-on-unknown-properties=true e um DTO anotado com @JsonIgnoreProperties(ignoreUnknown = true). Tudo sem container e sem servidor rodando.

Versões e o mapper que realmente atende o HTTP

O experimento rodou em 04/10/2026 com Spring Boot 4.1.1, a GA mais nova da linha 4.1. No Maven Central, o latest/release do spring-boot-starter-parent aponta para 4.2.0-M2, que é milestone e ficou de fora. O BOM do 4.1.1 traz Spring Framework 7.0.9 e Jackson 3.1.5 (grupo tools.jackson), com jackson-annotations 2.21. Runtime: Temurin 25.0.4 e Maven 3.9.12. Pela página de requisitos, o Boot 4.1.1 exige Java 17 e é compatível até o Java 26.

O ponto que muda em relação ao Boot 2 e 3 é o mapper. A documentação de JSON do Boot 4.1 diz que "Jackson 3 is the preferred and default library" e que, com Jackson no classpath, "a JsonMapper bean is automatically configured". Esse bean é tools.jackson.databind.json.JsonMapper, e o Boot injeta essa mesma instância no JacksonJsonHttpMessageConverter, o converter que lê o @RequestBody.

Não é com.fasterxml.jackson.databind.ObjectMapper nem MappingJackson2HttpMessageConverter. O suporte a Jackson 2 continua existindo como spring-boot-jackson2, com propriedades spring.jackson2.*, mas está deprecated e não é o default. A memória muscular do Jackson 2 vai digitar ObjectMapper antes de você terminar de ler o import. Vale conferir o pacote.

O fixture: um record, três endpoints

O POM usa o parent 4.1.1, java.version 25 e spring-boot-starter-webmvc. No 4.1.1, o starter-web está deprecated em favor do webmvc. O starter de validação entra só para o caso com @Valid:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
  <relativePath/>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>jackson-unknown</artifactId>
<version>0.0.1</version>
<properties>
  <java.version>25</java.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

O DTO principal tem um único campo, já com o nome novo:

package academy.devdojo.jacksonunknown;

public record OrderRequest(String email) {
}

Os outros dois tipos servem para comparação. Um ignora desconhecidos no próprio tipo, e o outro exige email:

package academy.devdojo.jacksonunknown;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public record StrictIgnoreRequest(String email) {
}
package academy.devdojo.jacksonunknown;

import jakarta.validation.constraints.NotNull;

public record ValidOrderRequest(@NotNull String email) {
}

Repare no import da anotação: com.fasterxml.jackson.annotation, e não tools.jackson. As anotações do Jackson 3 continuam no pacote 2.x.

O controller devolve o que recebeu, assim o teste lê o resultado do bind direto no body da resposta:

@RestController
public class OrderController {

    @PostMapping("/orders")
    OrderRequest orders(@RequestBody OrderRequest request) {
        return request;
    }

    @PostMapping("/orders/ignore")
    StrictIgnoreRequest ignore(@RequestBody StrictIgnoreRequest request) {
        return request;
    }

    @PostMapping("/orders/valid")
    ValidOrderRequest valid(@Valid @RequestBody ValidOrderRequest request) {
        return request;
    }
}

Os payloads são quatro:

  • EXTRA: {"email":"a@b.c","nickname":"ghost"}
  • RENAME: {"mail":"a@b.c"}
  • EXTRA+RENAME: {"mail":"a@b.c","nickname":"ghost"}
  • OMIT: {}

O comando executado foi mvn -B -e test. Resultado: Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS.

Default do Boot: o extra some e o nome antigo vira null

A classe DefaultJacksonUnknownTests usa @WebMvcTest(OrderController.class) sem nenhuma propriedade. O caso EXTRA:

@Test
void extraFieldIsDroppedAndReturns200() {
    assertThat(mvc.post().uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(EXTRA))
            .hasStatusOk()
            .bodyJson()
            .extractingPath("$.email")
            .isEqualTo("a@b.c");
    System.out.println("DEFAULT EXTRA status=200 email=a@b.c extra dropped");
}

O nickname chamado ghost cumpre o que o nome promete: entra no request e não aparece em lugar nenhum. O RENAME e o EXTRA+RENAME usam o mesmo formato, mas a asserção é .isNull() no $.email. A saída:

DEFAULT EXTRA status=200 email=a@b.c extra dropped
DEFAULT RENAME status=200 email=null
DEFAULT EXTRA+RENAME status=200 email=null extra dropped

O teste também injeta o JsonMapper do contexto e compara com um mapper criado só pela biblioteca:

@Test
void bootJsonMapperAndLibraryJsonMapperBothDisableFailOnUnknown() {
    JsonMapper library = JsonMapper.builder().build();
    boolean bootFail = bootMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    boolean libFail = library.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    // ...
    assertThat(bootFail).isFalse();
    assertThat(libFail).isFalse();
    OrderRequest fromLib = library.readValue(EXTRA, OrderRequest.class);
    assertThat(fromLib.email()).isEqualTo("a@b.c");
}
BOOT fail-on-unknown=false
LIB  fail-on-unknown=false
LIB EXTRA no throw email=a@b.c
LIB EXTRA with FAIL_ON_UNKNOWN_PROPERTIES throws UnrecognizedPropertyException

Ou seja, ignorar o desconhecido não é algo que o Boot adiciona. É o padrão do Jackson 3. O javadoc do DeserializationFeature 3.1.5 é direto: "Feature is disabled by default as of Jackson 3.0 (in 2.x it was enabled)". Um new JsonMapper() tem o mesmo default. O que o bean do Boot adiciona são as customizações dele: módulos, mixins e as propriedades spring.jackson.*. Por isso, criar um mapper "limpo" à mão não deixa o bind mais rígido. Só faz você perder essas customizações.

Ligando fail-on-unknown-properties

O how-to de Spring MVC do Boot 4.1 mapeia cada valor do enum DeserializationFeature para spring.jackson.deserialization.<feature>, com relaxed binding. Então fail-on-unknown-properties corresponde a FAIL_ON_UNKNOWN_PROPERTIES:

spring.jackson.deserialization.fail-on-unknown-properties=true

No teste, a propriedade entra pelo @TestPropertySource:

@WebMvcTest(OrderController.class)
@TestPropertySource(properties = "spring.jackson.deserialization.fail-on-unknown-properties=true")
class FailOnUnknownJacksonTests {

    @Test
    void extraFieldReturns400WithUnrecognizedProperty() {
        MvcTestResult result = mvc.post().uri("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(EXTRA)
                .exchange();
        assertThat(result).hasStatus(HttpStatus.BAD_REQUEST);
        Exception failure = result.getMvcResult().getResolvedException();
        assertThat(failure).isInstanceOf(HttpMessageNotReadableException.class);
        assertThat(failure.getCause()).isInstanceOf(UnrecognizedPropertyException.class);
    }
    // ...
}

A cadeia é a esperada: o Jackson lança tools.jackson.databind.exc.UnrecognizedPropertyException, o converter embrulha em HttpMessageNotReadableException e o Spring MVC responde 400. A mensagem que o teste capturou:

FAIL EXTRA status=400
FAIL EXTRA resolved=org.springframework.http.converter.HttpMessageNotReadableException
FAIL EXTRA message=JSON parse error: Unrecognized property "nickname" (class academy.devdojo.jacksonunknown.OrderRequest), not marked as ignorable
FAIL EXTRA cause=tools.jackson.databind.exc.UnrecognizedPropertyException

A parte interessante é o rename. O mail que sobrou no payload não é "campo faltando". Para o Jackson, é uma propriedade que o OrderRequest não conhece:

FAIL RENAME leftover old key status=400
FAIL RENAME message=JSON parse error: Unrecognized property "mail" (class academy.devdojo.jacksonunknown.OrderRequest), not marked as ignorable

Já o {} passa, porque não tem nada desconhecido nele. O email só não veio:

FAIL OMIT {} status=200 email=null — missing is not unknown

Por último, o POST /orders/ignore com EXTRA, ainda com a propriedade global ligada:

FAIL EXTRA on /orders/ignore status=200 annotation wins

O @JsonIgnoreProperties(ignoreUnknown = true) no tipo prevaleceu sobre o global, e o 200 nesse endpoint foi medido, não deduzido. Isso funciona como válvula para um DTO específico, mas também é um buraco que alguém pode abrir sem perceber. Uma anotação copiada de um projeto antigo desliga, só naquele tipo, a proteção que você acabou de ligar.

Desconhecido, ausente e o nome antigo: a tabela

PayloadEndpointDefault do Bootfail-on-unknown-properties=true
EXTRA/orders200, email=a@b.c, nickname descartado400, HttpMessageNotReadableException causada por UnrecognizedPropertyException (nickname)
RENAME/orders200, email=null400, Unrecognized property "mail"
EXTRA+RENAME/orders200, email=nullnão executado
OMIT {}/ordersnão executado200, email=null
EXTRA/orders/ignorenão executado200, a anotação no tipo vence
RENAME/orders/valid400, MethodArgumentNotValidExceptionnão executado
EXTRA/orders/valid200, email=a@b.cnão executado

As duas últimas linhas mostram a outra metade da proteção. Com @Valid @RequestBody e @NotNull no campo, o RENAME cai em 400 por MethodArgumentNotValidException (o email chega null, "must not be null"). O EXTRA com email presente passa com 200, porque a validação olha os campos do record, não as chaves que sobraram no JSON. A documentação de @RequestBody descreve esse caminho: erro de validação vira MethodArgumentNotValidException e 400.

Ou seja, cada mecanismo cobre uma coisa. fail-on-unknown-properties recusa chave que o tipo não conhece. @Valid com @NotNull recusa campo obrigatório ausente. O rename com o nome antigo cai nos dois. O extra com o nome certo só cai no primeiro.

A armadilha: tratar o nome antigo como "só faltou o campo"

O erro de diagnóstico mais comum é olhar o email nulo e concluir que o cliente "esqueceu de mandar". O cliente mandou, só que com o nome antigo. Se o time trata isso como ausência, a correção vira um @NotNull e o rename fica parcialmente coberto. Se trata como desconhecido, liga fail-on-unknown-properties e o mail vira um 400 com o nome da propriedade na mensagem. No caso do rename, o segundo diagnóstico diz exatamente o que aconteceu.

Para observar isso em produção:

  • Ligue a propriedade primeiro em ambiente de teste ou homologação, com o tráfego real dos clientes, e veja quais propriedades aparecem como Unrecognized property. Clientes que mandam campos a mais "por garantia" aparecem rápido.
  • Registre o HttpMessageNotReadableException com a rota e o nome da propriedade que a mensagem traz. Um aumento de 400 com Unrecognized property "mail" logo depois de um deploy de contrato aponta para cliente desatualizado, não para instabilidade.
  • Não registre o body inteiro do request só para depurar isso. O nome da propriedade basta, e o payload pode ter dado pessoal.

Limites do experimento

Foi uma execução funcional com MockMvcTester, sem servidor real, sem medição de desempenho e com um DTO de um campo. Não foram testados tipos aninhados, coleções, herança polimórfica nem o caminho Jackson 2 (spring-boot-jackson2). Algumas combinações da tabela ficaram sem execução e estão marcadas assim.

O assunto é a desserialização do request body pelo converter Jackson 3 no Spring MVC. Ele está perto de dois artigos recentes, mas não se confunde com eles. O RestClient 4xx no Boot 4.1 trata do cliente que lança exceção no 422 e não cobre o bind de propriedades desconhecidas na entrada. O @Valid nested em ConfigurationProperties trata do bind de propriedades aninhadas no startup, não de JSON HTTP nem do JsonMapper.

Recomendação

A DevDojo não publicaria uma API JSON pública no Boot 4.1 que descarta propriedades desconhecidas em silêncio, ainda mais durante um rename de campo. Nessa situação, ligue spring.jackson.deserialization.fail-on-unknown-properties=true e coloque @Valid com @NotNull nos campos obrigatórios. Os dois juntos transformam o nome antigo em 400 com uma mensagem que diz qual campo está errado.

O default tolerante faz sentido quando você controla os clientes e eles evoluem antes do servidor, ou numa API interna em que campo extra é esperado e inofensivo. Revise essa escolha se aparecer null em campo de negócio com status 200, um rename de contrato sem prazo para os clientes migrarem ou um @JsonIgnoreProperties(ignoreUnknown = true) espalhado em DTOs que deveriam ser estritos.

Próximo passo

Pegue o controller que recebe o DTO mais importante da API e crie um @WebMvcTest com @TestPropertySource(properties = "spring.jackson.deserialization.fail-on-unknown-properties=true"), no formato do FailOnUnknownJacksonTests. Escreva dois casos: um payload com uma chave extra e outro com o nome antigo de um campo que já foi renomeado. Os dois devem dar 400 com UnrecognizedPropertyException na causa. Se algum passar com 200, procure um @JsonIgnoreProperties nesse tipo antes de mexer em qualquer outra coisa.

javaspring-bootjackson

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

Conhecimento só conta quando vira prática.

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

Explorar mais artigos