Capítulo 01 Fundamento
Porque Java, e quando não
A linguagem mais fácil de escolher pelas razões erradas — e a mais fácil de descartar pelas razões desactualizadas.
1.1 O argumento honesto
Java não é escolhido por ser elegante. É escolhido por três coisas que quase nenhuma alternativa oferece ao mesmo tempo:
- Compatibilidade que raia o absurdo. Um jar compilado em 2011 corre hoje. Nenhuma outra plataforma grande leva a compatibilidade tão a sério, e é isso que sustenta sistemas com vinte anos que ninguém pode dar-se ao luxo de reescrever.
- Um tempo de execução que se explica sozinho. A JVM sabe dizer onde gastou memória, que thread ficou presa, que método aqueceu. Isso está incluído, não é um produto à parte.
- Quantidade de gente. É a pilha onde é mais fácil contratar, e onde é mais provável que o problema que tem já tenha resposta escrita por alguém.
Nada disto é entusiasmante. Tudo isto importa mais aos cinco anos de um sistema do que aos cinco meses — e é exactamente por isso que Java domina onde o horizonte é longo: banca, seguros, saúde, logística, retalho, administração pública.
Quase todas as críticas populares a Java atacam código escrito contra restrições que já não existem: a fábrica abstracta, os 200 ficheiros de XML, o DTO com oitenta linhas de getters. Essas coisas foram racionais quando a linguagem não tinha alternativa. Hoje há.
É a tese desta apostila: o problema do Java nunca foi a linguagem — é que a maior parte do Java que vai encontrar foi escrita contra restrições levantadas há anos, e nem os manuais nem as bases de código acompanharam. O ofício está em distinguir a cerimónia que ainda segura alguma coisa da que é só entulho.
1.2 A cadência, que decide mais do que parece
Sai uma versão de seis em seis meses, em Março e Setembro. De duas em duas anos, uma delas é LTS — a que recebe actualizações durante anos e a única que faz sentido pôr em produção num sistema que ninguém quer tocar todos os semestres.
| Versão | Disponibilidade | Estatuto |
|---|---|---|
| Java 17 | Setembro de 2021 | LTS — ainda é a base mínima de muita coisa |
| Java 21 | Setembro de 2023 | LTS — trouxe as threads virtuais |
| Java 24 | Março de 2025 | Trouxe a correcção que muda o capítulo 4 |
| Java 25 | Setembro de 2025 | LTS actual — o alvo por omissão |
| Java 26 | Março de 2026 | Disponível; não-LTS |
Compile para a LTS actual. Não use uma não-LTS em produção a menos que precise de uma funcionalidade concreta que só lá exista — e saiba que terá de migrar dentro de seis meses.
O erro caro não é ficar na versão nova. É ficar numa LTS antiga por inércia até que a distância se torne um projecto em vez de uma actualização. Quem salta de 8 para 25 de uma vez paga tudo junto; quem salta de LTS em LTS paga a prestações.
1.3 Quando não escolher Java
Uma apostila que só diz bem da sua matéria não é útil. Casos em que a escolha é outra:
| Situação | Porquê, e o que costuma ganhar |
|---|---|
| Função efémera, invocada aos picos e desligada | O arranque da JVM é pago em cada invocação. Go ou uma linguagem de arranque instantâneo ganha — a menos que compile para nativo (cap. 11.3) |
| Script ou automação de uma folha | A cerimónia mínima continua a ser maior que a de Python. Veja Python Essencial |
| Análise de dados, exploração, notebooks | O ecossistema não está aqui. Veja Análise de Dados |
| Controlo fino de memória, sem pausas toleráveis | A recolha de lixo é uma característica, não um defeito — mas é uma característica. Veja Rust |
| Equipa pequena, produto por validar, prazo curto | A vantagem do Java está nos anos seguintes, e pode não haver anos seguintes |
Repare no padrão: Java troca arranque lento e cerimónia inicial por previsibilidade a longo prazo. Quando não há longo prazo, está a pagar sem receber.
1.4 O que esta apostila é
Não é uma referência da linguagem — essa existe e é melhor que qualquer apostila. É o percurso do que distingue quem escreve Java que se aguenta de quem escreve Java que funciona no primeiro dia: o que a linguagem passou a permitir, o que a JVM exige que se saiba, o que o Spring está a decidir por si, e as três ou quatro armadilhas que aparecem sempre em produção e nunca no manual.
Pressupõe que já programa em alguma linguagem. Não pressupõe Java.
Num projecto Java a que tenha acesso, responda a três perguntas: que versão declara o ficheiro de compilação, que versão corre em produção, e há quanto tempo saiu a LTS seguinte? A distância entre a segunda e a terceira é a sua dívida de plataforma, e vale a pena saber o número antes de alguém perguntar.
Capítulo 02 Linguagem
A linguagem mudou por baixo dos manuais
Quatro adições que apagaram a maior parte da cerimónia pela qual Java é conhecido — e a pergunta de qual cerimónia ainda segura alguma coisa.
2.1 record: o fim do objecto de dados com oitenta linhas
O DTO clássico — campos privados, construtor, getters, equals,
hashCode, toString — era cinquenta a cem linhas para dizer «isto tem três
coisas lá dentro». Hoje:
// tudo o que estava acima, numa linha
public record Pedido(Long id, String cliente, BigDecimal total) {}
O compilador gera o construtor, os acessores, equals, hashCode e
toString. É imutável por omissão, que é a parte que interessa: um
record não pode ser alterado por engano três camadas abaixo.
record encaixa, e onde não
Encaixa: tudo o que atravessa uma fronteira — corpos de pedido e resposta, eventos, resultados de consulta, chaves de mapa, valores de configuração.
Não encaixa: entidades JPA, que precisam de construtor sem argumentos e de mutabilidade
(cap. 8). É a excepção que mais confunde quem descobre record e o quer usar em tudo.
Validação no sítio certo: um record pode ter construtor compacto, e é aí que se
rejeita o valor impossível — não em cada método que o recebe.
public record Pedido(Long id, String cliente, BigDecimal total) {
public Pedido {
if (total.signum() < 0)
throw new IllegalArgumentException("total negativo: " + total);
}
}
2.2 sealed e correspondência de padrões: a alternativa à hierarquia aberta
A herança tradicional deixa qualquer pessoa estender a sua classe. sealed permite dizer
exactamente quem pode — e, a partir daí, o compilador sabe que a lista está completa:
sealed interface Pagamento permits Cartao, Transferencia, Referencia {}
record Cartao(String ultimos4, int parcelas) implements Pagamento {}
record Transferencia(String iban) implements Pagamento {}
record Referencia(String entidade, String numero) implements Pagamento {}
// sem "default": se acrescentar um quarto tipo, ISTO DEIXA DE COMPILAR
String recibo(Pagamento p) {
return switch (p) {
case Cartao c -> "Cartão terminado em " + c.ultimos4();
case Transferencia t -> "Transferência de " + t.iban();
case Referencia r -> "Referência " + r.entidade() + " / " + r.numero();
};
}
O ganho não é escrever menos. É que acrescentar um caso passa a partir a compilação em todos
os sítios que precisam de saber. Com um default ou com polimorfismo clássico, o
caso novo cai silenciosamente no ramo genérico e o erro aparece em produção.
A regra: quando o conjunto de variantes é fechado e conhecido — meios de pagamento,
estados de encomenda, tipos de evento — sealed + switch sem
default transforma um erro de execução num erro de compilação. É a troca mais barata que
a linguagem oferece.
2.3 O resto do ruído que desapareceu
| Antes | Agora |
|---|---|
Map<String, List<Pedido>> m = new HashMap<>(); | var m = new HashMap<String, List<Pedido>>(); |
SQL ou JSON concatenado com + e \n | Blocos de texto com três aspas, com a formatação preservada |
Ciclo for com acumulador e if | stream().filter(...).map(...).toList() |
if (o instanceof Pedido) { Pedido p = (Pedido) o; ... } | if (o instanceof Pedido p) { ... } |
var passa de ajuda a estorvo
var é bom quando o tipo já está visível do lado direito
(var lista = new ArrayList<Pedido>()). É mau quando o esconde
(var r = servico.processar(x) — e agora ninguém sabe o que r é sem abrir
outro ficheiro). A pergunta não é «poupa caracteres», é «quem lê isto daqui a um ano sabe o
que está aqui dentro?».
2.4 A cerimónia que ainda segura, e a que é entulho
| Prática | Veredicto |
|---|---|
| Interface com uma implementação, criada «para o caso de» | Entulho. Extrai-se a interface no dia em que houver a segunda |
| Interface com duas ou mais, ou fronteira que quer trocar em teste | Segura. É para isto que serve |
| Getters e setters em objectos de dados | Entulho. Use record |
Campos private final e construtor que os preenche todos | Segura. É o que torna um objecto impossível de construir pela metade |
| Camada de mapeamento entre entidade e resposta da API | Segura, e mais do que parece — sem ela, mudar uma coluna muda o contrato público (cap. 7.1) |
| Configuração em XML | Entulho, em código novo. Em código antigo, é trabalho a fazer, não uma emergência |
| Excepções verificadas em toda a assinatura | Depende. Úteis na fronteira onde quem chama pode reagir; ruído puro quando são só reempacotadas |
Escolha a classe de dados mais longa do seu projecto e converta-a em record. Conte as
linhas antes e depois, e — mais importante — conte quantos sítios deixaram de conseguir
alterá-la. É esse o número que interessa: cada um deles era uma alteração possível que agora
é impossível.
Capítulo 03 Operação
A JVM como facto operacional
Quatro coisas que quem escreve Java tem de saber sobre a máquina onde ele corre — porque nenhuma delas aparece no código e todas aparecem às três da manhã.
3.1 A memória do processo não é a memória do heap
É a confusão que mais reinícios causa em contentores. O heap — onde vivem os objectos — é apenas uma parte do que a JVM consome:
Dá 512 MB de -Xmx a um contentor com 512 MB de limite. O heap nunca ultrapassa
os 512 MB, portanto nunca vê um OutOfMemoryError — mas o processo inteiro
passa dos 512 MB por causa de tudo o resto, e o orquestrador mata-o. Nos registos aparece um
encerramento sem causa, ou um código 137.
A correcção: não fixe -Xmx em contentores. Use
-XX:MaxRAMPercentage (algo entre 60 e 75) e deixe a JVM ler o limite do contentor. As JVMs
modernas sabem que estão num contentor; o problema é quase sempre uma opção escrita à mão em 2016 que
ninguém voltou a rever.
3.2 O recolector de lixo: quando mexer (quase nunca)
| Recolector | Optimiza | Quando |
|---|---|---|
| G1 (omissão) | Equilíbrio entre pausa e débito | Fique aqui. É a resposta certa para quase tudo |
| ZGC | Pausas muito curtas, mesmo com heaps grandes | Heap grande e latência de cauda que importa mesmo |
| Serial | Consumo mínimo | Contentores pequenos, um só núcleo |
Regra: mudar de recolector ou afinar as suas opções é das últimas coisas a tentar, não das primeiras. A esmagadora maioria dos problemas de pausa é lixo a mais a ser criado — e isso corrige-se no código, não na linha de comando. Um recolector afinado a esconder alocação desnecessária é uma dívida com juros.
3.3 Arranque lento, regime rápido — e o que isso implica
A JVM começa por interpretar, e vai compilando para código nativo o que vê ser usado. Consequência: a mesma função é dez vezes mais lenta nos primeiros segundos do que dez minutos depois.
1 · Testes de carga sem aquecimento medem ficção. Descarte o primeiro minuto, sempre.
2 · Sondas de arranque precisam de folga. Uma sonda que declare a aplicação morta ao fim de 10 segundos entra em ciclo de reinícios sem que nada esteja avariado (cap. 11.5).
3 · Escalar sob pico chega tarde. A instância nova entra fria e responde pior justamente quando é mais precisa. Ou escala com antecedência, ou vai para nativo (cap. 11.3).
3.4 A observabilidade que já pagou e quase ninguém usa
O Flight Recorder vem incluído na JVM e grava alocações, pausas do recolector, contenção de bloqueios, E/S e excepções, com um custo em regime da ordem de um por cento:
# gravar 60 segundos de um processo já a correr
jcmd <pid> JFR.start duration=60s filename=perfil.jfr
# e, no momento em que algo corre mal:
jcmd <pid> Thread.print # onde está cada thread, agora
jcmd <pid> GC.heap_info # estado do heap
Porque é que isto importa mais do que parece: na maior parte das pilhas, descobrir «que thread está bloqueada e à espera de quê» exige instalar alguma coisa antes de o problema acontecer. Aqui não exige. É a vantagem mais subestimada da plataforma, e existe desde antes de metade dos manuais que a ignoram.
Numa aplicação Java a correr, execute jcmd <pid> Thread.print e leia o resultado com
calma enquanto está tudo bem. Identifique as threads do servidor web, as do
reservatório de ligações e as do recolector. Fazer isto uma vez em calma poupa vinte minutos na noite
em que tiver de o fazer a correr.
Capítulo 04 Linguagem
Threads virtuais, e o conselho que caducou
A maior mudança de modelo em vinte anos de Java — e o facto de metade do que se escreveu sobre ela nos primeiros dois anos já estar errado.
4.1 O problema que resolvem
Uma thread de plataforma é uma thread do sistema operativo: custa cerca de um megabyte e alguns milissegundos a criar. Por isso ninguém cria uma por pedido — cria-se um reservatório de duzentas e reutilizam-se. E daí vem tudo:
Uma thread virtual é gerida pela JVM, custa alguns kilobytes, e quando bloqueia em E/S liberta a thread do sistema em vez de a ocupar. Podem existir milhões. O modelo passa a ser uma thread por pedido, que é o modelo que sempre foi mais fácil de ler — código sequencial, pilhas de chamadas que fazem sentido, depuração normal.
No Spring Boot, activar é uma linha:
# application.properties
spring.threads.virtual.enabled=true
Durante os primeiros anos, todos os textos diziam o mesmo: «substitua
synchronized por ReentrantLock, senão a thread virtual fica presa à
thread de plataforma». Era verdade — e deixou de ser.
A partir do Java 24, uma thread virtual desmonta-se normalmente ao
bloquear num monitor. O synchronized deixou de ser o problema, e a reescrita defensiva
que muita gente fez em 2023 e 2024 deixou de ter razão de ser.
O que ainda prende, e é bom saber porque é curto: bloquear dentro de código nativo (JNI), e bloquear durante o carregamento ou a inicialização de classes. Fora disso, é raro.
A lição maior que o facto: este assunto tem dois anos e já mudou de resposta. Numa área assim, a data do texto que está a ler é informação tão importante como o conteúdo.
4.2 A armadilha que não é a que avisam
O reservatório de threads não estava só a limitar threads. Estava, sem ninguém decidir isso, a limitar tudo o que estava a jusante:
Ao ligar threads virtuais, o limite tem de mudar de sítio, não desaparecer. Passa para onde sempre devia ter estado: um semáforo à frente de cada dependência lenta, o tamanho do reservatório de ligações à base de dados, um limitador por serviço externo.
O reservatório de threads era um limitador de carga acidental. Tirá-lo sem pôr um limitador deliberado no lugar não torna o sistema mais rápido — torna-o mais rápido a saturar outra coisa. É exactamente o que acontece no passo 6 da demo do capítulo 9.
4.3 Quando não valem a pena
| Perfil da aplicação | Ganho esperado |
|---|---|
| Muitas chamadas a bases de dados e a outros serviços (a maioria das aplicações web) | Grande, se os limites forem reposicionados |
| Cálculo intensivo, pouca espera | Nenhum. O limite é o processador, e as threads virtuais não criam processadores |
| Já em programação reactiva e a funcionar | Pouco — mas simplifica muito o código que vier a seguir |
Uso intenso de ThreadLocal | Cuidado: com milhões de threads, o que custava pouco por thread passa a custar muito no total |
Antes de ligar threads virtuais em qualquer sítio, escreva a lista dos recursos limitados que a sua aplicação toca: ligações à base de dados, quotas de APIs externas, ficheiros, memória por pedido. Ao lado de cada um, o limite actual e quem o impõe. Se a resposta for «o reservatório de threads», acabou de encontrar o que se vai partir.
Capítulo 05 Framework
Spring Boot: tornar a magia visível
A auto-configuração é a razão pela qual o Spring é produtivo e a razão pela qual assusta. As duas coisas resolvem-se com o mesmo comando.
5.1 O que a auto-configuração realmente faz
Não há magia. Há centenas de classes de configuração, cada uma protegida por condições que o Spring avalia no arranque. Todas se resumem à mesma pergunta:
Pôr o starter de JDBC no projecto faz aparecer no classpath uma classe de
reservatório de ligações. A condição «existe essa classe e o utilizador não definiu um
DataSource» passa a verdadeira, e o Spring cria um. Foi a dependência que
configurou a aplicação — é esse o modelo mental inteiro.
Ninguém tem de adivinhar o que a auto-configuração decidiu. Ela publica um relatório:
# application.properties
debug=true
No arranque, sai o relatório de condições com três listas: o que foi configurado e porquê, o que não foi e qual condição falhou, e o que foi excluído à mão.
A lista do meio é a que resolve problemas. «Porque é que o meu DataSource não
aparece?» tem ali a resposta escrita por extenso, em vez de uma tarde de tentativa e erro.
5.2 Substituir em vez de lutar
Como quase todas as condições incluem «e o utilizador ainda não definiu um», a forma de sair da omissão é definir o seu — não desligar nada:
@Bean
DataSource dataSource() {
// a partir daqui, a auto-configuração do DataSource desiste sozinha
return ...;
}
Desligar explicitamente (com exclude) é a excepção, não a regra — e quando aparece em
muitos sítios costuma ser sinal de que se está a lutar contra o framework em vez de o usar.
5.3 As versões, e porque as fixa uma só linha
| Peça | Estado verificado, Setembro de 2026 |
|---|---|
| Spring Boot | 4.1.x (4.0 saiu em Novembro de 2025; 4.1 em Junho de 2026) |
| Spring Framework | 7.0.9 ou superior |
| Java | Mínimo 17; suportado até ao 26 |
| Maven / Gradle | 3.6.3+ / 8.14+ ou 9.x |
| Servidor embutido | Tomcat 11.0.x ou Jetty 12.1.x (Servlet 6.1) |
| Nativo | GraalVM 25 ou superior |
Repare na terceira linha: o mínimo é o Java 17, não a LTS actual. É deliberado — o Spring não obriga a empresa a saltar de versão de Java para receber correcções. Mas «suportado» não é «recomendado»: as threads virtuais do capítulo 4 exigem 21, e a correcção que muda o conselho exige 24.
O parent ou o bill of materials do Spring Boot traz uma lista curada de versões compatíveis de tudo — Jackson, Hibernate, Tomcat, Micrometer, controladores de base de dados. Declara-se a dependência sem versão e herda-se a que foi testada em conjunto.
Fixar uma versão à mão é uma das causas mais comuns de avarias inexplicáveis num projecto Spring, e a mais fácil de evitar: se precisa mesmo de outra versão, mude a propriedade que o parent expõe, em vez de a escrever na dependência.
5.4 Perfis e propriedades
# application.properties — comum a tudo
spring.application.name=pedidos
# application-dev.properties — só no perfil dev
# application-prod.properties — só no perfil prod
A ordem de precedência tem uma dúzia de níveis, mas o que interessa é a ponta: argumentos da linha de comando e variáveis de ambiente ganham sempre aos ficheiros. É isso que permite ter a mesma imagem de contentor em todos os ambientes, com a configuração injectada de fora (cap. 11.4).
Um perfil prod com lógica de negócio diferente do dev significa que o que
testou não é o que corre. Perfis são para endereços, credenciais e níveis de registo —
não para comportamento. No dia em que um if (perfil == prod) aparecer no código de
negócio, é aí que os erros irreproduzíveis começam.
Ligue debug=true numa aplicação Spring Boot que conheça e leia a lista de condições que
falharam. Escolha três e perceba porquê. É a forma mais rápida de transformar «o Spring faz
coisas» em «o Spring avaliou estas condições e tomou estas decisões».
Capítulo 06 Framework
Injecção de dependências sem misticismo
Uma ideia simples, três formas de a escrever, e apenas uma que não esconde problemas.
6.1 A ideia, em duas linhas
Uma classe não constrói aquilo de que depende; recebe-o. O Spring mantém um registo de objectos geridos e, ao construir cada um, entrega-lhe o que ele pede. É só isto.
6.2 As três formas, e a que se usa
Por construtor — a certa
@Service
class ServicoPedidos {
private final RepoPedidos repo;
private final Pagamentos pag;
ServicoPedidos(RepoPedidos repo,
Pagamentos pag) {
this.repo = repo;
this.pag = pag;
}
}
Campos final, objecto impossível de construir incompleto, e testável sem Spring
nenhum: new ServicoPedidos(a, b).
Por campo — a armadilha
@Service
class ServicoPedidos {
@Autowired
private RepoPedidos repo;
@Autowired
private Pagamentos pag;
}
Mais curta, e pior em tudo o resto. Os campos não podem ser final, e num teste
new ServicoPedidos() devolve um objecto com tudo a null.
Não é estética. É que deixa de haver pressão sobre o número de dependências. Um
construtor com nove parâmetros incomoda quem o lê e obriga a repensar a classe. Nove
@Autowired passam despercebidos, e a classe cresce até ninguém saber o que faz.
O construtor é o sítio onde o desenho se defende sozinho. É por isso que se usa — o teste fácil é uma consequência agradável, não a razão.
Com um só construtor, o Spring injecta sem sequer precisar de @Autowired.
6.3 Âmbitos, e o erro que daí vem
| Âmbito | Significa |
|---|---|
singleton (omissão) | Uma instância partilhada por toda a aplicação |
prototype | Uma nova em cada pedido de injecção |
request, session | Uma por pedido HTTP ou por sessão |
Como a omissão é uma instância partilhada, qualquer estado mutável num
@Service é partilhado por todos os pedidos em simultâneo:
@Service
class ServicoRelatorio {
private List<Linha> linhas = new ArrayList<>(); // ← partilhado por TODOS
public Relatorio gerar(Pedido p) {
linhas.clear(); // ← limpa o de outra pessoa
...
}
}
Funciona perfeitamente em desenvolvimento, onde há um pedido de cada vez. Parte-se em produção, de
forma intermitente e impossível de reproduzir. Regra: um @Service não tem estado.
Se precisa de estado, ele vive nos parâmetros do método ou numa variável local.
Com threads virtuais (cap. 4), esta classe de erro aparece mais depressa — há muito mais pedidos verdadeiramente em simultâneo.
6.4 Quando o Spring se queixa de um ciclo
A resolver A → B → A, o Spring desiste com um erro. A tentação é partir o ciclo com
@Lazy ou uma propriedade que o permita. É quase sempre a resposta errada:
um ciclo é o framework a detectar um problema de desenho que já lá estava.
As duas saídas boas: extrair para uma terceira classe a parte de que ambas precisam, ou inverter a dependência com um evento. A saída má é silenciar o aviso e ficar com duas classes que não podem existir uma sem a outra e ninguém consegue testar em separado. Se isto lhe aparece com frequência, Arquitetura e System Design trata do problema de fundo.
Procure no seu projecto campos não-final em classes anotadas com @Service,
@Component ou @RestController. Cada um é um candidato a erro de concorrência.
A maioria será inofensiva — mas a maioria não é toda, e esta é a busca mais rentável
que pode fazer num projecto Spring que nunca a tenha feito.
Capítulo 07 Prática
A camada web: fronteira, não depósito
O controlador é o sítio onde entra o que não é de confiança. Tudo o que ele faz além de traduzir é trabalho no sítio errado.
7.1 O controlador magro, e porquê
@RestController
@RequestMapping("/pedidos")
class ControladorPedidos {
private final ServicoPedidos servico;
ControladorPedidos(ServicoPedidos servico) { this.servico = servico; }
@GetMapping("/{id}")
RespostaPedido porId(@PathVariable Long id) {
return RespostaPedido.de(servico.porId(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
RespostaPedido criar(@Valid @RequestBody CriarPedido corpo) {
return RespostaPedido.de(servico.criar(corpo));
}
}
Três responsabilidades e nenhuma mais: receber, validar, traduzir. A regra a fio de
prumo — se o método tem um if de negócio, está no ficheiro errado.
Devolver a entidade JPA da base de dados é a tentação mais forte e a decisão que mais custa depois. Três razões:
1 · O esquema passa a ser o contrato. Renomear uma coluna quebra clientes que não controla.
2 · Fugas. Um campo acrescentado à entidade aparece na resposta sem ninguém decidir isso — e é assim que hashes de palavras-passe e notas internas acabam em respostas públicas.
3 · Consultas surpresa. A serialização toca associações preguiçosas e dispara consultas na camada web, fora da transacção (cap. 8.3).
É a «cerimónia» do capítulo 2.4 que segura. Um record de resposta com
um método de conversão custa dez linhas e evita as três.
7.2 Validar na fronteira
record CriarPedido(
@NotBlank String cliente,
@NotEmpty List<@Valid LinhaPedido> linhas,
@Positive BigDecimal total) {}
Com @Valid no parâmetro, o Spring rejeita antes de chamar o serviço. O ganho: o serviço
deixa de precisar de verificar se o cliente está preenchido — o tipo já garante isso. É
a mesma ideia do record com construtor compacto do capítulo 2.1, aplicada à entrada HTTP.
7.3 Erros: um formato só, e normalizado
Sem tratamento central, cada erro sai de uma maneira: uns em JSON, outros em HTML, alguns com a pilha de chamadas exposta. O tratador global resolve isso, e o formato certo já é norma — Problem Details for HTTP APIs (RFC 7807):
@RestControllerAdvice
class TratadorErros {
@ExceptionHandler(PedidoNaoExiste.class)
ProblemDetail naoExiste(PedidoNaoExiste e) {
var pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Pedido não encontrado");
pd.setDetail(e.getMessage());
return pd;
}
}
Nunca deixe uma excepção não tratada chegar ao cliente. A mensagem por omissão de muitas excepções contém nomes de tabelas, caminhos de ficheiros e versões de bibliotecas — um mapa para quem está a sondar. Registe o detalhe com um identificador; devolva o identificador e mais nada.
Desenho de contrato em profundidade está em API Design.
7.4 Duas coisas que o Spring Boot 4 trouxe e vale a pena conhecer
Versionamento de API deixou de ser artesanal. Há configuração automática para ele, tanto em MVC como em WebFlux, com propriedades dedicadas — em vez de cada equipa inventar o seu esquema de prefixos ou cabeçalhos.
Clientes HTTP declarativos: declara-se uma interface e o Spring implementa-a para chamar o serviço remoto:
interface ClientePagamentos {
@PostExchange("/cobrancas")
Cobranca cobrar(@RequestBody PedidoCobranca p);
}
É a mesma ideia que os repositórios de dados aplicam há anos: declarar a forma, deixar o framework escrever o corpo. O ganho real é ter um tipo em vez de uma cadeia de texto a montar um pedido — e, com isso, um sítio óbvio para pôr tempos limite e um teste.
Faça um pedido inválido à sua API e olhe para a resposta como se fosse um atacante. Que nomes de tabelas, caminhos ou versões consegue extrair? Se conseguir algum, tem trabalho no capítulo 7.3 — e é trabalho de meia hora.
Capítulo 08 Crítica
Dados: o que a JPA esconde
A camada que mais produtividade dá no primeiro mês e mais problemas de desempenho causa no primeiro ano — sempre pelas mesmas três razões.
8.1 O modelo mental que falta
A JPA faz parecer que se está a mexer em objectos. Não se está — está-se a dar instruções a uma coisa
que emite SQL mais tarde, e mais SQL do que se pensa. Enquanto a transacção está aberta,
as entidades estão geridas: o que lhes muda é registado e escrito no fim, sem ninguém chamar
save. Fora dela, são objectos vulgares.
Quase tudo o que surpreende em JPA cabe numa frase: o SQL não sai onde está escrito o código.
8.2 O N+1, que é responsável pela maioria dos casos
List<Pedido> pedidos = repo.findAll(); // 1 consulta
for (Pedido p : pedidos) {
System.out.println(p.getCliente().getNome()); // +1 consulta CADA
}
Com 47 pedidos: 48 consultas em vez de uma. Cada uma custa pouco; juntas, custam a página. Em desenvolvimento, com três registos de teste, é invisível.
# mostra o SQL que sai mesmo
spring.jpa.show-sql=true
logging.level.org.hibernate.orm.jdbc.bind=TRACE # e os parâmetros
Ligue isto uma vez, carregue uma página real e conte as linhas. É o diagnóstico mais rentável de toda esta apostila, e leva dois minutos. Em produção, um contador de consultas por pedido nas métricas dá o mesmo sinal de forma contínua.
A correcção é dizer, na consulta, o que se vai precisar — com join fetch ou um grafo
de entidade. É uma decisão por consulta, não uma definição global, e é aí que a
maioria dos artigos engana: mudar a estratégia na entidade corrige um sítio e estraga dez.
8.3 Carregamento preguiçoso e o momento em que a sessão fecha
Uma associação preguiçosa não traz dados; traz uma promessa. Se essa promessa for tocada
depois de a transacção fechar — tipicamente durante a serialização da resposta —
rebenta com LazyInitializationException.
Há uma opção que faz o erro desaparecer mantendo a sessão aberta durante a resposta. É a pior correcção possível: esconde o sintoma, mantém ligações à base de dados ocupadas durante a escrita da resposta HTTP, e transforma um erro claro numa fuga lenta de reservatório de ligações.
A correcção real é a do 7.1: converter para um objecto de resposta dentro da transacção. Aí, o que não foi carregado nunca é tocado depois, e o erro deixa de poder existir.
8.4 Transacções: onde acabam
| Dentro da transacção | Fora |
|---|---|
| Ler e escrever na base de dados | Chamadas HTTP a outros serviços |
| Cálculo curto sobre o que leu | Envio de mensagens e de e-mail |
| Validação que precisa de dados | Escrita de ficheiros, chamadas a APIs externas |
Uma transacção segura uma ligação à base de dados do princípio ao fim. Uma chamada HTTP dentro dela segura essa ligação durante o tempo de rede de outro sistema — que pode ser lento, ou estar em baixo.
Com um reservatório de dez ligações e uma chamada de 240 ms lá dentro, chegam pouco mais de quarenta pedidos por segundo para esgotar o reservatório. A partir daí toda a aplicação fica lenta, inclusive as páginas que não tocam nesse serviço — e é por isso que o sintoma quase nunca aponta para a causa. É exactamente o passo 4 da demo do capítulo seguinte.
Detalhe que apanha muita gente: @Transactional num método chamado de dentro da
própria classe não faz nada. A anotação funciona por proxy, e uma chamada interna não
passa pelo proxy.
8.5 Quando descer ao SQL
A JPA é boa a carregar e gravar agregados por identificador. É má em relatórios, agregações sobre muitas linhas e escritas em massa. Nesses casos, o caminho não é lutar com ela — é usar SQL directamente para essa consulta e manter a JPA no resto.
Não é derrota nem inconsistência. É reconhecer que são ferramentas diferentes: uma tem o modelo de domínio, a outra tem o poder da base de dados. Quem escolhe bases de dados por carga encontra o assunto em NoSQL e Bases de Dados Modernas.
Ligue o registo de SQL, abra a página mais pesada da sua aplicação e conte as consultas de um só pedido. Escreva o número. Se passar de dez, tem um N+1 — e agora sabe onde procurar.
Capítulo 09 Método aplicado · Demo
Um endpoint a 800 ms, desmontado
Role devagar. Seis medições sobre o mesmo GET /pedidos/{id} — e a descoberta de que a última "melhoria" piorou tudo.
Processador a 12%, latência a 800 ms
A equipa propõe duplicar o reservatório de threads. É a resposta reflexa, e vai custar memória sem mudar nada — o processador a 12% já diz que ninguém está a calcular; estão todos à espera. Antes de mexer, medir.
48 consultas num só pedido
Duas linhas de configuração e um pedido chegam. Um pedido com 47 linhas dispara uma consulta para o pedido e uma por cada linha, para ir buscar o produto. Em desenvolvimento, com três registos de teste, isto é invisível — são quatro consultas rápidas.
48 consultas passam a 2, e o p95 cai 46%
Dizer na consulta o que se vai precisar resolve o N+1. De 800 para 430 ms. É um bom resultado e seria fácil parar aqui — e parar aqui deixaria mais de metade do problema por resolver.
Outro endpoint começa a falhar
Se a correcção tivesse sido feita na entidade — mudar a associação para carregamento ansioso — corrigia esta página e carregava dados a mais em todas as outras. Feita na consulta, corrige só onde é preciso. O carregamento é uma decisão por consulta, e a excepção que apareceu é o aviso de que alguém a tomou no sítio errado.
Só 60 dos 430 ms são trabalho
240 ms são uma chamada HTTP ao serviço de pagamentos, feita dentro da transacção. E 130 ms são espera pura por uma ligação livre: com dez ligações, cada uma retida 300 ms, bastam cerca de 33 pedidos por segundo para formar fila. A latência que mais dói é a que não aparece em lado nenhum do código — não está escrita, é consequência.
300 ms — e as outras páginas também melhoram
Fechada a transacção antes da chamada externa, cada ligação fica presa 60 ms em vez de 300. A fila desaparece. E páginas que nunca tocaram em pagamentos ficam rápidas, porque também elas esperavam por ligações que este endpoint monopolizava. É a assinatura deste tipo de avaria: o sintoma aparece longe da causa.
As threads virtuais pioraram o p95
De 300 para 380 ms. O reservatório de 200 threads não estava só a limitar threads — estava a limitar quantos pedidos chegavam à base de dados e ao serviço de pagamentos ao mesmo tempo. Ninguém decidiu isso; era um efeito lateral que segurava o sistema. Removido sem pôr um limitador deliberado no lugar, a carga foi toda para o recurso seguinte.
1 · Medir custa dois minutos e a intuição custa uma semana. A proposta inicial — mais threads — teria gasto memória para piorar o passo 7 mais cedo.
2 · O sintoma aparece longe da causa. Páginas sem relação nenhuma com pagamentos estavam lentas por causa de pagamentos.
3 · A correcção certa no sítio errado é uma avaria nova. Passo 4.
4 · Nenhum destes seis passos é sobre Java. São sobre recursos limitados, filas e onde os limites estão. A linguagem só decide como se escreve a correcção.
Escolha o endpoint mais lento que tenha e decomponha o tempo como no passo 5: trabalho real, chamadas externas, espera por recursos. Se não conseguir decompor, é essa a primeira tarefa — e é mais valiosa do que qualquer optimização que fizesse às cegas.
Capítulo 10 Prática
Testar sem levantar o mundo
O Spring facilita tanto levantar a aplicação inteira num teste que quase toda a gente o faz — e é por isso que tantas suítes Spring demoram vinte minutos.
10.1 A pergunta que decide o tipo de teste
«Preciso mesmo do Spring para este teste?» Na maioria dos casos a resposta é não, e responder honestamente é o que separa uma suíte de trinta segundos de uma de vinte minutos.
| A testar | Ferramenta | Custo |
|---|---|---|
| Lógica de negócio pura | JUnit simples, sem anotações de Spring | milissegundos |
| Um controlador: rotas, validação, formato | @WebMvcTest | ~1 s |
| Consultas e mapeamento JPA | @DataJpaTest | ~2 s |
| Um percurso completo, poucas vezes | @SpringBootTest | segundos a dezenas |
É a pirâmide de sempre, com os nomes do Spring. O capítulo 6.2 já tinha dado a chave:
injecção por construtor faz com que a primeira linha desta tabela seja possível —
new ServicoPedidos(repoFalso, pagamentosFalsos) e mais nada.
10.2 As fatias
@WebMvcTest(ControladorPedidos.class)
class ControladorPedidosTest {
@Autowired MockMvc mvc;
@MockitoBean ServicoPedidos servico; // só o que o controlador precisa
@Test
void devolve404QuandoNaoExiste() throws Exception {
given(servico.porId(9L)).willThrow(new PedidoNaoExiste(9L));
mvc.perform(get("/pedidos/9"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Pedido não encontrado"));
}
}
Levanta a camada web e nada mais: sem base de dados, sem clientes HTTP, sem o resto do contexto. É rápido porque é pequeno, e é preciso porque falha por uma razão só.
@SpringBootTest em todos os testes. Cada um levanta a aplicação inteira, e o Spring
reutiliza o contexto entre testes com a mesma configuração — mas qualquer diferença
cria um contexto novo: um @MockitoBean diferente, uma propriedade, um perfil.
É assim que uma suíte passa de dois para vinte minutos sem que nenhuma alteração isolada pareça responsável. Sintoma: nos registos, «Starting Application» dezenas de vezes. Se isso acontece, o problema não é a máquina de integração contínua.
10.3 Testcontainers: a base de dados a sério, descartável
Testar contra uma base de dados em memória e correr contra PostgreSQL em produção significa testar outro sistema. Os dialectos divergem exactamente onde dói — tipos, datas, bloqueios, JSON.
@SpringBootTest
@Testcontainers
class PedidosIT {
@Container @ServiceConnection
static PostgreSQLContainer<?> bd = new PostgreSQLContainer<>("postgres:17");
// @ServiceConnection liga a aplicação ao contentor sozinho
}
Um contentor real, criado para a suíte e deitado fora no fim. Custa alguns segundos de arranque e elimina a classe inteira de erros «passava nos testes, falhou em produção». É a troca mais fácil de justificar deste capítulo. Fundamentos de contentores em DevOps, Docker e Kubernetes; a disciplina de testes em Testing e Automação.
Corra a sua suíte e conte quantas vezes aparece «Starting Application» nos registos. Esse número é quantos contextos de Spring foram criados. Se for maior que três ou quatro, tem ali minutos para recuperar — e a causa é sempre a mesma: configurações ligeiramente diferentes que podiam ser iguais.
Capítulo 11 Operação
Empacotar, configurar e correr
A distância entre «funciona na minha máquina» e «funciona às três da manhã» são cinco decisões, e nenhuma delas é de código.
11.1 Um ficheiro, sem servidor a instalar
O Spring Boot produz um jar executável com a aplicação, as dependências e o servidor web lá
dentro. Corre-se com java -jar. Não há Tomcat a instalar nem war a implantar — o
modelo antigo, e a fonte de metade da má fama da pilha.
11.2 Contentor em camadas, e porquê
Copiar o jar inteiro para uma imagem torna cada compilação uma camada nova de dezenas de megabytes, mesmo que só tenha mudado uma linha. As dependências mudam raramente e o seu código muda sempre — separá-los faz a camada que muda ser pequena:
# extrai o jar em camadas por frequência de alteração
java -Djarmode=tools -jar app.jar extract --layers --launcher
Resultado prático: dependências em cache, e cada nova versão a transferir alguns megabytes em vez de algumas dezenas. Em implantações frequentes, é a diferença entre segundos e minutos.
11.3 Nativo com GraalVM: quando compensa mesmo
| JVM | Nativo | |
|---|---|---|
| Arranque | segundos | dezenas de milissegundos |
| Memória em repouso | maior | bastante menor |
| Débito em regime | melhor (o JIT optimiza com dados reais) | bom, mas sem esse ganho |
| Compilação | segundos | minutos |
| Reflexão e proxies | livre | tem de ser declarada |
Vá a nativo se a aplicação arranca e desliga com frequência — funções sem servidor, tarefas curtas, escalar a zero, CLIs. Fique na JVM se ela arranca uma vez e corre durante dias: aí paga a compilação lenta e as restrições, e abdica justamente do compilador que melhora com o tempo. É a leitura operacional do capítulo 3.3.
11.4 Configuração e segredos
A mesma imagem em todos os ambientes, com a configuração vinda de fora. Como as variáveis de ambiente ganham aos ficheiros (cap. 5.4), isto sai de graça:
# spring.datasource.url ←→ SPRING_DATASOURCE_URL
docker run -e SPRING_DATASOURCE_URL=... -e SPRING_PROFILES_ACTIVE=prod app:1.4.2
Palavras-passe, chaves de API e certificados. Não em application-prod.properties,
não num perfil, não «temporariamente». O histórico do Git é permanente — um segredo lá dentro continua
lá depois de removido do ficheiro, e tem de ser rodado, não apagado.
Trate-o como incidente, não como limpeza. O tema está em Git e GitHub.
11.5 Sondas: dizer a verdade sobre o estado
| Sonda | Responde a | Se falhar |
|---|---|---|
| liveness | «O processo está irrecuperável?» | Reinicia |
| readiness | «Posso receber tráfego agora?» | Sai da rotação, não reinicia |
Trocar as duas é o erro clássico. Se a sonda de liveness verificar a base de dados, uma indisponibilidade breve da base reinicia todas as instâncias ao mesmo tempo — e agora tem duas avarias em vez de uma, com todas as aplicações a arrancar frias contra uma base que já estava em dificuldades.
Regra: liveness só verifica o próprio processo. Dependências externas pertencem à readiness. E dê ao arranque folga suficiente para o aquecimento do capítulo 3.3 — uma sonda impaciente inventa avarias que não existem.
Abra a configuração de implantação da sua aplicação e responda: a sonda de liveness toca em alguma coisa externa? Se tocar, acabou de encontrar um mecanismo que transforma uma avaria de dependência num reinício geral. É uma correcção de duas linhas.
Capítulo 12 Ofício · Catálogo
O que fica, e onde verificar
Doze regras que sobrevivem a mudanças de versão, e as fontes onde se confirma o que aqui está antes de acreditar.
12.1 As doze regras
- Compile para a LTS actual, e salte de LTS em LTS. A dívida de plataforma paga-se a prestações ou de uma vez.
- Antes de optimizar, meça. O processador a 12% com latência alta significa espera, não falta de capacidade.
- Conte as consultas de um pedido. Duas linhas de configuração; é o diagnóstico mais rentável desta apostila.
- Nada de chamadas de rede dentro de uma transacção. Segura uma ligação à base de dados durante o tempo de outro sistema.
- Carregamento é decisão por consulta, nunca por entidade.
- Nunca devolva a entidade JPA na resposta HTTP. Converta dentro da transacção.
- Injecte por construtor. É o que impede o objecto incompleto e mantém pressão sobre o desenho.
- Um
@Servicenão tem estado. A omissão é uma instância partilhada por todos os pedidos. - Ao ligar threads virtuais, mude o limite de sítio — não o deixe desaparecer.
- Não fixe
-Xmxem contentores, e lembre-se de que o heap não é a memória do processo. - Liveness não toca em dependências externas. Senão transforma uma avaria em reinício geral.
- Pergunte se precisa mesmo do Spring nesse teste. Quase sempre não precisa.
Sete das doze não são sobre Java. São sobre recursos limitados, onde estão os limites e quem os impõe — ligações, threads, memória, tempo de rede. A linguagem decide como se escreve a correcção; não decide qual é.
É também por isso que quase tudo o que aqui está se transporta para outras pilhas praticamente sem tradução. O que não se transporta são os nomes.
12.2 Catálogo: onde verificar, e com que reservas
Documentação de referência do Spring Boot
Quem: Broadcom / equipa Spring. O quê: a referência completa, incluindo a página de requisitos de sistema que fixa versões de Java, Maven, Gradle, Tomcat e GraalVM.
A favor: é a única fonte que está certa por definição; a secção de requisitos responde em
dez segundos a perguntas que geram discussões de uma hora.
Contra: é uma referência, não um percurso — responde bem a quem já sabe o que procurar.
Notas de versão e guia de migração do Spring Boot
Quem: equipa Spring, no wiki do repositório. O quê: o que mudou em cada versão e o que parte ao actualizar.
A favor: é onde se descobre que um módulo mudou de pacote antes de o descobrir a compilar;
indispensável antes de qualquer salto de versão maior.
Contra: assume que já conhece a versão anterior; não serve como introdução a nada.
Índice de JEPs do OpenJDK
Quem: OpenJDK. O quê: cada mudança da linguagem e da JVM, com a motivação, as alternativas rejeitadas e os limites conhecidos.
A favor: é a fonte onde se confirma, por exemplo, que a correcção do synchronized
com threads virtuais entrou no Java 24 e o que ainda prende depois disso — informação
que quase nenhum artigo traz.
Contra: escrito para quem implementa a JVM. Denso, e sem exemplos práticos.
dev.java
Quem: Oracle, portal oficial de aprendizagem. O quê: tutoriais mantidos a par das
versões, com secções próprias sobre record, sealed, correspondência de
padrões e concorrência.
A favor: resolve o problema central desta matéria — a maior parte do material de Java na
Internet foi escrita para versões que já não são as actuais.
Contra: cobre a linguagem, não o ecossistema: não encontra aqui Spring, Maven nem nada de
operação.
Testcontainers
Quem: AtomicJar / Docker. O quê: bases de dados e serviços reais, em contentor, criados e destruídos pela suíte de testes.
A favor: elimina a classe inteira de erros «passava com a base em memória, falhou com a
real»; a integração com o Spring Boot reduz a configuração a uma anotação.
Contra: exige Docker na máquina e na integração contínua, e acrescenta segundos de arranque —
pouco, mas não zero.
Baeldung
Quem: Eugen Paraschiv e uma rede grande de autores. O quê: a maior colecção de artigos práticos de Java e Spring que existe.
A favor: é quase sempre o primeiro resultado útil para um problema concreto, e cobre
recantos que a documentação oficial não trata.
Contra, e é sério: a qualidade varia com o autor e muitos artigos não foram revistos desde
que saíram. É onde sobrevive o conselho caducado do capítulo 4.1. Verifique sempre a data,
e confirme na documentação oficial o que for decisivo.
As cinco primeiras ligações foram verificadas em Setembro de 2026. A última bloqueia acesso automatizado, e por isso não foi possível confirmá-la da mesma maneira — o que, sendo irrelevante para quem a abre no navegador, é a razão pela qual não se diz aqui que foi verificada.
12.3 O que vem a seguir
Se o problema é o código que já lá está
A maior parte do Java do mundo é código que ninguém escreveu de raiz. Código Legado e Refatoração trata de caracterizar, criar costuras e decidir entre refazer, substituir ou congelar — e é o par natural desta apostila.
Se o problema é a forma do sistema
Ciclos de dependências, limites entre serviços e onde pôr as fronteiras não são questões de linguagem. Arquitetura e System Design e API Design continuam daqui.
Se o problema é chegar a produção
Camadas de imagem, sondas e implantação em DevOps, Docker e Kubernetes; a disciplina de testes em Testing e Automação.
12.4 A última ideia
Java tem trinta anos e a maior parte do que se diz sobre ele descreve uma versão que já não existe. Isso é irritante para quem o defende e conveniente para quem o ataca — mas o problema prático é outro: também descreve as bases de código onde vai trabalhar. Elas são antigas, foram escritas contra restrições reais, e a maioria das pessoas que as escreveu tinha razão na altura.
O ofício não é limpar tudo. É saber, para cada pedaço de cerimónia, se ainda segura alguma coisa — e ter a paciência de descobrir isso antes de apagar.