Jackson 3 no Spring Boot 4 - o catch(IOException) que engole erros de JSON - Meu Universo Nerd

Você migrou a aplicação pro Spring Boot 4, rodou a suíte de testes inteira, deu tudo verde. Subiu em produção numa terça tranquila. Aí, três dias depois, o time de suporte abre um chamado: a API tá devolvendo HTTP 200 com payload pela metade, sem estourar exceção nenhuma, sem log de erro, sem alerta. O seu catch (IOException e) que sempre pegou os problemas de JSON parou de pegar. E ninguém te avisou.

O culpado tem nome: Jackson 3. O Spring Boot 4 trocou o serializador JSON padrão e, junto com ele, veio uma mudança silenciosa na hierarquia de exceções que derruba o tratamento de erro de quem migrou no automático. Bora entender o que mudou e corrigir isso do jeito certo, antes que o cliente descubra primeiro.

O erro que passa nos testes e explode em produção

Esse é o tipo de bug mais cruel que existe: o que não aparece. Ele não quebra o build, não falha no teste unitário feliz, não solta stack trace no console. Ele só muda o comportamento da sua aplicação em um caso de borda que o seu teste não cobre, justamente o caso de borda que o usuário real vai encontrar.

O cenário é quase sempre o mesmo. Tem um @RestControllerAdvice antigo na aplicação, escrito lá em 2022, que captura erros de parsing de JSON pra devolver um 400 Bad Request bonitinho em vez de um 500 genérico. Esse handler foi escrito pensando no Jackson 2, onde toda exceção de JSON descendia de IOException. No Jackson 3, isso mudou. E o handler simplesmente para de funcionar, sem reclamar.

Se a sua equipe está nessa migração agora, vale lembrar que esse não é o único susto silencioso do Boot 4. Já falei aqui sobre como as APIs viram 403 silencioso depois de migrar por causa do CSRF do Spring Security 7. O padrão se repete: o Boot 4 mudou defaults sensatos, mas defaults que ninguém leu.

O que mudou no Jackson 3 (e por que o Boot 4 te empurrou pra ele)

O Jackson 3.0 saiu como versão estável em outubro de 2025, a primeira grande virada de versão da biblioteca em mais de uma década. O Spring Framework 7 e o Spring Boot 4 adotaram ele como serializador JSON padrão. Quem subiu pro Boot 4 está rodando Jackson 3, saiba disso ou não.

E por que você foi empurrado pra essa migração agora? Por causa do calendário. Com o fim de vida do Spring Boot 3 chegando em junho, ficar parado deixou de ser opção. A conta venceu.

São três mudanças do Jackson 3 que importam pra esse bug, em ordem de quanto vão te machucar:

  • A hierarquia de exceções virou unchecked. No Jackson 2, JsonProcessingException estendia IOException (checked). No Jackson 3, tudo desce de JacksonException, que agora é uma RuntimeException.
  • O pacote mudou. Saiu de com.fasterxml.jackson e foi pra tools.jackson. O group ID do Maven mudou junto.
  • O ObjectMapper mutável deu lugar ao JsonMapper imutável. Quem configurava o mapper na unha vai precisar reescrever.

A fonte oficial dessa virada tá no anúncio do suporte a Jackson 3 no blog da Spring, e o detalhe fino de cada mudança tá no guia de migração do próprio Jackson. Vale o bookmark.

O catch(IOException) que engole exceção: errado vs certo

Aqui tá o coração do problema. Olha o handler clássico que existia em metade dos projetos Spring Boot 3 por aí:

@RestControllerAdvice
public class ApiExceptionHandler {

    // Jackson 2: JsonProcessingException era um IOException (checked).
    // Esse catch pegava QUALQUER falha de desserializacao de JSON.
    @ExceptionHandler(IOException.class)
    public ResponseEntity<ApiError> tratarJson(IOException ex) {
        return ResponseEntity
                .badRequest()
                .body(new ApiError("JSON invalido", ex.getMessage()));
    }
}

No Boot 4 com Jackson 3, esse handler vira decoração. A exceção de desserialização agora é uma RuntimeException, não desce mais de IOException, então o @ExceptionHandler acima nunca é acionado. A exceção sobe direto, o Spring devolve um 500 genérico ou, dependendo de onde ela estoura no pipeline, uma resposta truncada. Nenhum teste antigo pega isso, porque os testes felizes mandam JSON válido.

O jeito certo é tratar a exceção pela nova raiz. Importe JacksonException de tools.jackson.core e aponte o handler pra ela:

import tools.jackson.core.JacksonException;

@RestControllerAdvice
public class ApiExceptionHandler {

