Modernizando Java legado com IA - o que funciona e o que dá errado

Você herdou um sistema. Java 8, Spring 4 com metade da configuração em XML, Hibernate com hbm.xml, um pom.xml com dependências que não recebem atualização desde 2017 e um scanner de segurança apontando dezenas de CVEs. Ninguém do time original está mais na empresa. Não tem teste, ou tem uns vinte que ninguém roda porque metade quebra.

A tentação em 2026 é abrir um agente de IA, apontar para o repositório e pedir "migra isso para Java 21 e Spring Boot 3". Dá para fazer. O resultado compila, às vezes até sobe. E aí, três semanas depois, o financeiro reclama que um relatório está arredondando centavos diferente.

Esse post é sobre como usar IA nesse trabalho sem cair nisso: onde ela rende muito, onde ferramenta determinística é melhor, e o que precisa existir antes de qualquer linha mudar.

#O problema real não é o código

Sistema legado não é sinônimo de código feio. Código feio que tem teste e documentação se refatora numa tarde. O que torna um sistema legado difícil é outra coisa:

  • Conhecimento perdido: regras de negócio que só existem no código, às vezes num if dentro de um service de três mil linhas, sem comentário nem ticket.
  • Nenhuma rede de segurança: sem teste, qualquer mudança é aposta.
  • Dependências mortas: bibliotecas abandonadas, versões fora de suporte, APIs do JDK que deixaram de existir.
  • Medo: ninguém mexe porque ninguém sabe o que quebra, então o sistema envelhece mais rápido.

Repara que digitar código não aparece na lista. O gargalo de uma modernização é entender o sistema, não escrever o código novo. E é justamente aí que a IA mais ajuda, muito mais do que na geração de código em si.

#Fase 1: arqueologia

Antes de mudar qualquer coisa, use a IA para ler. Um modelo com janela de contexto grande, rodando como agente dentro do repositório (Claude Code, Cursor, Copilot no modo agente, o que seu time usar), consegue fazer em uma hora o que levaria uma semana de leitura manual.

Coisas que valem pedir, uma por vez:

  • Mapa de módulos: quais pacotes existem, quem depende de quem, onde estão os pontos de entrada (servlets, controllers, jobs agendados, listeners de fila).
  • Fluxos de ponta a ponta: "siga o que acontece quando um pedido é cancelado, do endpoint até o banco, e liste cada efeito colateral". Esse tipo de pergunta revela as regras escondidas.
  • Tradução de configuração: XML do Spring, mapeamentos do Hibernate, web.xml, filtros. A IA explica o que cada bean faz e de onde vem cada injeção.
  • SQL e stored procedures: muita regra de negócio de sistema antigo mora no banco. Peça a explicação de cada procedure e quais tabelas ela altera.

O resultado dessa fase é documentação: um arquivo por módulo, diagramas em Mermaid, uma lista de regras de negócio encontradas. Revise tudo. A IA vai errar em alguns pontos, principalmente onde o código usa reflexão, configuração externa ou convenção que não aparece no fonte. Mas corrigir um documento errado é muito mais rápido que escrever do zero, e o ato de revisar já te ensina o sistema.

Guarde o que for confirmado num arquivo de contexto do projeto (CLAUDE.md, AGENTS.md ou equivalente). Toda sessão futura do agente começa sabendo o que você já descobriu, em vez de redescobrir e errar de novo.

#Fase 2: travar o comportamento atual

Essa é a fase que todo mundo quer pular e a mais importante do post.

Você não vai migrar um sistema sem teste. Mas também não vai escrever testes unitários bonitos para código que você ainda não entende. O que você precisa é de testes de caracterização: testes que registram o que o sistema faz hoje, certo ou errado, e quebram se esse comportamento mudar.

A técnica também é chamada de golden master. Você roda o código atual com um conjunto de entradas, salva a saída, e o teste compara execuções futuras com essa saída salva:

 1class CalculoFreteCaracterizacaoTest {
 2
 3    // Entradas reais, anonimizadas, tiradas de produção.
 4    static Stream<Pedido> pedidos() {
 5        return CarregadorFixture.ler("fixtures/pedidos.json");
 6    }
 7
 8    @ParameterizedTest
 9    @MethodSource("pedidos")
10    void freteContinuaIgual(Pedido pedido) {
11        BigDecimal frete = new CalculoFreteService().calcular(pedido);
12
13        // Compara com o valor gravado na primeira execução, contra o código antigo.
14        assertEquals(Golden.valor("frete", pedido.getId()), frete.toPlainString());
15    }
16}

Repara no toPlainString(). Comparar BigDecimal com equals considera a escala (1.0 é diferente de 1.00), e é exatamente esse tipo de diferença que você quer pegar.

