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
| Payload | Endpoint | Default do Boot | fail-on-unknown-properties=true |
|---|---|---|---|
| EXTRA | /orders | 200, email=a@b.c, nickname descartado | 400, HttpMessageNotReadableException causada por UnrecognizedPropertyException (nickname) |
| RENAME | /orders | 200, email=null | 400, Unrecognized property "mail" |
| EXTRA+RENAME | /orders | 200, email=null | não executado |
OMIT {} | /orders | não executado | 200, email=null |
| EXTRA | /orders/ignore | não executado | 200, a anotação no tipo vence |
| RENAME | /orders/valid | 400, MethodArgumentNotValidException | não executado |
| EXTRA | /orders/valid | 200, email=a@b.c | nã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
HttpMessageNotReadableExceptioncom a rota e o nome da propriedade que a mensagem traz. Um aumento de 400 comUnrecognized 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.