    // Jackson 3: toda falha de JSON desce de JacksonException (unchecked).
    @ExceptionHandler(JacksonException.class)
    public ResponseEntity<ApiError> tratarJson(JacksonException ex) {
        // Agora cai aqui de novo: payload malformado vira 400, nao 500.
        return ResponseEntity
                .badRequest()
                .body(new ApiError("JSON invalido", ex.getOriginalMessage()));
    }
}

Repare em dois detalhes que valem ouro numa code review. Primeiro: como agora é RuntimeException, o compilador não te obriga mais a declarar throws, então é fácil esquecer que a exceção existe. O contrato deixou de ser explícito. Segundo: o Jackson 3 trocou getMessage() por getOriginalMessage() quando você quer a mensagem sem o ruído de localização. Pequeno, mas é o tipo de coisa que confunde no debug.

Como Tech Leader, o que eu aprendi revisando migração de time grande é simples: todo handler global de exceção precisa de um teste de caso infeliz. Manda um JSON quebrado de propósito no teste de integração e garante que o status é 400. Se você tivesse esse teste, a migração teria falhado no CI, não em produção. É o tipo de teste que parece bobo até salvar o seu fim de semana.

tools.jackson: o rename de pacote que quebra seus imports

A segunda dor é mais visível, e por isso menos perigosa: ela quebra a compilação na sua cara. O Jackson 3 mudou o pacote raiz de com.fasterxml.jackson pra tools.jackson. Todo import direto da biblioteca precisa ser atualizado:

// ANTES (Jackson 2)
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;

// DEPOIS (Jackson 3)
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.core.JacksonException;

Tem uma exceção importante nessa regra, e é uma boa notícia: as anotações continuam em com.fasterxml.jackson.annotation. Os seus @JsonProperty, @JsonIgnore e @JsonCreator espalhados pelos DTOs continuam funcionando sem tocar em uma linha. O pessoal do Jackson manteve o pacote de anotações estável de propósito, justamente pra que os dois mundos consigam coexistir.

No pom.xml, o group ID também muda. Se você declara o Jackson explicitamente em algum lugar, atualize:

<!-- ANTES -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

<!-- DEPOIS -->
<dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Na prática, se você usa os starters do Spring Boot 4 e não fixa versão de Jackson na mão, o próprio gerenciamento de dependências do Boot já traz o Jackson 3 certo. O rename de import você resolve com um localizar e substituir guiado pela IDE. Chato, mas mecânico.

ObjectMapper imutável: bem-vindo ao JsonMapper

A terceira mudança é a mais elegante e a que mais mexe com quem gosta de configurar o mapper. O velho ObjectMapper, aquele objeto mutável que todo mundo configurava chamando .configure(...) em qualquer canto da aplicação, foi aposentado em favor do JsonMapper, que é imutável e construído por builder.

Isso resolve um problema real e antigo: no Jackson 2 era possível pegar o ObjectMapper compartilhado, mudar a configuração dele em runtime e bagunçar a serialização do resto da aplicação sem perceber. Com o JsonMapper imutável, isso deixa de ser possível. Você monta uma vez e ele é thread-safe pra sempre.

// Jackson 3: JsonMapper construido por builder, imutavel e thread-safe
JsonMapper mapper = JsonMapper.builder()
        .findAndAddModules()
        .build();

String json = mapper.writeValueAsString(pedido);
Pedido pedido = mapper.readValue(json, Pedido.class);

Se você customizava o mapper via Spring, tem mais uma troca. O antigo Jackson2ObjectMapperBuilderCustomizer está deprecado. No Boot 4 você usa o novo customizer, que opera sobre o builder do JsonMapper:

@Configuration
public class JacksonConfig {

    @Bean
    JsonMapperBuilderCustomizer customizar() {
        // Configura o mapper do jeito do time, sem mexer no bean global na unha
        return builder -> builder
                .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(Include.NON_NULL))
                .configure(SerializationFeature.INDENT_OUTPUT, false);
    }
}

Quem quiser se aprofundar no novo modelo de configuração vai gostar do guia oficial de migração do Spring Boot 4, que lista cada propriedade afetada uma por uma.

As properties do application.yml que mudaram de lugar

Se você nunca tocou em ObjectMapper e configurava tudo pelo application.yml, ainda assim tem trabalho. As propriedades específicas de JSON ganharam um nível a mais: spring.jackson.read e spring.jackson.write agora vivem embaixo de spring.jackson.json.read e spring.jackson.json.write.

# ANTES (Spring Boot 3 / Jackson 2)
spring:
  jackson:
    read:
      ACCEPT_SINGLE_VALUE_AS_ARRAY: true