A IA é ótima para gerar esses testes em volume. Peça para ela identificar os caminhos de cada método crítico, montar entradas que cubram cada ramo e escrever o teste. Dois cuidados:

  • O teste trava o comportamento atual, inclusive os bugs. Se o agente "corrigir" algo enquanto escreve o teste, o teste deixa de servir. Deixe isso explícito no pedido.
  • Rode contra o código antigo primeiro. Todo teste de caracterização tem que passar antes de qualquer migração. Se não passa, ele está errado, não o sistema.

Com isso no lugar, você tem algo que não tinha antes: um jeito de saber se quebrou.

#Fase 3: migração mecânica com ferramenta determinística

Agora sim, mudar código. E aqui vai a regra que mais economiza dor de cabeça: o que é mecânico vai para ferramenta determinística, e a IA fica com o que exige julgamento.

Trocar javax.persistence por jakarta.persistence em quatrocentos arquivos não exige inteligência. Exige consistência. Um LLM fazendo isso arquivo por arquivo vai acertar 98% e errar 2% de forma diferente a cada vez. Uma ferramenta de refatoração baseada em AST acerta 100%, ou falha de um jeito previsível.

A ferramenta padrão para isso no mundo Java é o OpenRewrite. Ele trabalha sobre a árvore sintática com tipos resolvidos, não sobre texto, e vem com receitas prontas para os saltos mais comuns:

 1<plugin>
 2    <groupId>org.openrewrite.maven</groupId>
 3    <artifactId>rewrite-maven-plugin</artifactId>
 4    <version><!-- versão atual no Maven Central --></version>
 5    <configuration>
 6        <activeRecipes>
 7            <recipe>org.openrewrite.java.migrate.UpgradeToJava21</recipe>
 8            <recipe>org.openrewrite.java.migrate.jakarta.JavaxToJakarta</recipe>
 9        </activeRecipes>
10    </configuration>
11    <dependencies>
12        <dependency>
13            <groupId>org.openrewrite.recipe</groupId>
14            <artifactId>rewrite-migrate-java</artifactId>
15            <version><!-- versão atual --></version>
16        </dependency>
17    </dependencies>
18</plugin>
 1mvn rewrite:dryRun   # gera um patch em target/rewrite/ sem tocar no código
 2mvn rewrite:run      # aplica

Para Spring Boot existe a família de receitas UpgradeSpringBoot_3_x no módulo rewrite-spring. Confira com mvn rewrite:discover quais estão disponíveis na versão que você instalou, porque a lista cresce a cada release.

Outra opção, se você está na AWS, é o Amazon Q Developer, que faz o salto de Java 8, 11 ou 17 para 17 ou 21 combinando receitas determinísticas com IA. A limitação prática é que o projeto precisa buildar com Maven, então projeto em Ant ou Gradle fica de fora.

Rode a migração em passos pequenos, um salto por vez, com os testes de caracterização entre cada um. Java 8 para 11 é um salto com problemas próprios (os módulos de JAXB, JAX-WS e CORBA saíram do JDK no 11). De 11 para 17 o encapsulamento forte das APIs internas do JDK quebra bibliotecas que usavam reflexão. De 17 para 21 costuma ser tranquilo. Fazer tudo de uma vez transforma três problemas conhecidos em um problema misterioso.

#Fase 4: a IA no que sobra

As receitas cobrem talvez 70 a 80% do trabalho. O resto é onde a IA brilha:

  • Bibliotecas abandonadas: aquela lib de geração de PDF que parou em 2016 e não tem versão compatível. A IA sugere substitutos e reescreve as chamadas.
  • APIs removidas sem substituto direto: Nashorn saiu no Java 15, o SecurityManager foi depreciado no 17 e desativado de vez no 24. Código que dependia disso precisa ser repensado, não traduzido.
  • Erros de compilação e de runtime pós-migração: cole o stack trace, deixe o agente investigar. Ele lê o código ao redor, o changelog da dependência, e propõe a correção.
  • Código reflexivo e configuração por convenção: onde o OpenRewrite não enxerga porque a referência está numa string.

O fluxo que funciona com agente é sempre o mesmo loop curto:

  1. Uma tarefa pequena e bem definida ("faça o módulo de faturamento compilar no Java 17").
  2. O agente edita.
  3. Build e testes de caracterização rodam, de preferência pelo próprio agente.
  4. Você revisa o diff antes de aceitar.
  5. Commit. Próxima tarefa.

PRs pequenos não são preciosismo, são a única forma de a revisão humana continuar funcionando. Um diff de três mil linhas gerado por IA não é revisado, é aprovado. E aprovar sem revisar é como os bugs silenciosos chegam em produção.

#Fase 5: modernizar de verdade

Compilar no Java 21 não é modernizar, é só sair da zona de risco. Depois que o sistema está estável na versão nova, vem a parte que melhora o código.

