Junior dev na era da IA: o que aprender para não ser substituído
1. Visão geral
Ferramentas de geração de código agora conseguem escrever controllers, entities ou testes de exemplo em poucos segundos. Por isso muitos iniciantes na profissão se preocupam que a posição de junior vai desaparecer. Este texto não faz previsões sobre o futuro. Ele se concentra em uma pergunta mais prática: quando digitar código deixa de ser a parte difícil, que habilidades você deve treinar?
Ao final, você terá uma lista de habilidades para priorizar. Cada uma vem com um exemplo Java que você pode executar agora. O texto também deixa claro quando não deve entregar trabalho para um assistente de código.
2. Por que essa pergunta está sendo feita agora
Antes, junior geralmente recebia tarefas repetitivas como escrever CRUD, mapear DTOs ou corrigir pequenos bugs. Essa era também a forma como aprendiam a profissão. Agora a maioria dessas tarefas pode ser gerada automaticamente. O custo de criar código caiu muito.
O custo de errar continua o mesmo. O código gerado pode compilar, parecer correto e passar em um teste simples, mas ainda estar errado em detalhes sutis: valores monetários, concorrência, transações, segurança. Quem é responsável quando o código vai para produção continua sendo você.
Por isso o valor está se movendo para três coisas:
- Avaliar se o código está certo ou errado e por quê.
- Verificar o comportamento com testes.
- Entender a runtime, o framework e o sistema no qual o código executa.
As seções seguintes cobrem cada uma dessas habilidades.
3. Ler e revisar código é melhor que escrever código rápido
A primeira habilidade é olhar para um trecho de código “que parece correto” e descobrir que está errado. Suponha que você peça a um assistente de código para escrever uma função que calcula o total de um pedido e receba isso:
import java.util.List;
record OrderLine(String product, double price, int quantity) {}
record Order(List<OrderLine> lines) {
double total() {
return lines.stream()
.mapToDouble(l -> l.price() * l.quantity())
.sum();
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(
new OrderLine("Caneta", 0.1, 1),
new OrderLine("Caderno", 0.2, 1)));
System.out.println(order.total());
}
}
O código é conciso e fácil de ler, mas o resultado é:
0.30000000000000004
double representa números em formato binário de ponto flutuante. Por isso 0.1 e 0.2 não são armazenados com precisão. Com moeda, esse erro se acumula ao longo de milhares de transações e vira uma discrepância contábil. A correção usa BigDecimal:
import java.math.BigDecimal;
import java.util.List;
record OrderLine(String product, BigDecimal price, int quantity) {
BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
record Order(List<OrderLine> lines) {
BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
Três detalhes para observar:
pricetem tipoBigDecimal. Você deve criar valores comonew BigDecimal("0.1"), nãonew BigDecimal(0.1).subtotal()multiplica o preço pela quantidade sem passar pordouble.reducecomeça comBigDecimal.ZEROentão um pedido vazio ainda retorna0em vez de lançar um erro.
Essa é exatamente a habilidade que as ferramentas ainda não conseguem substituir. Você precisa saber fazer perguntas sobre tipos de dados, valores extremos e casos vazios. Ao revisar, pergunte a si mesmo: “Qual entrada faria essa função falhar?”
4. Escrever testes como uma especificação
Sem testes, você não sabe se um trecho de código que recebeu está correto. Testes também são a forma mais clara de descrever o que você quer. Quanto mais clara a descrição, mais fácil é verificar se o código gerado ou escrito por colegas atende aos requisitos.
Com um projeto Spring Boot, você só precisa de uma dependência para ter JUnit 5 e AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Testes para a classe Order acima:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderTest {
@Test
@DisplayName("Total soma corretamente números decimais")
void totalIsExactForDecimals() {
var order = new Order(List.of(
new OrderLine("Caneta", new BigDecimal("0.1"), 1),
new OrderLine("Caderno", new BigDecimal("0.2"), 1)));
assertThat(order.total()).isEqualByComparingTo("0.3");
}
@Test
@DisplayName("Pedido vazio tem total igual a zero")
void emptyOrderTotalIsZero() {
var order = new Order(List.of());
assertThat(order.total()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("Total multiplica corretamente pela quantidade")
void totalMultipliesByQuantity() {
var order = new Order(List.of(
new OrderLine("Livro", new BigDecimal("12.50"), 3)));
assertThat(order.total()).isEqualByComparingTo("37.50");
}
}
Alguns detalhes que valem a pena aprender:
@DisplayNameregistra a regra de negócio em linguagem legível. Ler um relatório de testes é entender o que o sistema precisa fazer.isEqualByComparingTocompara pelo valor.new BigDecimal("0.3").equals(new BigDecimal("0.30"))retornafalseporque a escala é diferente. Esse é um erro muito comum.- Cada teste verifica exatamente um comportamento: caso normal, caso vazio, caso com multiplicação.
Atenção: Não deixe a ferramenta gerar tanto o código quanto os testes e achar que está tudo bem só porque vê verde. Se ambos entenderam errado o requisito, o teste ainda passa. Você deve escrever pelo menos os testes para as regras de negócio críticas.
5. Entender a runtime: concorrência não pode ser adivinhada
A maioria dos bugs difíceis só aparecem quando múltiplas threads rodam ao mesmo tempo. Em Spring, todo bean é singleton por padrão, significando que toda requisição usa a mesma instância. O código abaixo parece inofensivo:
import java.util.concurrent.Executors;
class ReportService {
private int processedCount = 0;
void process() {
processedCount++;
}
int processedCount() {
return processedCount;
}
}
public class CounterDemo {
public static void main(String[] args) {
var service = new ReportService();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(service::process);
}
}
System.out.println("Processados: " + service.processedCount());
}
}
Executors.newVirtualThreadPerTaskExecutor() do Java 21 cria uma virtual thread para cada tarefa. O bloco try-with-resources espera todas as tarefas terminarem antes de fechar. Esperamos ver 10000 impresso, mas na realidade geralmente vemos um número menor, e muda a cada execução:
Processados: 9871
O motivo é que processedCount++ não é uma operação atômica. Ela tem três passos: ler, somar e gravar. Duas threads podem ler o mesmo valor, fazendo uma soma desaparecer. A correção:
import java.util.concurrent.atomic.AtomicInteger;
class ReportService {
private final AtomicInteger processedCount = new AtomicInteger();
void process() {
processedCount.incrementAndGet();
}
int processedCount() {
return processedCount.get();
}
}
Dessa vez o resultado é sempre 10000. A lição mais ampla é: beans singleton não devem manter estado que muda por requisição. Quando revisar um @Service, procure por fields que não são final. Geralmente é aí que o bug está escondido.
Você não precisa memorizar toda a API de java.util.concurrent. Mas precisa entender conceitos como race condition, visibility e lock para reconhecer um erro quando o vê.
6. Entender o framework: saber como a “mágica” funciona
Spring ajuda você a escrever menos código usando annotations, em troca de muita coisa acontecendo nos bastidores. Se não entender o mecanismo, você será surpreendido. Um exemplo clássico é self-invocation com @Transactional:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
public void payAll(List<Long> paymentIds) {
paymentIds.forEach(this::pay);
}
@Transactional
public void pay(Long paymentId) {
var payment = paymentRepository.findById(paymentId).orElseThrow();
payment.markPaid();
paymentRepository.save(payment);
}
}
À primeira vista, cada chamada para pay rodaria em uma transação. Na verdade, não roda. Spring aplica @Transactional através de um proxy que envolve o bean. Quando payAll chama this.pay(...), a chamada vai direto para o objeto original e ignora o proxy. Portanto nenhuma transação é aberta.
Existem duas formas comuns de corrigir:
- Coloque
@Transactionalno próprio método que é chamado de fora, ou seja, empayAll, se você quer que todo o lote rode em uma transação. - Mova
paypara outro bean, comoSinglePaymentProcessor, e injete emPaymentServicepara que a chamada passe pelo proxy.
Entender o mecanismo de proxy ajuda a explicar muitos outros fenômenos: @Async não roda de forma assíncrona, @Cacheable não cacheia, ou annotations em métodos private são ignoradas. Esse é o conhecimento fundamental que ajuda a debugar mais rápido do que qualquer ferramenta de sugestão.
7. Observar o sistema em vez de adivinhar
Código que roda certo no seu computador não quer dizer que roda certo em produção. Um junior que sabe ler logs, métricas e health checks terá muito mais valor do que muitas pessoas que só sabem escrever features. Com Spring Boot, você começa com Actuator:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Depois configure os endpoints que você precisa:
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
logging:
level:
root: INFO
org.springframework.transaction: DEBUG
O significado de cada parte:
exposure.includeabre apenas três endpoints necessários. Abrir tudo com"*"é um erro de segurança comum.show-details: when-authorizedsó mostra detalhes de health para usuários autenticados.- Habilitar
DEBUGparaorg.springframework.transactionpermite que você veja nos logs quando uma transação abre e faz commit. Essa é a forma de verificar diretamente o bug de self-invocation da seção 6.
Uma verificação rápida:
curl http://localhost:8080/actuator/health
{"status":"UP"}
O hábito a desenvolver aqui é: quando há um erro, reúna evidências de logs, métricas e stack traces antes de corrigir. Não simplesmente tente trechos de código aleatoriamente sugeridos até o erro sumir por conta própria.
8. Usar assistente de código corretamente e quando não usar
Assistente de código é uma ferramenta boa. Evitá-lo não vai ajudá-lo a progredir mais. O importante é você manter o papel de quem decide. Um processo sensato tem quatro passos:
- Escreva você mesmo o requisito e os testes principais primeiro, como na seção 4.
- Peça a ferramenta uma sugestão de implementação.
- Leia cada linha. Se não conseguir explicar uma linha, não merge.
- Execute os testes, adicione testes para casos extremos que você pensou enquanto revisar.
Não deve entregar para o assistente, ou deve ser muito cuidadoso, nestes casos:
- Código de segurança: autenticação, autorização, criptografia, tratamento de tokens. Um pequeno erro aqui pode ter consequências grandes, e código errado geralmente se parece muito com código certo.
- Quando você está aprendendo um conceito novo. Se a ferramenta escrever o seu primeiro exercício sobre concorrência, você vai perder exatamente a parte mais importante do aprendizado.
- Dados sensíveis: não coloque secrets, informações de clientes ou código interno em ferramentas sem aprovação da empresa.
- Negócio específico que a ferramenta não tem contexto, como regras de imposto ou políticas de desconto. Pergunte primeiro quem entende do negócio.
Atenção: Um sinal de perigo é você fazer merge mais rápido do que consegue entender. Quando isso acontece, você não é mais um engenheiro, está só passando código adiante.
9. Roteiro sugerido para junior
Você não precisa aprender tudo ao mesmo tempo. Uma ordem sensata seria:
- Fundações da linguagem: tipos de dados, collections,
equals/hashCode, exceptions, concorrência básica. - Testes: JUnit 5, AssertJ, como nomear testes por comportamento, testar casos extremos.
- Framework por dentro: injeção de dependência, ciclo de vida do bean, proxy, transações.
- Dados: SQL de verdade, índices, isolamento de transações, problema N+1 queries com JPA.
- Operação: ler logs, métricas, health checks, debugar em ambiente parecido com produção.
- Comunicação: transformar requisitos vagos em critérios de aceite claros, escrever descrições de pull request que facilitam review.
A última habilidade é geralmente subestimada. Alguém que sabe fazer as perguntas certas, como “e se o pedido estiver vazio?” ou “e se duas requisições chegarem ao mesmo tempo?”, sempre será necessário, não importa quem ou o que escreve o código.
10. Conclusão
Neste texto, vimos que o valor do junior dev está se movendo de velocidade em digitar código para capacidade de avaliar, verificar e entender o sistema. Os exemplos ilustram erros que código “que parece correto” ainda comete: imprecisão de double com moeda, race condition em beans singleton, e @Transactional sendo ignorado por self-invocation. Você também viu como usar testes e Actuator para verificar por conta própria, em vez de simplesmente adivinhar.
Use o assistente de código como um colega que escreve rápido mas erra bastante. Você aproveita a velocidade dele, mas sempre lê de novo, escreve testes e entende cada linha antes de fazer merge. É assim que você não é substituído e se torna alguém que o time não consegue ficar sem.