# DEPOIS (Spring Boot 4 / Jackson 3)
spring:
  jackson:
    json:
      read:
        ACCEPT_SINGLE_VALUE_AS_ARRAY: true

O detalhe perverso aqui é que uma propriedade no lugar errado não dá erro de boot. O Spring só ignora ela. Então a sua configuração de leitura tolerante some sem aviso, e de novo você só descobre quando um payload do mundo real, que dependia daquela tolerância, chega e quebra. Confere essas chaves com lupa.

O atalho pra migrar sem quebrar tudo: use-jackson2-defaults

O time do Spring Boot sabe que essa migração mexe em muita coisa, então deixou uma rede de segurança. Existe uma propriedade nova, spring.jackson.use-jackson2-defaults, que quando ligada faz o JsonMapper auto-configurado se comportar o mais próximo possível dos padrões do Jackson 2 no Boot 3.

spring:
  jackson:
    # Liga o comportamento "modo Jackson 2" enquanto voce migra com calma
    use-jackson2-defaults: true

Pensa nisso como uma marcha reduzida, não como destino. É ótimo pra subir o Boot 4 hoje e destravar a migração maior sem mudar o comportamento de serialização de uma vez. Mas ela não conserta o seu catch (IOException) quebrado, porque a hierarquia de exceções do Jackson 3 continua valendo de qualquer jeito. A flag alinha defaults de serialização, não a árvore de classes. Use pra ganhar tempo, marque um TODO e remova depois.

Quando isso vai te pegar (e quando não)

Vale calibrar o medo. Nem todo projeto sente esse bug com a mesma força:

  • Te pega com força se você tem @RestControllerAdvice capturando IOException ou JsonProcessingException pra tratar erro de payload.
  • Te pega de leve se você instancia ObjectMapper na mão em algum util: vai quebrar na compilação, que é o melhor lugar pra quebrar.
  • Quase não te toca se você usa só os DTOs com anotações e deixa o Spring serializar tudo, sem handler customizado de JSON. Nesse caso o starter do Boot 4 faz o trabalho e você segue a vida.

Esse cuidado com defaults que mudam de versão é o mesmo que a gente precisou ter quando o Spring Boot 4 ligou as Virtual Threads por padrão e o código blocking passou a escalar como reativo. A lição é a mesma de sempre: no mercado de hoje, migrar de major não é trocar número no pom.xml. É ler o release note inteiro, mesmo as três linhas sobre JSON que parecem detalhe.

Perguntas frequentes (FAQ)

Preciso reescrever todos os meus DTOs com anotações do Jackson?

Não. As anotações continuam no pacote com.fasterxml.jackson.annotation, então @JsonProperty, @JsonIgnore e companhia seguem funcionando sem mudança. Só os imports das classes da biblioteca (mapper, exceções) é que migram pra tools.jackson.

Dá pra rodar Jackson 2 e Jackson 3 juntos no mesmo classpath?

Dá. O Spring Boot 4 suporta os dois lado a lado: o JsonMapper auto-configurado usa Jackson 3, e um ObjectMapper que você declare manualmente continua no Jackson 2. Útil quando uma lib de terceiro ainda depende do Jackson 2.

O use-jackson2-defaults resolve o problema do catch de exceção?

Não. Essa flag alinha os defaults de serialização, não a hierarquia de classes. O JacksonException continua sendo RuntimeException com ela ligada. O handler você ajusta na mão.

Como eu garanto que não vai escapar nenhum erro de JSON?

Escreve um teste de integração que manda um corpo de requisição malformado de propósito e afirma que a resposta é 400, não 500. Roda no CI. Esse teste teria pego o bug antes de produção, certinho?

Resumo e próximos passos

Recapitulando o que importa pra você não levar esse susto:

  • A hierarquia de exceções virou unchecked. Troque catch (IOException) por catch (JacksonException) nos seus handlers de JSON.
  • O pacote migrou pra tools.jackson, mas as anotações ficam em com.fasterxml.jackson.annotation.
  • O ObjectMapper virou JsonMapper imutável, e o customizer do Spring mudou junto.
  • As properties de JSON desceram um nível pra spring.jackson.json.*, e a flag use-jackson2-defaults compra tempo, sem ser destino.

Você já migrou pro Spring Boot 4 e topou com esse catch mudo? Ou ainda tá segurando no Boot 3 esperando a poeira baixar? Conta nos comentários como tá sendo a migração no seu time, quero saber quais outras armadilhas vocês acharam.

Semana que vem eu volto com o próximo capítulo dessa saga de migração: como configurar a observabilidade nativa do Boot 4 sem depender do Actuator completo. Fica de olho que esse vai poupar dependência e dor de cabeça.