Um exemplo típico: um DAO com JDBC cru, do jeito que se escrevia em 2012.

 1public List<Cliente> buscarAtivos() {
 2    List<Cliente> lista = new ArrayList<Cliente>();
 3    Connection conn = null;
 4    PreparedStatement ps = null;
 5    ResultSet rs = null;
 6    try {
 7        conn = dataSource.getConnection();
 8        ps = conn.prepareStatement("SELECT id, nome, email FROM cliente WHERE ativo = 1");
 9        rs = ps.executeQuery();
10        while (rs.next()) {
11            Cliente c = new Cliente();
12            c.setId(rs.getLong("id"));
13            c.setNome(rs.getString("nome"));
14            c.setEmail(rs.getString("email"));
15            lista.add(c);
16        }
17    } catch (SQLException e) {
18        throw new RuntimeException(e);
19    } finally {
20        // três blocos try/catch fechando rs, ps e conn
21    }
22    return lista;
23}

Com um record e o JdbcClient do Spring 6.1:

 1public record Cliente(Long id, String nome, String email) {}
 2
 3public List<Cliente> buscarAtivos() {
 4    return jdbcClient.sql("SELECT id, nome, email FROM cliente WHERE ativo = 1")
 5            .query(Cliente.class)
 6            .list();
 7}

Esse tipo de reescrita a IA faz muito bem, e o teste de caracterização garante que a lista devolvida continua igual. Mas tenha critério: record é imutável, então se algum lugar do sistema chama setNome no objeto devolvido, a troca quebra. É o tipo de coisa que você pede para o agente verificar antes, com uma busca por todos os usos.

Vale o mesmo para os outros recursos novos. Pattern matching e sealed ajudam onde existe hierarquia de tipos com instanceof espalhado. Virtual threads ajudam em código que bloqueia muito em I/O. Nenhum deles precisa estar em todo lugar só porque existe.

Para extrair partes do monólito, o padrão continua sendo o strangler fig: um módulo novo assume uma fatia do comportamento, o tráfego migra aos poucos, o código antigo morre por último. A IA ajuda a sugerir onde cortar, mapeando quais classes conversam entre si e quais tabelas cada fluxo toca. A decisão de onde fica a fronteira é sua, porque depende de coisas que não estão no código: time, deploy, quem é dono de qual regra.

#Onde dá errado

Os problemas que aparecem quase sempre:

  • API alucinada: o modelo usa um método que existe em outra versão da biblioteca, ou que nunca existiu. O compilador pega a maioria. Os que passam costumam estar em código chamado por reflexão ou em configuração.
  • Mudança silenciosa de comportamento: o código compila, os testes unitários passam, e o resultado mudou. Os suspeitos clássicos são BigDecimal com escala diferente, equals e hashCode reescritos, fuso horário na troca de Date para java.time, e charset. Desde o Java 18 o charset padrão é UTF-8 em qualquer sistema operacional, então um new String(bytes) que rodava em Windows com Cp1252 passa a ler diferente. É exatamente para isso que existe a fase 2.
  • Ordem de iteração: código que dependia, sem saber, da ordem de um HashMap ou HashSet. A ordem nunca foi garantida, mas o código funcionava por coincidência e para de funcionar quando a implementação muda.
  • Diffs gigantes: já falei, mas repito porque é o erro mais comum. Agente com tarefa grande produz diff grande, e diff grande não é revisado.
  • Código proprietário saindo da empresa: mandar o código-fonte para uma API externa é decisão de compliance, não de desenvolvedor. Verifique o contrato do provedor (retenção de dados, uso para treino), e se o sistema processa dados pessoais, a LGPD entra na conversa. Muitas empresas resolvem com planos corporativos ou modelos rodando em nuvem própria.

#Por onde começar amanhã

  • Rode um agente em modo só leitura e gere a documentação dos módulos. Revise e guarde no arquivo de contexto do projeto.
  • Escolha os cinco fluxos que mais doem se quebrarem e escreva testes de caracterização para eles.
  • Confirme que esses testes passam no código atual.
  • Configure o OpenRewrite e rode rewrite:dryRun só para ver o tamanho do patch.
  • Migre um salto de versão por vez, com os testes entre cada um.
  • Use a IA no que sobrar das receitas, sempre em tarefas pequenas, com build e teste no loop.
  • Revise todo diff. Se está grande demais para revisar, a tarefa estava grande demais.
  • Só depois de estável na versão nova, modernize o código de verdade.

O trade-off no fim é simples. A IA reduz drasticamente o custo de entender e de reescrever, mas não reduz em nada o custo de errar em produção. Se você não tem como saber que algo quebrou, a velocidade da IA só te leva mais rápido para o problema. Invista o tempo economizado na rede de segurança, e a migração que antes levava um ano de medo cabe em alguns meses de trabalho controlado.

profile image of Lucien Risso Correia

Lucien Risso Correia

Arquiteto de IA. Levo LLMs a produção com RAG, agentes via MCP, avaliação automatizada e governança, sobre nove anos de arquitetura de software.

Mais posts de Lucien Risso Correia