Meta de hoje
O tempo conta enquanto o curso estiver visível.
Programação Orientada a Objetos (POO) é um paradigma que organiza o código em torno de objetos — entidades que juntam estado (atributos) e comportamento (métodos) em um único bloco. Em vez de escrever uma sequência de funções soltas operando sobre dados globais, você modela o problema como um conjunto de "coisas" que conversam entre si trocando mensagens (chamadas de método).
Java é uma linguagem fortemente orientada a objetos, mas multiparadigma: todo método é declarado dentro de uma classe ou interface, enquanto tipos primitivos, membros static, lambdas e programação funcional mostram que nem todo valor ou estilo de programação é um objeto. Entender POO continua essencial porque o modelo de tipos e a maior parte das APIs Java são organizados em torno de classes, interfaces e objetos.
| Sigla | O que é | Quando você usa |
|---|---|---|
| JVM | Java Virtual Machine — a máquina virtual que executa bytecode Java, independente de sistema operacional | Sempre, é ela quem roda o .class |
| JRE | Java Runtime Environment — JVM + bibliotecas padrão, só para executar programas | Ambientes que só rodam Java, sem compilar |
| JDK | Java Development Kit — JRE + compilador (javac), depurador e ferramentas | Para desenvolver — é o que você instala para programar |
// 1. Você escreve Main.java (código-fonte, texto legível) // 2. javac compila para bytecode: javac Main.java // gera Main.class // 3. a JVM interpreta/compila-JIT o bytecode e executa: java Main
javac não é código de máquina x86 nem ARM — é um formato intermediário que qualquer JVM (Windows, Linux, macOS) sabe interpretar. É esse desenho que dá origem ao lema "write once, run anywhere".Escreva a classe Main com um método main que imprime "Olá, POO!". Depois, explique com suas palavras (num comentário acima do método) por que main precisa ser public static void.
// public: a JVM (fora da classe) precisa conseguir chamar este método. // static: a JVM chama main SEM criar um objeto Main antes — precisa // existir independente de qualquer instância. // void: main não devolve valor para quem o chamou (o processo termina // com um código de saída, não com um "return" de objeto). public class Main { public static void main(String[] args) { System.out.println("Olá, POO!"); } }
Antes de estudar abstrações, você precisa dominar o ciclo concreto: um arquivo .java contém código-fonte; o compilador javac verifica regras e produz bytecode; a JVM executa esse bytecode. IDEs escondem parte desse caminho, mas não o eliminam.
public class Main { public static void main(String[] args) { System.out.println("Olá, Java!"); } }
O nome do arquivo deve ser Main.java porque a classe pública se chama Main. main é o ponto de entrada. String[] args recebe argumentos da linha de comando. System.out é a saída padrão e println imprime uma linha.
javac Main.java # compila e cria Main.class java Main # pede à JVM para executar Main java Main.java # execução direta de arquivo-fonte em Java moderno
Leia de cima para baixo: arquivo, linha, símbolo indicado por ^ e descrição. Conserte o primeiro erro e compile outra vez. Um caractere ausente pode gerar vários erros em cascata.
Crie o arquivo manualmente, compile pelo terminal e execute. Depois remova um ponto e vírgula, observe o diagnóstico e explique por que o problema pertence à compilação.
Uma variável associa um nome a um valor cujo tipo é conhecido pelo compilador. O tipo limita operações válidas, representação e faixa. Java é estaticamente tipada: incompatibilidades são detectadas antes da execução sempre que possível.
| Grupo | Tipos | Uso |
|---|---|---|
| Inteiros | byte, short, int, long | int é a escolha normal; long para faixas maiores |
| Decimais | float, double | double é o padrão; nenhum deles representa dinheiro com exatidão decimal |
| Caractere | char | Uma unidade UTF-16, não necessariamente um caractere humano completo |
| Lógico | boolean | true ou false |
int idade = 17; long habitantes = 8_100_000_000L; double altura = 1.78; boolean ativo = true; String nome = "Felipy";
Uma variável primitiva contém o próprio valor. Uma variável de referência aponta para um objeto ou contém null. Copiar um primitivo copia o valor; copiar uma referência faz duas variáveis apontarem inicialmente para o mesmo objeto.
null não significa vazio nem zero. Significa ausência de referência. Tentar acessar um membro por uma referência nula causa NullPointerException.int pequeno = 10; long ampliado = pequeno; // widening: seguro double media = 7.9; int truncado = (int) media; // narrowing: vira 7, não arredonda
Variáveis locais precisam ser inicializadas antes do uso. Campos de objetos recebem valores padrão, mas depender disso sem intenção explícita reduz clareza.
Explique o valor e o tipo de cada expressão: 5 / 2, 5 / 2.0, (double) 5 / 2 e (int) 5.9.
Expressão é uma combinação que produz um valor. Operadores determinam como os operandos são combinados. Decorar uma tabela de precedência é menos importante que escrever expressões claras com parênteses.
| Categoria | Operadores principais | Resultado |
|---|---|---|
| Aritméticos | + - * / % | Número |
| Comparação | == != > >= < <= | boolean |
| Lógicos | && || ! | boolean |
| Atribuição | = += -= *= /= | Atualiza uma variável |
if (usuario != null && usuario.estaAtivo()) { liberarAcesso(); }
Com &&, a segunda condição só é avaliada se a primeira for verdadeira. Com ||, ela só é avaliada se a primeira for falsa. Isso afeta segurança contra null e também efeitos colaterais.
== não é igualdade de conteúdo para objetosEm primitivos, == compara valores. Em referências, compara se ambos os lados apontam para o mesmo objeto. Para conteúdo de String e outros value objects, use equals conforme o contrato do tipo.
String a = new String("Java"); String b = new String("Java"); System.out.println(a == b); // false: referências distintas System.out.println(a.equals(b)); // true: mesmo conteúdo
Escreva uma expressão que aceite idade entre 18 e 65, inclusive. Depois negue a condição inteira sem repetir a lógica.
Controle de fluxo decide qual caminho será executado. A qualidade não está em usar o maior número de recursos, mas em tornar as regras mutuamente compreensíveis e testáveis.
if (nota >= 7) { situacao = "Aprovado"; } else if (nota >= 5) { situacao = "Recuperação"; } else { situacao = "Reprovado"; }
A ordem importa. Em uma cadeia, coloque condições mais específicas antes das mais amplas. Evite pirâmides de if; retornos antecipados costumam separar casos inválidos do caminho principal.
String tipo = switch (codigo) { case 200, 201 -> "sucesso"; case 400, 404 -> "erro do cliente"; default -> "outro"; };
else if.Calcule frete considerando região, peso e gratuidade acima de um valor. Liste primeiro casos-limite e só então escreva o código.
Um laço repete enquanto uma condição permite. Todo laço precisa de estado inicial, condição de continuidade e progresso em direção ao término. Se um desses elementos estiver errado, surge um laço infinito ou uma iteração incompleta.
| Estrutura | Use quando |
|---|---|
while | A quantidade de repetições depende de uma condição externa |
do-while | O corpo precisa executar pelo menos uma vez |
for | Inicialização, condição e incremento formam uma contagem clara |
for-each | Você percorre todos os elementos e não precisa do índice |
int soma = 0; for (int i = 1; i <= 100; i++) { if (i % 2 != 0) continue; soma += i; }
Os bugs mais comuns são começar uma posição tarde, terminar uma posição cedo ou usar <= quando o limite é exclusivo. Simule à mão a primeira e a última iteração. Para coleções indexadas de tamanho n, índices válidos normalmente vão de 0 a n - 1.
Leia números até receber zero. Ignore negativos, some positivos e conte quantos foram aceitos. Teste: zero como primeiro valor, apenas negativos e uma sequência mista.
Array é um objeto de tamanho fixo que armazena elementos do mesmo tipo. A variável guarda uma referência; o objeto array conhece seu tamanho por length. Fixar isso evita a falsa ideia de que int[] é apenas uma lista de variáveis soltas.
int[] notas = {7, 9, 6}; notas[1] = 10; for (int i = 0; i < notas.length; i++) { System.out.printf("notas[%d] = %d%n", i, notas[i]); }
int[] a = {1, 2}; int[] b = a; // mesmo array int[] c = a.clone(); // novo array de primitivos b[0] = 99; // a[0] também vira 99
Em arrays de objetos, clone cria outro array, mas copia referências dos elementos. Os objetos internos continuam compartilhados. Isso é uma cópia rasa.
int[][] não exige linhas do mesmo tamanho. Cada linha é outro array e pode até ser null. Percorra usando matriz.length para linhas e matriz[i].length para colunas.
ArrayList. Não reimplemente crescimento manual sem uma razão técnica.Receba um array e encontre mínimo, máximo, média e posição do primeiro máximo. Trate array vazio explicitamente.
String é objeto imutável. Operações como toUpperCase, replace e concatenação não modificam a instância; produzem outra. Ignorar o retorno é um erro lógico frequente.
String nome = " Ada Lovelace "; String normalizado = nome.trim().toLowerCase(); boolean contemAda = normalizado.contains("ada");
Em um laço grande, concatenar repetidamente pode criar muitos objetos temporários. Use StringBuilder quando estiver construindo texto incrementalmente.
StringBuilder builder = new StringBuilder(); for (String item : itens) { builder.append(item).append(System.lineSeparator()); } String resultado = builder.toString();
Integer, Long, Double e Boolean representam primitivos como objetos. São necessários em generics e podem ser null. A conversão automática entre int e Integer é conveniente, mas desempacotar null causa NullPointerException.
double. Para valores monetários decimais exatos, estude BigDecimal, escala e modo de arredondamento.Normalize nomes removendo espaços externos, recusando texto vazio e capitalizando cada palavra sem destruir acentos.
Método dá nome a uma operação e cria um limite de responsabilidade. Um bom método recebe apenas o necessário, devolve um resultado claro e evita alterar estado que o nome não anuncia.
static double calcularMedia(double[] valores) { if (valores.length == 0) { throw new IllegalArgumentException("Array vazio"); } double soma = 0; for (double valor : valores) soma += valor; return soma / valores.length; }
Para primitivos, o método recebe uma cópia do valor. Para objetos, recebe uma cópia da referência. O método pode alterar o objeto apontado, mas reatribuir o parâmetro não muda a variável do chamador.
Uma variável local existe apenas no bloco em que foi declarada. Prefira o menor escopo possível: isso reduz estados simultâneos e impede uso acidental antes ou depois do ponto correto.
Métodos podem compartilhar o nome se diferirem na lista de parâmetros. O tipo de retorno sozinho não distingue sobrecargas. Use sobrecarga para variações coerentes da mesma operação, não para esconder comportamentos diferentes.
Crie um programa de relatório de notas separando leitura, validação, cálculo e apresentação. Nenhum método deve misturar as quatro responsabilidades.
Este capítulo é diferente dos outros: não ensina uma ferramenta ou biblioteca — treina a habilidade que sustenta tudo mais no curso. Quem programa bem não é quem decorou mais sintaxe, é quem consegue quebrar um problema grande em passos pequenos e certos.
// Problema: "dado um array, contar quantos números são positivos" // // Decomposição em português ANTES do código: // 1. começar um contador em zero // 2. percorrer cada número // 3. aumentar o contador quando o número for maior que zero // 4. devolver o contador // // SÓ AGORA traduzir para Java: int contarPositivos(int[] numeros) { int quantidade = 0; for (int numero : numeros) { if (numero > 0) quantidade++; } return quantidade; }
Você já usa if, for, while desde os primeiros capítulos — mas pensar em quais situações cada um é a ferramenta certa é uma habilidade separada de saber a sintaxe.
| Estrutura | Pensamento por trás |
|---|---|
if/else | "Dependendo de uma condição, o caminho muda" — uma decisão |
for | "Sei exatamente quantas vezes preciso repetir" (ou tenho uma coleção para percorrer) |
while | "Repito até uma condição parar de ser verdadeira, mas não sei de antemão quantas vezes" |
| Recursão (capítulo 81) | "O problema grande é feito de versões menores de si mesmo" |
Antes de rodar qualquer código, treine "ser a máquina" — percorrer o código linha por linha no papel, anotando o valor de cada variável a cada passo.
int fatorial(int n) { int resultado = 1; for (int i = 1; i <= n; i++) { resultado = resultado * i; } return resultado; } // dry run manual de fatorial(4): // i=1: resultado = 1*1 = 1 // i=2: resultado = 1*2 = 2 // i=3: resultado = 2*3 = 6 // i=4: resultado = 6*4 = 24 // i=5: condição "i<=4" falsa, sai do loop // retorna 24
i < n com i <= n, ou começar um índice em 0 quando deveria começar em 1 (ou vice-versa). Não existe atalho mágico para evitar isso além de fazer o dry run mentalmente nos casos-limite (primeira iteração, última iteração, coleção vazia) toda vez que escrever um loop novo.Antes de escrever qualquer código, escreva em português os passos para resolver: "dado um array de inteiros, encontrar o segundo maior valor, sem ordenar o array inteiro". Só depois traduza para Java.
// Decomposição: // 1. guardar duas variáveis: "maior" e "segundoMaior" // 2. percorrer o array uma única vez // 3. se o valor atual for maior que "maior": o antigo "maior" vira "segundoMaior", // e o valor atual vira o novo "maior" // 4. senão, se o valor atual for maior que "segundoMaior" (mas menor que "maior"): // só atualiza "segundoMaior" int segundoMaior(int[] numeros) { int maior = Integer.MIN_VALUE, segundoMaior = Integer.MIN_VALUE; boolean encontrouMaior = false, encontrouSegundo = false; for (int n : numeros) { if (!encontrouMaior || n > maior) { if (encontrouMaior) { segundoMaior = maior; encontrouSegundo = true; } maior = n; encontrouMaior = true; } else if (n != maior && (!encontrouSegundo || n > segundoMaior)) { segundoMaior = n; encontrouSegundo = true; } } if (!encontrouSegundo) throw new IllegalArgumentException("não há dois valores distintos"); return segundoMaior; }
Construa um caixa eletrônico sem POO avançada. O programa mantém saldo durante a execução e oferece depósito, saque, extrato e encerramento.
Saldo inicial zero; depósito de 100; saque de 30; saque de 100; entrada textual onde se espera número; mais transações que a capacidade do array.
Uma classe é um molde — define quais atributos e métodos os objetos criados a partir dela terão. Um objeto é uma instância concreta desse molde, vivendo na memória com seus próprios valores.
public class Cafe { String tipo; boolean comAcucar; void descrever() { System.out.println("Um " + tipo + (comAcucar ? " doce" : " puro")); } } Cafe expresso = new Cafe(); expresso.tipo = "expresso"; expresso.comAcucar = false; expresso.descrever(); // Um expresso puro
Este é o ponto que separa quem "decora sintaxe" de quem realmente entende Java. Existem duas regiões de memória relevantes para o seu programa:
int, double, boolean...), o valor em si fica na stack.new realmente existe. A variável que você declara na stack não guarda o objeto — guarda uma referência (um endereço) apontando para o objeto no heap.Cafe a = new Cafe(); a.tipo = "expresso"; Cafe b = a; // b aponta para o MESMO objeto que a b.tipo = "latte"; System.out.println(a.tipo); // "latte" -- a e b são a mesma referência!
b = a) não cria uma cópia — copia apenas a referência. Os dois nomes de variável agora apontam para o mesmo espaço no heap. Se quiser um objeto realmente independente, você precisa criar um novo com new e copiar os campos manualmente (ou implementar um método clone() ou um construtor de cópia).delete ou free em Java. Quando nenhuma variável mais referencia um objeto no heap, o Garbage Collector (GC) identifica isso automaticamente em segundo plano e libera a memória. Isso é ótimo para produtividade, mas significa que você não controla exatamente quando um objeto é destruído.Crie uma classe Livro com atributos titulo, autor (String) e paginas (int). Adicione um método resumo() que imprime "Dom Casmurro, de Machado de Assis (256 páginas)". Crie dois objetos Livro diferentes no main e chame resumo() em cada.
public class Livro { String titulo; String autor; int paginas; void resumo() { System.out.println(titulo + ", de " + autor + " (" + paginas + " páginas)"); } } public class Main { public static void main(String[] args) { Livro l1 = new Livro(); l1.titulo = "Dom Casmurro"; l1.autor = "Machado de Assis"; l1.paginas = 256; l1.resumo(); Livro l2 = new Livro(); l2.titulo = "1984"; l2.autor = "George Orwell"; l2.paginas = 328; l2.resumo(); } }
Sem rodar em uma IDE, preveja no papel a saída deste código, depois explique o porquê usando o conceito de stack/heap:
Cafe x = new Cafe(); x.tipo = "coado"; Cafe y = new Cafe(); y.tipo = "coado"; System.out.println(x == y); y = x; System.out.println(x == y);
Saída: false depois true. No primeiro ==, x e y são referências para dois objetos diferentes no heap (mesmo com o mesmo valor de tipo) — == em objetos compara referência, não conteúdo. Depois de y = x, ambas as variáveis passam a apontar para o mesmo objeto, então == retorna true. (Para comparar conteúdo, usa-se equals() — capítulo 05.)
Atributos (campos) guardam o estado do objeto. Métodos definem seu comportamento e podem receber parâmetros, retornar valores, ou apenas executar uma ação.
public class ContaBancaria { double saldo; void depositar(double valor) { saldo = saldo + valor; } boolean sacar(double valor) { if (valor > saldo) return false; saldo = saldo - valor; return true; } }
Uma fonte comum de confusão: Java sempre passa parâmetros por valor — nunca por referência, no sentido estrito da palavra. A pegadinha é que, para objetos, o "valor" passado é a referência (o endereço), não o objeto em si.
static void dobra(int n) { n = n * 2; // altera só a cópia local } static void renomeia(Cafe c) { c.tipo = "renomeado"; // altera o OBJETO apontado (visível fora!) } static void trocaObjeto(Cafe c) { c = new Cafe(); // só troca para o que a cópia LOCAL da referência aponta } // main: int x = 5; dobra(x); System.out.println(x); // 5 -- inalterado, primitivo copiado por valor Cafe meuCafe = new Cafe(); meuCafe.tipo = "original"; renomeia(meuCafe); System.out.println(meuCafe.tipo); // "renomeado" -- o objeto no heap foi alterado trocaObjeto(meuCafe); System.out.println(meuCafe.tipo); // ainda "renomeado" -- reatribuir o parâmetro local não afeta a variável externa
c = new Cafe()), isso só afeta a variável local — a variável de fora continua apontando para o objeto original.Java permite vários métodos com o mesmo nome, desde que a assinatura (tipo e quantidade de parâmetros) seja diferente. O tipo de retorno sozinho não conta.
public class Calculadora { int somar(int a, int b) { return a + b; } double somar(double a, double b) { return a + b; } int somar(int a, int b, int c) { return a + b + c; } // ❌ NÃO compila: mesma assinatura, só o retorno muda // long somar(int a, int b) { return a + b; } }
somar(1, 2) com dois int literais, Java escolhe a versão int — mas se um dos argumentos for long e não existir overload exato, o compilador faz um widening automático (ex: int→double) antes de tentar autoboxing (int→Integer). A ordem de preferência do compilador é: 1) correspondência exata, 2) widening primitivo, 3) autoboxing, 4) varargs. Isso raramente importa, até o dia em que causa um bug sutil de overload ambíguo.Crie uma classe Retangulo com largura e altura (double). Adicione métodos area() e perimetro(). Teste com um retângulo 4 por 5.
public class Retangulo { double largura, altura; double area() { return largura * altura; } double perimetro() { return 2 * (largura + altura); } } Retangulo r = new Retangulo(); r.largura = 4; r.altura = 5; System.out.println(r.area()); // 20.0 System.out.println(r.perimetro()); // 18.0
Escreva um método esvaziar(ArrayList<String> lista) que tenta esvaziar a lista recebida de duas formas: (a) usando lista.clear(), (b) usando lista = new ArrayList<>(). No main, chame os dois e imprima a lista original depois de cada chamada. Explique a diferença de resultado usando o que você aprendeu sobre passagem por valor de referências. (Não se preocupe se ainda não viu ArrayList em detalhes — veremos no capítulo 11; o objetivo aqui é só o conceito de referência.)
static void esvaziarComClear(ArrayList<String> lista) { lista.clear(); // muda o OBJETO apontado -> visível fora } static void esvaziarComNovaLista(ArrayList<String> lista) { lista = new ArrayList<>(); // só troca a referência LOCAL -> invisível fora } // main: ArrayList<String> nomes = new ArrayList<>(); nomes.add("Ana"); esvaziarComClear(nomes); System.out.println(nomes); // [] -- clear() alterou o objeto real nomes.add("Bruno"); esvaziarComNovaLista(nomes); System.out.println(nomes); // [Bruno] -- a reatribuição local não afetou "nomes" fora do método
Um construtor é um método especial, com o mesmo nome da classe e sem tipo de retorno, executado automaticamente no new. Ele existe para garantir que todo objeto nasça em um estado válido, em vez de depender que alguém lembre de preencher cada atributo depois.
public class Cafe { String tipo; boolean comAcucar; Cafe(String tipo, boolean comAcucar) { this.tipo = tipo; this.comAcucar = comAcucar; } } Cafe expresso = new Cafe("expresso", false);
this se refere ao próprio objeto sendo construído — aqui, diferencia o parâmetro tipo do atributo tipo.
public class Cafe { String tipo; boolean comAcucar; Cafe(String tipo, boolean comAcucar) { this.tipo = tipo; this.comAcucar = comAcucar; } Cafe(String tipo) { this(tipo, false); // delega para o construtor de cima -- deve ser a 1ª linha } }
Esse é o tipo de detalhe que raramente é ensinado, mas explica bugs sutis. Quando você chama new Filho(...), a JVM executa, nesta ordem:
static da hierarquia inteira (só na primeira vez que a classe é carregada, uma única vez no programa).super(...) implícito ou explícito, sempre a primeira linha).public class A { static { System.out.println("1. static de A"); } { System.out.println("3. bloco de instância de A"); } public A() { System.out.println("4. construtor de A"); } } public class B extends A { static { System.out.println("2. static de B"); } { System.out.println("5. bloco de instância de B"); } public B() { System.out.println("6. construtor de B"); } } // new B(); imprime exatamente na ordem 1,2,3,4,5,6
static { ... } rodam uma única vez, quando a classe é carregada pela JVM — não a cada new. Já blocos de instância { ... } (sem static) rodam toda vez que um objeto é criado, logo antes do corpo do construtor. Poucos devs usam blocos de instância no dia a dia, mas eles aparecem em bibliotecas e em provas de concurso/certificação.final, não private) de dentro do construtor da superclasse. Como o construtor da subclasse ainda não rodou nesse ponto, os atributos da subclasse ainda estarão com valor padrão (null/0/false), o que costuma causar NullPointerException silenciosos. Regra prática: evite chamar métodos "overridable" dentro de construtores.Reescreva ContaBancaria com um construtor que recebe titular e saldoInicial. Se saldoInicial for negativo, defina o saldo como 0.
public class ContaBancaria { String titular; double saldo; ContaBancaria(String titular, double saldoInicial) { this.titular = titular; this.saldo = (saldoInicial < 0) ? 0 : saldoInicial; } }
Crie duas classes Pai e Filho (Filho extends Pai), cada uma com um bloco static, um bloco de instância e um construtor, cada um imprimindo uma mensagem própria. Antes de rodar, escreva no papel a ordem exata em que as mensagens vão aparecer ao criar new Filho() duas vezes seguidas. Depois confira sua previsão.
Na primeira criação: static de Pai → static de Filho → instância de Pai → construtor de Pai → instância de Filho → construtor de Filho. Na segunda criação de new Filho(), os blocos static não rodam de novo (a classe já foi carregada) — só a sequência de instância se repete: instância de Pai → construtor de Pai → instância de Filho → construtor de Filho.
Até agora, qualquer código externo pode escrever conta.saldo = -9999 diretamente. Isso quebra a integridade do objeto. Encapsulamento é esconder os atributos (private) e controlar o acesso por meio de métodos públicos.
public class ContaBancaria { private String titular; private double saldo; public ContaBancaria(String titular, double saldoInicial) { this.titular = titular; this.saldo = Math.max(saldoInicial, 0); } public double getSaldo() { return saldo; } public void depositar(double valor) { if (valor > 0) saldo += valor; } public boolean sacar(double valor) { if (valor <= 0 || valor > saldo) return false; saldo -= valor; return true; } }
| Modificador | Mesma classe | Mesmo pacote | Subclasse (outro pacote) | Qualquer lugar |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
(default) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
Um erro comum: gerar getter e setter public para todos os atributos automaticamente (muitas IDEs fazem isso com um clique). Isso é apenas encapsulamento de fachada — na prática, o objeto continua totalmente mutável de fora, só que através de métodos em vez de campos. Encapsulamento de verdade pensa em quais operações fazem sentido no domínio, não em expor 1 getter/setter por campo.
// encapsulamento "de fachada" -- não resolve nada: public void setSaldo(double saldo) { this.saldo = saldo; } // ainda permite saldo negativo! // encapsulamento de verdade -- expõe operações, não o campo: public boolean sacar(double valor) { /* valida regra de negócio */ }
Uma classe imutável não permite alteração de estado depois de construída — nenhum setter, todos os campos final. Isso elimina uma classe inteira de bugs relacionados a estado mutável compartilhado (especialmente importante em threads, capítulo 14).
public final class Ponto { private final double x; private final double y; public Ponto(double x, double y) { this.x = x; this.y = y; } public double getX() { return x; } public double getY() { return y; } // "modificar" retorna um NOVO Ponto, nunca altera o atual public Ponto transladar(double dx, double dy) { return new Ponto(x + dx, y + dy); } }
record, que gera automaticamente uma classe imutável com construtor, getters, equals(), hashCode() e toString(): public record Ponto(double x, double y) {} substitui toda a classe acima em uma linha. Vale conhecer o conceito manual primeiro (como fizemos) para entender o que o record está economizando para você.Torne Livro totalmente encapsulado: atributos private, construtor com os três valores, getters para todos, e um setter apenas para paginas que rejeite valores ≤ 0.
public class Livro { private String titulo, autor; private int paginas; public Livro(String titulo, String autor, int paginas) { this.titulo = titulo; this.autor = autor; this.paginas = paginas; } public String getTitulo() { return titulo; } public String getAutor() { return autor; } public int getPaginas() { return paginas; } public void setPaginas(int paginas) { if (paginas > 0) this.paginas = paginas; } }
Crie uma classe imutável Dinheiro com valor (double) e moeda (String), ambos final. Adicione um método somar(Dinheiro outro) que retorna um novo Dinheiro com a soma dos valores (lance uma IllegalArgumentException se as moedas forem diferentes — veremos exceções a fundo no capítulo 10, mas throw new IllegalArgumentException("mensagem") já funciona sozinho).
public final class Dinheiro { private final double valor; private final String moeda; public Dinheiro(double valor, String moeda) { this.valor = valor; this.moeda = moeda; } public Dinheiro somar(Dinheiro outro) { if (!this.moeda.equals(outro.moeda)) { throw new IllegalArgumentException("Moedas diferentes"); } return new Dinheiro(this.valor + outro.valor, this.moeda); } public double getValor() { return valor; } }
O que acontece se você tentar acessar um atributo private de fora da classe?
public class Contador { private static int totalCriados = 0; // compartilhado entre TODOS os objetos private final int id; public Contador() { totalCriados++; this.id = totalCriados; } public static int getTotalCriados() { return totalCriados; } // static não usa "this" } // uso: Contador.getTotalCriados() -- chamado na CLASSE, sem "new"
Um campo static existe uma única vez, compartilhado por todas as instâncias. Um método static pertence à classe e não acessa atributos de instância diretamente — é por isso que public static void main(...) roda sem que nenhum objeto tenha sido criado ainda.
public class Circulo { public static final double PI_APROX = 3.14159; // constante de classe private final double raio; // só pode ser atribuído uma vez public Circulo(double raio) { this.raio = raio; } }
final em uma classe (public final class Foo) impede que ela seja estendida. String é final por esse motivo, entre outros ligados a segurança e imutabilidade.
final List<String> lista = new ArrayList<>(); impede reatribuir a variável lista a outro objeto — mas você ainda pode chamar lista.add("x") normalmente. final trava a referência, não o conteúdo do objeto apontado.Já vimos this para diferenciar parâmetro de atributo e para encadear construtores. Ele também habilita method chaining, retornando o próprio objeto:
public class PedidoBuilder { private String item = ""; private int quantidade = 1; public PedidoBuilder item(String item) { this.item = item; return this; } public PedidoBuilder quantidade(int q) { this.quantidade = q; return this; } } // uso: new PedidoBuilder().item("Café").quantidade(2);
this é a base do Builder Pattern, muito usado para construir objetos complexos com muitos parâmetros opcionais sem criar dez construtores sobrecarregados. Bibliotecas populares como StringBuilder (.append(a).append(b)) e frameworks de teste usam exatamente essa técnica.Crie Pedido com static int proximoId = 1 e final int id. No construtor, atribua id a partir de proximoId e incremente. Crie três pedidos e confirme ids sequenciais.
public class Pedido { private static int proximoId = 1; private final int id; public Pedido() { this.id = proximoId++; } public int getId() { return id; } } Pedido p1 = new Pedido(), p2 = new Pedido(), p3 = new Pedido(); System.out.println(p1.getId() + " " + p2.getId() + " " + p3.getId()); // 1 2 3
Crie uma classe Pizza com atributos tamanho, borda e uma List<String> ingredientes. Crie um PizzaBuilder com métodos encadeáveis tamanho(String), borda(String) e adicionar(String ingrediente), todos retornando this, e um método final construir() que retorna a Pizza pronta.
public class Pizza { private final String tamanho, borda; private final java.util.List<String> ingredientes; public Pizza(String tamanho, String borda, java.util.List<String> ingredientes) { this.tamanho = tamanho; this.borda = borda; this.ingredientes = ingredientes; } } public class PizzaBuilder { private String tamanho = "média", borda = "tradicional"; private java.util.List<String> ingredientes = new java.util.ArrayList<>(); public PizzaBuilder tamanho(String t) { this.tamanho = t; return this; } public PizzaBuilder borda(String b) { this.borda = b; return this; } public PizzaBuilder adicionar(String ing) { ingredientes.add(ing); return this; } public Pizza construir() { return new Pizza(tamanho, borda, ingredientes); } } // uso: // Pizza p = new PizzaBuilder().tamanho("grande").adicionar("mussarela").adicionar("manjericão").construir();
Herança permite que uma classe (subclasse) reaproveite atributos e métodos de outra (superclasse), usando extends. É a ferramenta certa quando existe uma relação genuína de "é um tipo de" — um Gerente é um Funcionario.
public class Funcionario { protected String nome; protected double salarioBase; public Funcionario(String nome, double salarioBase) { this.nome = nome; this.salarioBase = salarioBase; } public double calcularSalario() { return salarioBase; } } public class Gerente extends Funcionario { private double bonus; public Gerente(String nome, double salarioBase, double bonus) { super(nome, salarioBase); this.bonus = bonus; } @Override public double calcularSalario() { return salarioBase + bonus; } }
protected em nome e salarioBase porque Gerente precisa acessá-los diretamente. Java só permite herança simples — uma classe tem no máximo uma superclasse direta.Se você não escreve extends, sua classe estende java.lang.Object implicitamente. É de lá que vêm três métodos que você vai sobrescrever o tempo todo:
public class Ponto { private final int x, y; public Ponto(int x, int y) { this.x = x; this.y = y; } @Override public String toString() { return "Ponto(" + x + ", " + y + ")"; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Ponto)) return false; Ponto outro = (Ponto) obj; return this.x == outro.x && this.y == outro.y; } @Override public int hashCode() { return java.util.Objects.hash(x, y); } }
| Método | Para que serve |
|---|---|
toString() | Representação textual — usada implicitamente em println(obj) e concatenação com String |
equals(Object) | Igualdade de conteúdo (padrão é ==, ou seja, mesma referência) |
hashCode() | Código numérico usado por HashMap/HashSet para localizar o objeto rapidamente |
equals(), é obrigatório sobrescrever hashCode() junto — e ambos precisam usar os mesmos campos. Quebrar esse contrato faz objetos "iguais" sumirem silenciosamente de HashSets e chaves de HashMap pararem de bater, um dos bugs mais difíceis de rastrear em Java. A IDE geralmente gera os dois juntos automaticamente — use essa opção em vez de escrever na mão.class A { void falar() { System.out.println("A"); } } class B extends A { @Override void falar() { super.falar(); System.out.println("B"); } } class C extends B { @Override void falar() { super.falar(); System.out.println("C"); } } // new C().falar(); imprime "A", "B", "C" nessa ordem
super.metodo() chama a versão do método na superclasse imediata, permitindo estender o comportamento herdado em vez de substituí-lo por completo.
Crie Veiculo com marca, modelo e ligar(). Crie Carro e Moto estendendo, cada uma com um atributo próprio e um método exclusivo.
public class Veiculo { protected String marca, modelo; public Veiculo(String marca, String modelo) { this.marca = marca; this.modelo = modelo; } public void ligar() { System.out.println(modelo + " ligado"); } } public class Carro extends Veiculo { private int numeroPortas; public Carro(String marca, String modelo, int numeroPortas) { super(marca, modelo); this.numeroPortas = numeroPortas; } public void abrirPortaMalas() { System.out.println("Porta-malas aberto"); } } public class Moto extends Veiculo { private int cilindradas; public Moto(String marca, String modelo, int cilindradas) { super(marca, modelo); this.cilindradas = cilindradas; } public void empinar() { System.out.println(modelo + " empinando!"); } }
Crie uma classe Cpf com um único atributo numero (String). Implemente equals() (dois CPFs são iguais se o número for igual) e hashCode() coerente. No main, coloque dois objetos Cpf com o mesmo número dentro de um HashSet<Cpf> e mostre que o set considera os dois como duplicados (tamanho final = 1).
public class Cpf { private final String numero; public Cpf(String numero) { this.numero = numero; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Cpf)) return false; return numero.equals(((Cpf) obj).numero); } @Override public int hashCode() { return numero.hashCode(); } } // main: java.util.Set<Cpf> set = new java.util.HashSet<>(); set.add(new Cpf("111.222.333-44")); set.add(new Cpf("111.222.333-44")); System.out.println(set.size()); // 1 -- considerados duplicados
Polimorfismo ("muitas formas") é tratar objetos de subclasses diferentes de maneira uniforme através de uma referência do tipo da superclasse — cada um responde de um jeito próprio ao mesmo método chamado.
Funcionario[] equipe = { new Funcionario("Ana", 3000), new Gerente("Bruno", 3000, 1500) }; for (Funcionario f : equipe) System.out.println(f.calcularSalario()); // 3000.0 (versão de Funcionario) // 4500.0 (versão sobrescrita em Gerente)
Isso é upcasting: uma variável Funcionario guarda referência para um objeto Gerente. O tipo da variável é Funcionario, mas o tipo do objeto continua Gerente — é o objeto real, não a variável, quem decide qual versão do método rodar (ligação dinâmica, dynamic binding).
Nem tudo em Java é resolvido em tempo de execução. Métodos static, private e final, além de atributos, usam binding estático — resolvido em tempo de compilação, pelo tipo da variável, não do objeto.
class Animal { String nome = "animal genérico"; // atributo -- binding ESTÁTICO static void classificar() { System.out.println("reino Animalia"); } // static -- binding ESTÁTICO void emitirSom() { System.out.println("..."); } // instância -- binding DINÂMICO } class Cachorro extends Animal { String nome = "cachorro"; static void classificar() { System.out.println("canídeo"); } @Override void emitirSom() { System.out.println("Au au"); } } Animal a = new Cachorro(); System.out.println(a.nome); // "animal genérico" -- atributo lido pelo tipo da VARIÁVEL a.emitirSom(); // "Au au" -- método pelo tipo do OBJETO a.classificar(); // "reino Animalia" -- static pelo tipo da VARIÁVEL (evite chamar static assim!)
static, não private, não final) têm ligação dinâmica. Isso é uma razão prática, não só teórica, para nunca acessar campos public diretamente — use getters, que são polimórficos.Animal a = new Cachorro(); // forma clássica if (a instanceof Cachorro) { Cachorro c = (Cachorro) a; // downcast explícito c.buscar(); } // pattern matching for instanceof (Java 16+) -- mais direto if (a instanceof Cachorro c) { c.buscar(); // "c" já vem com o cast feito }
instanceof antes, a menos que tenha certeza absoluta do tipo.Crie Forma com double area() retornando 0. Crie Circulo e Quadrado sobrescrevendo. Monte um Forma[] misto e some a área total com um for.
public class Forma { public double area() { return 0; } } public class Circulo extends Forma { private double raio; public Circulo(double raio) { this.raio = raio; } @Override public double area() { return Math.PI * raio * raio; } } public class Quadrado extends Forma { private double lado; public Quadrado(double lado) { this.lado = lado; } @Override public double area() { return lado * lado; } } Forma[] formas = { new Circulo(3), new Quadrado(4) }; double total = 0; for (Forma f : formas) total += f.area(); System.out.println("Área total: " + total);
Reproduza exatamente o exemplo de Animal/Cachorro mostrado acima na sua IDE. Antes de rodar, escreva no papel o que cada uma das três linhas (a.nome, a.emitirSom(), a.classificar()) vai imprimir e por quê. Depois confira.
a.nome → "animal genérico" (atributos usam o tipo da variável). a.emitirSom() → "Au au" (métodos de instância sobrescritos usam o tipo real do objeto). a.classificar() → "reino Animalia" (métodos static são resolvidos pelo tipo da variável, não do objeto — e chamar um static através de uma variável de instância é, aliás, um mau hábito: o certo seria Animal.classificar()).
No exercício anterior, Forma.area() retorna 0 só para existir — nunca faz sentido instanciar uma Forma "genérica" sozinha. Uma classe abstrata torna essa intenção explícita: não pode ser instanciada e pode ter métodos abstratos, sem corpo, que as subclasses são obrigadas a implementar.
public abstract class Forma { public abstract double area(); // sem corpo public void descrever() { // métodos concretos também são permitidos System.out.println("Área: " + area()); } } public class Circulo extends Forma { private double raio; public Circulo(double raio) { this.raio = raio; } @Override public double area() { return Math.PI * raio * raio; } } // Forma forma = new Forma(); // ❌ erro de compilação // Circulo c = new Circulo(2); // ✅ ok
descrever()) além de um contrato que cada subclasse implementa do seu jeito. Se não há nenhum comportamento compartilhado — só o contrato — uma interface (próximo capítulo) costuma ser melhor.Parece contraditório (já que ela não pode ser instanciada diretamente), mas faz sentido: o construtor roda quando uma subclasse concreta é instanciada, via super(...).
public abstract class Forma { protected final String nome; protected Forma(String nome) { this.nome = nome; } // roda via super() nas subclasses public abstract double area(); }
abstract na classe é impedir instanciação direta. Isso é usado, por exemplo, quando você quer forçar que a classe só seja usada através de subclasses, mesmo que hoje todos os métodos já tenham implementação padrão.Crie Instrumento abstrata com tocar() abstrato e afinar() concreto (imprime "Afinando..."). Implemente Violao e Bateria.
public abstract class Instrumento { public abstract void tocar(); public void afinar() { System.out.println("Afinando..."); } } public class Violao extends Instrumento { @Override public void tocar() { System.out.println("Dedilhando as cordas"); } } public class Bateria extends Instrumento { @Override public void tocar() { System.out.println("Batendo nos tambores"); } }
Implemente o padrão Template Method: crie uma classe abstrata ProcessadorPedido com um método final processar() que chama, em ordem fixa, três métodos abstratos: validar(), cobrar() e notificar(). Crie uma subclasse ProcessadorPedidoPix implementando os três passos à sua maneira. A ideia: o "esqueleto" do algoritmo é fixo na superclasse, só os passos individuais variam.
public abstract class ProcessadorPedido { public final void processar() { // final: subclasses não podem mudar a ordem validar(); cobrar(); notificar(); } protected abstract void validar(); protected abstract void cobrar(); protected abstract void notificar(); } public class ProcessadorPedidoPix extends ProcessadorPedido { @Override protected void validar() { System.out.println("Validando chave Pix"); } @Override protected void cobrar() { System.out.println("Gerando QR Code Pix"); } @Override protected void notificar() { System.out.println("Enviando confirmação por e-mail"); } } // main: new ProcessadorPedidoPix().processar();
Uma interface define um contrato puro: métodos que qualquer classe implementadora promete ter. Diferente de herança, uma classe pode implementar várias interfaces — é como Java contorna a ausência de herança múltipla.
public interface Comparavel { int comparar(Object outro); } public interface Imprimivel { void imprimir(); } public class Produto implements Comparavel, Imprimivel { private double preco; @Override public int comparar(Object outro) { Produto p = (Produto) outro; return Double.compare(this.preco, p.preco); } @Override public void imprimir() { System.out.println("Produto: R$" + preco); } }
Desde o Java 8, interfaces deixaram de ser 100% abstratas. Isso foi introduzido principalmente para permitir evoluir a API de Collection/List sem quebrar todo código já escrito.
public interface Notificador { void enviar(String mensagem); // abstrato -- cada implementação define // default: implementação padrão, subclasses podem sobrescrever ou não default void enviarUrgente(String mensagem) { enviar("[URGENTE] " + mensagem); } // static: pertence à interface, chamado como Notificador.padrao() static Notificador padrao() { return msg -> System.out.println("Log: " + msg); // lambda -- capítulo 13 } }
default com a mesma assinatura, o compilador força você a sobrescrever esse método explicitamente na classe — Java não escolhe um dos dois por conta própria. Esse é o único jeito de "herança múltipla de implementação" ser tolerado, e ainda assim de forma explícita e segura.| Situação | Use |
|---|---|
Classes não relacionadas precisam prometer o mesmo comportamento (ex: Voador em Pássaro e Avião) | Interface |
| Existe hierarquia real ("é um") com estado e comportamento compartilhados | Classe abstrata |
| A classe já herda de outra e ainda precisa do contrato | Interface (única forma de "herança múltipla") |
| Você quer expor só o comportamento, sem se importar como é implementado por trás | Interface |
Crie MetodoPagamento com boolean pagar(double valor). Implemente Pix e CartaoCredito. No main, itere um array chamando pagar(100).
public interface MetodoPagamento { boolean pagar(double valor); } public class Pix implements MetodoPagamento { @Override public boolean pagar(double valor) { System.out.println("Pagando R$" + valor + " via Pix"); return true; } } public class CartaoCredito implements MetodoPagamento { @Override public boolean pagar(double valor) { System.out.println("Pagando R$" + valor + " no cartão"); return true; } } MetodoPagamento[] metodos = { new Pix(), new CartaoCredito() }; for (MetodoPagamento m : metodos) m.pagar(100);
Crie duas interfaces, Voador e Nadador, cada uma com um método default String mover() retornando textos diferentes ("Voando" e "Nadando"). Crie a classe PatoSelvagem implements Voador, Nadador e resolva o conflito de assinatura sobrescrevendo mover() explicitamente para retornar "Voando e nadando".
public interface Voador { default String mover() { return "Voando"; } } public interface Nadador { default String mover() { return "Nadando"; } } public class PatoSelvagem implements Voador, Nadador { @Override public String mover() { return "Voando e nadando"; // obrigatório resolver o conflito manualmente // também seria possível chamar Voador.super.mover() para reusar uma delas } }
Modele livros, usuários e empréstimos. A regra central é impedir empréstimo simultâneo do mesmo exemplar e registrar devolução.
Até aqui, todo exemplo do curso viveu implicitamente em um único "pacote padrão". Em qualquer projeto real — e em todo projeto Spring Boot — o código é organizado em pacotes, que são pastas que refletem a estrutura de nomes totalmente qualificados das classes.
// arquivo: src/main/java/com/felipy/biblioteca/modelo/Livro.java package com.felipy.biblioteca.modelo; public class Livro { ... }
A declaração package precisa ser a primeira linha não-comentário do arquivo, e o caminho de pastas no disco precisa espelhar exatamente o nome do pacote. Para usar essa classe de outro pacote, você importa:
package com.felipy.biblioteca.servico; import com.felipy.biblioteca.modelo.Livro; // import explícito import com.felipy.biblioteca.modelo.*; // import de tudo do pacote (evite em projetos grandes) public class LivroServico { void processar(Livro livro) { ... } }
meu-projeto/ ├── pom.xml // configuração do Maven (capítulo 19) └── src/ ├── main/ │ ├── java/ │ │ └── com/felipy/app/ │ │ ├── Application.java │ │ ├── modelo/ │ │ ├── servico/ │ │ ├── repositorio/ │ │ └── controlador/ │ └── resources/ │ └── application.properties └── test/ └── java/ └── com/felipy/app/ // mesma estrutura de pacotes, para os testes
modelo/domain, servico/service, repositorio/repository, controlador/controller) — uma aplicação prática do SRP (capítulo 16) em nível de projeto, não só de classe. Entender essa convenção agora evita estranhar a estrutura de qualquer projeto Spring Boot no primeiro contato.Por convenção (não exigência do compilador), pacotes usam domínio invertido em minúsculas: com.empresa.projeto.modulo. Isso evita colisão de nomes entre bibliotecas de fornecedores diferentes — é por isso que classes do JDK ficam em java.util, java.io etc.
ValidadorInterno usada só dentro do pacote servico), sem precisar deixá-las totalmente public.
Pegue as classes do projeto final do capítulo 17 (ItemAcervo, Livro, Revista, Emprestavel, Biblioteca, ItemIndisponivelException) e distribua em pacotes: com.felipy.biblioteca.modelo (entidades), com.felipy.biblioteca.excecao (exceções) e com.felipy.biblioteca.servico (a classe Biblioteca). Escreva as declarações package e import necessárias em cada arquivo.
// com/felipy/biblioteca/modelo/ItemAcervo.java package com.felipy.biblioteca.modelo; import com.felipy.biblioteca.excecao.ItemIndisponivelException; public abstract class ItemAcervo implements Emprestavel { ... } // com/felipy/biblioteca/modelo/Livro.java package com.felipy.biblioteca.modelo; public class Livro extends ItemAcervo { ... } // com/felipy/biblioteca/excecao/ItemIndisponivelException.java package com.felipy.biblioteca.excecao; public class ItemIndisponivelException extends Exception { ... } // com/felipy/biblioteca/servico/Biblioteca.java package com.felipy.biblioteca.servico; import com.felipy.biblioteca.modelo.ItemAcervo; import com.felipy.biblioteca.excecao.ItemIndisponivelException; import java.util.List; import java.util.ArrayList; public class Biblioteca { ... }
Exceções em Java também são objetos — instâncias de classes que herdam de Throwable. Você pode criar suas próprias exceções especializando Exception, exatamente como especializaria qualquer outra classe.
Throwable ├── Error // problemas graves da JVM (OutOfMemoryError...) -- não trate └── Exception ├── RuntimeException // UNCHECKED -- erro de programação (NullPointerException...) │ ├── NullPointerException │ ├── ArrayIndexOutOfBoundsException │ ├── ArithmeticException │ ├── ClassCastException │ └── IllegalArgumentException └── (demais) // CHECKED -- condição esperada (IOException, SQLException...)
| Tipo | Compilador obriga tratar? | Quando usar |
|---|---|---|
Checked (estende Exception direto) | Sim — try/catch ou throws | Condições externas previsíveis: arquivo não encontrado, rede indisponível |
Unchecked (estende RuntimeException) | Não | Erros de programação que idealmente não deveriam acontecer: argumento inválido, índice fora do array |
public class SaldoInsuficienteException extends Exception { public SaldoInsuficienteException(String mensagem) { super(mensagem); } } public class ContaBancaria { private double saldo; public void sacar(double valor) throws SaldoInsuficienteException { if (valor > saldo) { throw new SaldoInsuficienteException("Saldo insuficiente para sacar R$" + valor); } saldo -= valor; } } try { conta.sacar(500); } catch (SaldoInsuficienteException e) { System.out.println("Erro: " + e.getMessage()); } finally { System.out.println("Operação finalizada"); // SEMPRE roda, com ou sem exceção }
Desde o Java 7, recursos que precisam ser fechados (arquivos, conexões) usam try-with-resources, que chama close() automaticamente, mesmo se uma exceção ocorrer:
try (BufferedReader br = new BufferedReader(new FileReader("dados.txt"))) { System.out.println(br.readLine()); } catch (IOException e) { System.out.println("Falha ao ler arquivo: " + e.getMessage()); } // br.close() é chamado automaticamente aqui, sem precisar de finally
Ao relançar uma exceção como outro tipo, preserve a causa original — perder a stack trace original é um dos erros mais frustrantes de depurar em produção.
try { conectarBanco(); } catch (SQLException e) { throw new RuntimeException("Falha ao processar pedido", e); // "e" vira a "cause" } // e.getCause() no catch externo recupera a SQLException original
catch (Exception e) {} vazio — isso é conhecido como "engolir exceção" e é uma das piores práticas em Java: o erro simplesmente desaparece, sem log, sem rastro, e o sistema segue rodando em um estado inconsistente até quebrar em outro lugar completamente diferente, tornando a causa raiz quase impossível de achar depois.Exception ou Throwable genericamente a menos que seja em uma borda muito específica do sistema (ex: um handler global de erros de uma API). Capturar o tipo mais específico possível deixa claro qual falha você realmente está tratando, e evita mascarar bugs inesperados como se fossem esperados.Crie IdadeInvalidaException (unchecked). No construtor de Pessoa, lance-a se a idade for negativa ou > 130. Teste em um try/catch.
public class IdadeInvalidaException extends RuntimeException { public IdadeInvalidaException(String msg) { super(msg); } } public class Pessoa { private String nome; private int idade; public Pessoa(String nome, int idade) { if (idade < 0 || idade > 130) throw new IdadeInvalidaException("Idade inválida: " + idade); this.nome = nome; this.idade = idade; } } try { new Pessoa("Felipy", 200); } catch (IdadeInvalidaException e) { System.out.println("Erro: " + e.getMessage()); }
Crie uma exceção base checked PedidoException e duas subclasses, EstoqueInsuficienteException e PagamentoRecusadoException. Escreva um método finalizarPedido(int qtd, boolean pagamentoOk) que lança a exceção adequada, e um main com um único bloco try e dois blocos catch específicos (um para cada subtipo), mais um catch (PedidoException e) genérico por último como rede de segurança.
public class PedidoException extends Exception { public PedidoException(String msg) { super(msg); } } public class EstoqueInsuficienteException extends PedidoException { public EstoqueInsuficienteException(String msg) { super(msg); } } public class PagamentoRecusadoException extends PedidoException { public PagamentoRecusadoException(String msg) { super(msg); } } static void finalizarPedido(int qtd, boolean pagamentoOk) throws PedidoException { if (qtd > 10) throw new EstoqueInsuficienteException("Estoque insuficiente"); if (!pagamentoOk) throw new PagamentoRecusadoException("Pagamento recusado"); System.out.println("Pedido finalizado!"); } // main: try { finalizarPedido(20, true); } catch (EstoqueInsuficienteException e) { System.out.println("Sem estoque: " + e.getMessage()); } catch (PagamentoRecusadoException e) { System.out.println("Pagamento falhou: " + e.getMessage()); } catch (PedidoException e) { System.out.println("Erro genérico de pedido: " + e.getMessage()); }
Repare que os catch mais específicos precisam vir antes do genérico — Java não compila se um catch de superclasse aparecer antes de um de subclasse, porque o primeiro nunca deixaria o segundo ser alcançado.
Arrays em Java têm tamanho fixo. Na prática, o dia a dia pede estruturas que crescem e encolhem — é para isso que existe o Java Collections Framework. As três interfaces mais usadas são List, Set e Map.
List<String> nomes = new ArrayList<>(); // programe contra a interface, instancie a implementação nomes.add("Ana"); nomes.add("Bruno"); nomes.add(0, "Zeca"); // insere na posição 0 nomes.remove("Bruno"); // remove pelo valor nomes.remove(0); // remove pelo índice -- CUIDADO com ambiguidade int vs Integer! System.out.println(nomes.get(0)); System.out.println(nomes.size()); System.out.println(nomes.contains("Ana")); for (String nome : nomes) System.out.println(nome); // for-each
lista.remove(1) remove o elemento no índice 1 se a lista é List<Integer> — mas para remover o valor 1, você precisa de lista.remove(Integer.valueOf(1)), forçando autoboxing. Essa ambiguidade entre remove(int) e remove(Object) já causou bugs sutis em produção.Set<String> tags = new HashSet<>(); tags.add("java"); tags.add("java"); // segunda chamada é ignorada System.out.println(tags.size()); // 1 Set<String> ordenado = new TreeSet<>(tags); // mantém ordem natural (alfabética) Set<String> insercao = new LinkedHashSet<>(tags); // mantém ordem de inserção
Lembre do capítulo 05: para HashSet reconhecer objetos "iguais" corretamente, equals() e hashCode() precisam estar implementados de forma coerente.
Map<String, Double> precos = new HashMap<>(); precos.put("café", 8.5); precos.put("chá", 6.0); precos.put("café", 9.0); // sobrescreve o valor anterior da mesma chave System.out.println(precos.get("café")); // 9.0 System.out.println(precos.getOrDefault("suco", 0.0)); // 0.0 -- não lança exceção for (Map.Entry<String, Double> e : precos.entrySet()) { System.out.println(e.getKey() + " -> " + e.getValue()); }
public class Produto implements Comparable<Produto> { String nome; double preco; @Override public int compareTo(Produto outro) { // ordenação "natural" -- só existe UMA return Double.compare(this.preco, outro.preco); } } Collections.sort(produtos); // usa compareTo // Comparator: quantas ordenações alternativas você quiser, fora da classe produtos.sort(Comparator.comparing(p -> p.nome)); produtos.sort(Comparator.comparingDouble((Produto p) -> p.preco).reversed());
Comparable quando existe uma ordem "óbvia e única" para o tipo (números por valor, datas cronologicamente). Use Comparator quando precisar de várias formas de ordenar o mesmo tipo dependendo do contexto — e prefira Comparator mesmo assim quando a ordenação for uma decisão de "quem está usando a lista", não do objeto em si, para não acoplar a classe a uma única forma de comparação.| Implementação | Ordem | Permite duplicata? | Quando usar |
|---|---|---|---|
ArrayList | Inserção | Sim | Acesso por índice frequente, uso geral |
LinkedList | Inserção | Sim | Muitas inserções/remoções nas pontas |
HashSet | Nenhuma garantida | Não | Checar existência rapidamente, sem ordem |
TreeSet | Ordenada | Não | Precisa de ordem + sem duplicata |
HashMap | Nenhuma garantida | Chaves não | Uso geral chave→valor |
LinkedHashMap | Inserção | Chaves não | Precisa lembrar ordem de inserção |
Use um Map<String, Integer> para representar um carrinho (produto → quantidade). Escreva um método adicionar(Map<String,Integer> carrinho, String produto, int qtd) que soma à quantidade existente se o produto já estiver no carrinho, ou insere se for novo (dica: getOrDefault).
static void adicionar(Map<String, Integer> carrinho, String produto, int qtd) { carrinho.put(produto, carrinho.getOrDefault(produto, 0) + qtd); } // main: Map<String, Integer> carrinho = new HashMap<>(); adicionar(carrinho, "café", 2); adicionar(carrinho, "café", 1); System.out.println(carrinho.get("café")); // 3
Usando a classe Livro (título, autor, páginas), crie uma List<Livro> com pelo menos 4 livros e ordene por paginas crescente usando Comparator.comparingInt. Depois ordene por autor e, em caso de empate, por titulo (dica: .thenComparing(...)).
livros.sort(Comparator.comparingInt(Livro::getPaginas)); livros.sort( Comparator.comparing(Livro::getAutor) .thenComparing(Livro::getTitulo) );
Escreva um for-each que tenta remover elementos pares de uma List<Integer> diretamente dentro do laço (lista.remove(...)). Rode e observe a exceção. Depois corrija usando um Iterator explícito com iterator.remove().
// ❌ lança ConcurrentModificationException: List<Integer> numeros = new ArrayList<>(List.of(1,2,3,4,5,6)); for (Integer n : numeros) { if (n % 2 == 0) numeros.remove(n); // modifica a lista durante a iteração } // ✅ correto -- Iterator sabe que a remoção está acontecendo: Iterator<Integer> it = numeros.iterator(); while (it.hasNext()) { if (it.next() % 2 == 0) it.remove(); }
O for-each usa um Iterator por baixo dos panos. Muitas coleções implementam detecção fail-fast de alterações estruturais por meio de um contador interno, frequentemente chamado modCount. Alterar a coleção fora do próprio iterator pode invalidar essa contagem e lançar a exceção. Isso ajuda a revelar uso incorreto, mas não oferece sincronização nem segurança entre threads.
Você já usou generics em List<String>. Agora vamos escrever suas próprias classes e métodos genéricos — código que funciona com qualquer tipo, mantendo segurança de tipo em tempo de compilação.
// SEM generics (como era antes do Java 5): Object genérico, cast manual, perigoso List lista = new ArrayList(); lista.add("texto"); lista.add(42); // compila! nada impede misturar tipos String s = (String) lista.get(1); // ClassCastException em RUNTIME, só descoberto ao rodar // COM generics: o compilador barra o erro antes de rodar List<String> lista2 = new ArrayList<>(); lista2.add("texto"); // lista2.add(42); // ❌ erro de COMPILAÇÃO -- muito mais barato que descobrir em produção
public class Caixa<T> { // T = "type parameter", convenção de nome de uma letra private T conteudo; public void guardar(T item) { this.conteudo = item; } public T abrir() { return conteudo; } } Caixa<String> caixaDeTexto = new Caixa<>(); caixaDeTexto.guardar("segredo"); String valor = caixaDeTexto.abrir(); // sem cast necessário!
public static <T> T primeiro(List<T> lista) { return lista.isEmpty() ? null : lista.get(0); } // uso: String s = primeiro(listaDeStrings); Integer i = primeiro(listaDeInteiros);
// T precisa ser algum tipo que implementa Comparable<T> public static <T extends Comparable<T>> T maior(List<T> lista) { T maior = lista.get(0); for (T item : lista) { if (item.compareTo(maior) > 0) maior = item; } return maior; }
// ? extends Numero: aceita listas de Numero OU QUALQUER SUBTIPO -- só leitura segura static double somarTodos(List<? extends Number> numeros) { double soma = 0; for (Number n : numeros) soma += n.doubleValue(); return soma; } // funciona com List<Integer>, List<Double>, List<Number>... // ? super Integer: aceita listas de Integer OU QUALQUER SUPERTIPO -- seguro para ESCRITA static void adicionarInteiros(List<? super Integer> lista) { lista.add(1); lista.add(2); }
? extends. Se a coleção vai receber dados que você insere (consumidora), use ? super. Isso não é regra decorada à toa: é literalmente como as próprias APIs de Collections.copy() e Stream do JDK são declaradas.new ArrayList<String>() e new ArrayList<Integer>() pertencem à mesma classe ArrayList. O compilador insere casts e preserva algumas assinaturas genéricas como metadados do arquivo .class, que reflection pode consultar em campos e métodos. A ausência de um tipo T reificado explica por que não se pode usar diretamente new T(), new T[10] ou obj instanceof List<String>.Crie uma classe genérica Par<A, B> (dois parâmetros de tipo!) com atributos primeiro e segundo, construtor, getters, e um método inverter() que retorna um novo Par<B, A> com os valores trocados.
public class Par<A, B> { private final A primeiro; private final B segundo; public Par(A primeiro, B segundo) { this.primeiro = primeiro; this.segundo = segundo; } public A getPrimeiro() { return primeiro; } public B getSegundo() { return segundo; } public Par<B, A> inverter() { return new Par<>(segundo, primeiro); } } // uso: Par<String,Integer> p = new Par<>("idade", 25); Par<Integer,String> inv = p.inverter();
Crie uma classe Repositorio<T> com uma List<T> interna e métodos salvar(T item), listar() (retorna cópia imutável, dica: List.copyOf(...)) e buscar(Predicate<T> filtro) retornando List<T> com os itens que satisfazem o filtro (pode usar um for manual se ainda não viu streams — veremos no próximo capítulo).
public class Repositorio<T> { private final List<T> itens = new ArrayList<>(); public void salvar(T item) { itens.add(item); } public List<T> listar() { return List.copyOf(itens); } public List<T> buscar(java.util.function.Predicate<T> filtro) { List<T> resultado = new ArrayList<>(); for (T item : itens) { if (filtro.test(item)) resultado.add(item); } return resultado; } } // uso: repo.buscar(livro -> livro.getPaginas() > 300);
Código Spring moderno (e Java moderno em geral) usa fartamente alguns recursos que vale dominar antes de mergulhar no framework.
// ❌ o chamador precisa "adivinhar" que pode vir null: public Usuario buscarPorId(int id) { return encontrado ? usuario : null; } // ✅ o TIPO já avisa: "isso pode não existir" public Optional<Usuario> buscarPorId(int id) { return encontrado ? Optional.of(usuario) : Optional.empty(); } // uso: Optional<Usuario> resultado = repositorio.buscarPorId(5); resultado.ifPresent(u -> System.out.println(u.getNome())); Usuario u = resultado.orElse(Usuario.ANONIMO); Usuario u2 = resultado.orElseThrow(() -> new UsuarioNaoEncontradoException(id));
Optional<T> em vez de T possivelmente nulo — que os métodos findById(...) do Spring Data JPA usam. Quando você vir Optional<Usuario> findById(Long id) em um repositório Spring, você já vai saber exatamente o que fazer com o retorno.Optional como tipo de campo de classe, parâmetro de método, ou dentro de coleções (List<Optional<T>>). Ele foi desenhado especificamente como tipo de retorno, para deixar explícito "isso pode não existir" na assinatura do método.var lista = new ArrayList<Livro>(); // o compilador infere: List<Livro>... na verdade ArrayList<Livro> for (var livro : lista) { ... } // livro é inferido como Livro // var só existe em variáveis LOCAIS -- nunca em campos, parâmetros ou retornos
var não torna Java "dinamicamente tipado" — o tipo ainda é fixado em compilação, só a escrita é opcional quando o tipo já é óbvio pelo lado direito da atribuição. Use com moderação: var x = calcular(); esconde o tipo de retorno e pode prejudicar a leitura.
LocalDate hoje = LocalDate.now(); LocalDate nascimento = LocalDate.of(1998, 5, 23); Period idade = Period.between(nascimento, hoje); System.out.println(idade.getYears() + " anos"); LocalDateTime agora = LocalDateTime.now(); LocalDateTime daqui1Hora = agora.plusHours(1); DateTimeFormatter formato = DateTimeFormatter.ofPattern("dd/MM/yyyy"); System.out.println(hoje.format(formato));
java.util.Date e java.util.Calendar ainda existem por compatibilidade, mas são mutáveis e possuem APIs propensas a erro. Em código novo, prefira LocalDate, LocalDateTime, Instant, OffsetDateTime e ZonedDateTime. Em integrações legadas, converta explicitamente na fronteira e mantenha o domínio em java.time.public record Coordenada(double latitude, double longitude) {} // gera automaticamente: construtor, getters (latitude(), longitude()), equals, hashCode, toString String json = """ { "nome": "Felipy", "cidade": "Custódia" } """; // text block -- string multilinha, útil para JSON/SQL de exemplo
Reescreva o método buscarPorTitulo da classe Biblioteca (capítulo 17) criando um novo método buscarPorCodigo(String codigo) que retorna Optional<ItemAcervo> em vez de lançar exceção quando não encontra. Teste os três caminhos: orElseThrow, orElse e ifPresent.
public Optional<ItemAcervo> buscarPorCodigo(String codigo) { return acervo.stream() .filter(i -> i.getCodigo().equals(codigo)) .findFirst(); // findFirst() JÁ retorna Optional<T> } // uso: biblioteca.buscarPorCodigo("L001").ifPresent(i -> System.out.println(i.descricaoCompleta())); ItemAcervo item = biblioteca.buscarPorCodigo("L999") .orElseThrow(() -> new NoSuchElementException("Item não encontrado"));
Uma interface funcional tem exatamente um método abstrato (pode ter vários default/static). É esse formato que permite escrever lambdas — funções anônimas curtas.
@FunctionalInterface public interface Operacao { int aplicar(int a, int b); } // forma "clássica" com classe anônima: Operacao soma1 = new Operacao() { @Override public int aplicar(int a, int b) { return a + b; } }; // mesma coisa com lambda -- muito mais direto: Operacao soma2 = (a, b) -> a + b; System.out.println(soma2.aplicar(2, 3)); // 5
| Interface | Assinatura | Uso típico |
|---|---|---|
Predicate<T> | boolean test(T t) | Filtros (stream.filter(...)) |
Function<T,R> | R apply(T t) | Transformação (stream.map(...)) |
Consumer<T> | void accept(T t) | Efeito colateral (stream.forEach(...)) |
Supplier<T> | T get() | Fornecer valor sob demanda (lazy) |
BiFunction<T,U,R> | R apply(T t, U u) | Combinar dois valores |
livros.forEach(System.out::println); // referência a método de instância livros.stream().map(Livro::getTitulo); // referência a método de instância "não vinculado" List<String> nomes = List.of("a","b").stream().map(String::toUpperCase).toList();
Um Stream não é uma estrutura de dados — é um pipeline de operações sobre uma fonte de dados (geralmente uma coleção), avaliado de forma preguiçosa (lazy) e, em geral, sem alterar a coleção original.
List<Livro> livros = ...; List<String> titulosGrandes = livros.stream() .filter(l -> l.getPaginas() > 300) // operação intermediária: mantém só alguns .sorted(Comparator.comparing(Livro::getTitulo)) .map(Livro::getTitulo) // operação intermediária: transforma o tipo .collect(Collectors.toList()); // operação TERMINAL: dispara o processamento double mediaPaginas = livros.stream() .mapToInt(Livro::getPaginas) .average() .orElse(0); Map<String, List<Livro>> porAutor = livros.stream() .collect(Collectors.groupingBy(Livro::getAutor));
filter, map, sorted...) realmente executa até que uma operação terminal (collect, forEach, count, reduce...) seja chamada. Isso permite otimizações — por exemplo, filter().findFirst() pode parar no primeiro item que bate, sem processar a coleção inteira. Também significa que um Stream só pode ser consumido uma vez: chamar uma segunda operação terminal no mesmo stream lança IllegalStateException.for tradicional costuma ser mais legível e até mais rápido (sem overhead de boxing/lambdas). Streams brilham quando há uma cadeia real de transformações (filtrar → mapear → agrupar → coletar) que ficaria confusa em loops aninhados.int total = List.of(1,2,3,4).stream() .reduce(0, (acumulado, atual) -> acumulado + atual); // 10 // map/filter/collect nada mais são que casos especiais de reduce por baixo dos panos
Dada uma List<Livro>, use streams para obter uma List<String> apenas com os títulos dos livros com mais de 200 páginas, em ordem alfabética.
List<String> resultado = livros.stream() .filter(l -> l.getPaginas() > 200) .map(Livro::getTitulo) .sorted() .collect(Collectors.toList());
Dada uma List<Livro>, produza um Map<String, Long> com a contagem de livros por autor, usando Collectors.groupingBy combinado com Collectors.counting(). Depois, encontre o autor com mais livros usando reduce ou Stream.max sobre as entradas do map.
Map<String, Long> contagemPorAutor = livros.stream() .collect(Collectors.groupingBy(Livro::getAutor, Collectors.counting())); String autorComMais = contagemPorAutor.entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse("nenhum");
Aplicações reais precisam atravessar a fronteira entre memória e recursos externos. Em Java moderno, Path representa caminhos e Files concentra operações comuns de leitura, escrita, cópia, movimentação e inspeção.
Path arquivo = Path.of("dados", "pedidos.csv"); List<String> linhas = Files.readAllLines(arquivo, StandardCharsets.UTF_8); try (Stream<String> stream = Files.lines(arquivo, StandardCharsets.UTF_8)) { long validas = stream.filter(linha -> !linha.isBlank()).count(); }
readAllLines carrega tudo na memória e serve para arquivos pequenos. Files.lines permite processamento gradual, mas mantém um recurso aberto e exige fechamento. O try-with-resources garante isso mesmo quando ocorre uma exceção.
Bytes são dados brutos; texto depende de uma codificação que converte bytes em caracteres. Declare UTF-8 explicitamente quando o formato exigir. Confiar no padrão da máquina torna o comportamento diferente entre ambientes.
IOException e continue como se nada tivesse ocorrido. Decida se a operação pode ser repetida, convertida em erro de domínio ou propagada com contexto.Leia um arquivo grande linha a linha, conte ocorrências por nível e grave um resumo em outro arquivo. Prove que arquivos inexistentes, linhas malformadas e diretório de saída ausente recebem tratamentos diferentes.
Partindo de uma coleção de vendas, gere rankings, agrupamentos e métricas. Implemente primeiro com laços e depois com Streams; compare legibilidade e custo.
O capítulo 34 já mostrou EXPLAIN ANALYZE comparando Seq Scan com Index Scan. Big O é a linguagem formal para descrever exatamente esse tipo de diferença de performance — não em milissegundos (que variam por máquina), mas em como o tempo cresce conforme o tamanho da entrada cresce.
| Notação | Nome | Exemplo |
|---|---|---|
O(1) | Constante | Acessar array[5], ou HashMap.get(chave) — capítulo 11 |
O(log n) | Logarítmica | Busca binária, TreeMap, um índice de banco (capítulo 34) |
O(n) | Linear | Percorrer uma lista uma vez — um for simples |
O(n log n) | Log-linear | Algoritmos de ordenação eficientes (capítulo 83) |
O(n²) | Quadrática | Loop dentro de loop sobre a mesma coleção |
O(2ⁿ) | Exponencial | Recursão ingênua de Fibonacci (capítulo 81) |
// O(1) -- não importa o tamanho de "lista", sempre um passo: int primeiro = lista.get(0); // O(n) -- um passo POR elemento: for (int x : lista) { System.out.println(x); } // O(n²) -- para cada elemento, percorre TODOS de novo -- CUIDADO: for (int a : lista) { for (int b : lista) { if (a == b) System.out.println("achou par"); } }
O(1) mas você faz isso dentro de um loop de N livros, o custo total vira O(n) chamadas ao banco, cada uma com seu próprio custo de rede, em vez de O(1) chamada com um JOIN. Big O não é só teoria acadêmica — é literalmente o vocabulário formal para explicar por que aquele bug de performance específico acontece.Big O também mede memória usada, não só tempo de execução — um algoritmo pode ser rápido (O(n) em tempo) mas gastar memória proporcional ao tamanho da entrada (O(n) em espaço) para conseguir isso, um trade-off clássico entre os dois recursos.
O(n²) rodando em uma coleção de 50 itens é instantâneo na prática — otimizar isso "porque O(n²) é ruim" sem medir é desperdiçar esforço em algo que nunca seria um problema real. Big O importa quando n é (ou pode vir a ser) genuinamente grande — a mesma lição prática do EXPLAIN ANALYZE do capítulo 34: meça antes de otimizar.O(n). Se é "para cada elemento, eu olho todos os outros de novo" → provavelmente O(n²). Esse hábito de perguntar resolve a maioria das análises do dia a dia sem precisar de matemática formal.Para cada trecho, identifique a complexidade Big O e justifique: (a) buscar um elemento em um HashSet; (b) verificar se um array tem elementos duplicados usando dois loops aninhados; (c) o mesmo problema (b), mas usando um HashSet auxiliar para rastrear valores já vistos.
(a) O(1) em média — HashSet usa hashing como HashMap; colisões, distribuição ruim e redimensionamento impedem tratar isso como garantia absoluta. (b) O(n²) — para cada elemento, compara com os demais. (c) O(n) esperado — percorre uma vez e usa consultas/inserções de custo médio constante, trocando memória adicional por tempo.
Um método que chama a si mesmo. Parece paradoxal na primeira vez que se vê, mas é a ferramenta natural para problemas que são, por definição, feitos de versões menores de si mesmos.
int fatorial(int n) { if (n <= 1) return 1; // CASO BASE -- sem isso, recursão infinita return n * fatorial(n - 1); // CASO RECURSIVO -- problema menor + chamada a si mesmo } // fatorial(4) = 4 * fatorial(3) // = 4 * (3 * fatorial(2)) // = 4 * (3 * (2 * fatorial(1))) // = 4 * (3 * (2 * 1)) // = 24
StackOverflowError, porque cada chamada recursiva empilha um novo frame na stack (capítulo 01) até estourar a memória reservada para ela.fatorial(4) não retorna nada até que fatorial(3) retorne, que não retorna até fatorial(2) retornar, e assim por diante — só quando o caso base é alcançado é que a pilha começa a ser "desempilhada", de baixo para cima, cada chamada multiplicando seu resultado antes de devolver para quem a chamou.// versão iterativa do mesmo fatorial (capítulo 79, raciocínio de loop): int fatorialIterativo(int n) { int resultado = 1; for (int i = 2; i <= n; i++) resultado *= i; return resultado; } // mesmo resultado, SEM o custo de empilhar N frames na stack
| Recursão | Iteração | |
|---|---|---|
| Legibilidade | Frequentemente mais clara para problemas naturalmente recursivos (árvores, capítulo 82) | Mais direta para contagens simples |
| Custo de memória | Uma stack frame por chamada — pode estourar em entradas grandes | Não empilha nada extra |
| Bom para | Estruturas recursivas por natureza: árvores, backtracking, "dividir para conquistar" | Contagens e transformações lineares simples |
fibonacci(n) = fibonacci(n-1) + fibonacci(n-2) parece elegante, mas recalcula os mesmos subproblemas repetidamente, chegando a O(2ⁿ) — para n=40, isso já são bilhões de chamadas. A correção (guardar resultados já calculados, técnica chamada memoization) transforma isso em O(n), e é o primeiro contato prático que muita gente tem com programação dinâmica.// ❌ ingênua -- O(2ⁿ), recalcula os mesmos valores repetidamente: int fibonacci(int n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } // ✅ com memoization -- O(n), guarda resultados já calculados: Map<Integer, Long> cache = new HashMap<>(); long fibonacciMemo(int n) { if (n <= 1) return n; if (cache.containsKey(n)) return cache.get(n); // já calculado, não refaz long resultado = fibonacciMemo(n - 1) + fibonacciMemo(n - 2); cache.put(n, resultado); return resultado; }
Escreva um método recursivo somaDigitos(int n) que soma todos os dígitos de um número (ex: somaDigitos(1234) retorna 1+2+3+4=10). Identifique claramente o caso base e o caso recursivo.
int somaDigitos(int n) { if (n < 10) return n; // caso base: número de um único dígito return (n % 10) + somaDigitos(n / 10); // último dígito + soma do resto } // somaDigitos(1234) = 4 + somaDigitos(123) // = 4 + (3 + somaDigitos(12)) // = 4 + (3 + (2 + somaDigitos(1))) // = 4 + 3 + 2 + 1 = 10
Implemente fibonacci das duas formas mostradas acima. Conte chamadas para diferentes valores de n e observe a curva de crescimento. Se medir tempo, use System.nanoTime() somente como experimento inicial e depois reproduza com JMH, pois aquecimento do JIT e otimizações distorcem medições isoladas.
A versão ingênua cresce exponencialmente em número de chamadas, enquanto memoization calcula cada subproblema uma vez. O tempo absoluto de n=35 varia com hardware, JVM e aquecimento; a evidência correta é a curva e a contagem, não prometer uma duração específica.
Collections.sort(...) e list.sort(comparator) (capítulo 11) já resolvem ordenação na prática — mas entender como por trás dos panos ajuda a reconhecer trade-offs de performance em situações que a biblioteca padrão não cobre perfeitamente.
int buscaBinaria(int[] ordenado, int alvo) { int inicio = 0, fim = ordenado.length - 1; while (inicio <= fim) { int meio = inicio + (fim - inicio) / 2; // evita overflow de (inicio+fim)/2 em arrays gigantes if (ordenado[meio] == alvo) return meio; else if (ordenado[meio] < alvo) inicio = meio + 1; // descarta metade esquerda else fim = meio - 1; // descarta metade direita } return -1; // não encontrado } // O(log n) -- SÓ funciona se o array já estiver ORDENADO
int[] mergeSort(int[] array) { if (array.length <= 1) return array; // caso base -- 0 ou 1 elemento já está "ordenado" int meio = array.length / 2; int[] esquerda = mergeSort(Arrays.copyOfRange(array, 0, meio)); int[] direita = mergeSort(Arrays.copyOfRange(array, meio, array.length)); return mesclar(esquerda, direita); // combina os dois já ordenados } int[] mesclar(int[] esq, int[] dir) { int[] resultado = new int[esq.length + dir.length]; int i = 0, j = 0, k = 0; while (i < esq.length && j < dir.length) { resultado[k++] = (esq[i] <= dir[j]) ? esq[i++] : dir[j++]; } while (i < esq.length) resultado[k++] = esq[i++]; while (j < dir.length) resultado[k++] = dir[j++]; return resultado; }
| Algoritmo | Complexidade média | Observação |
|---|---|---|
| Bubble Sort | O(n²) | Simples de entender, péssimo em dados grandes — raramente usado na prática |
| Merge Sort | O(n log n) | Estável, previsível, usa memória extra |
| Quick Sort | O(n log n) médio, O(n²) pior caso | Rápido na prática, in-place (sem memória extra significativa) |
Collections.sort() do Java | O(n log n) | Timsort (híbrido merge+insertion), a escolha certa para 99% dos casos reais |
Collections.sort() e Arrays.sort() já usam Timsort, uma implementação extremamente otimizada e testada por décadas. O valor de entender Merge Sort não é reimplementá-lo em produção, é desenvolver a intuição de "dividir para conquistar" — a mesma técnica reaparece resolvendo problemas completamente diferentes (busca binária, algoritmos de processamento distribuído como MapReduce).Collections.sort() foi otimizado, testado e endurecido contra casos-limite por milhares de desenvolvedores ao longo de anos. Reimplementar isso quase sempre resulta em algo mais lento e com mais bugs do que a biblioteca padrão.Collections.sort()/list.sort(comparator) (capítulo 11) no resto da sua carreira — o objetivo do exercício é entendimento, não reuso.Implemente mergeSort, valide-o contra Arrays.sort() em arrays vazios, ordenados, reversos e com duplicatas. Depois crie um benchmark JMH com dados copiados a cada invocação e explique por que algoritmos de mesma classe assintótica podem ter desempenho diferente.
Arrays.sort() é altamente otimizado, mas o algoritmo depende do tipo: arrays de objetos usam uma ordenação estável baseada em TimSort, enquanto arrays primitivos usam algoritmos especializados, como dual-pivot quicksort em vários casos. Big O descreve crescimento assintótico, não constantes, alocações, localidade de cache ou adaptação à entrada.
A partir daqui o curso entra em ferramentas que cercam o código Java, não a linguagem em si. Git é pré-requisito de tudo que vem depois — CI/CD, deploy, trabalho em equipe — porque é o sistema de controle de versão que registra o histórico do seu projeto.
# baixe o instalador em git-scm.com e siga o wizard, ou via winget:
winget install --id Git.Git -e --source winget# via Homebrew:
brew install git# Debian/Ubuntu: sudo apt update && sudo apt install git # Fedora: sudo dnf install git
# confirme a instalação e configure identidade (uma vez só, por máquina):
git --version
git config --global user.name "Felipy Santos"
git config --global user.email "seu-email@exemplo.com"
git init # cria um repositório novo na pasta atual git status # o que mudou desde o último commit git add arquivo.java # move para a "staging area" (o que vai entrar no próximo commit) git add . # adiciona TUDO que mudou git commit -m "Adiciona classe Livro" # registra um snapshot permanente git log --oneline # histórico resumido
git branch feature/emprestimo # cria uma branch nova git checkout feature/emprestimo # muda para ela git checkout -b feature/emprestimo # cria E muda, em um comando só # depois de terminar e commitar na branch: git checkout main git merge feature/emprestimo # traz as mudanças de volta para main
.gitignore sempre configurado antes do primeiro commit — target/, .env, *.class, credenciais nunca vão para o repositório.git push --force em uma branch compartilhada (main/develop) — isso reescreve o histórico que outras pessoas já têm localmente.Um Pull Request (PR) é um pedido para mesclar sua branch na branch principal, geralmente com revisão de código de outra pessoa antes de aceitar. É a prática padrão em qualquer time — mesmo sozinho, vale simular esse fluxo para manter main sempre funcional.
git merge feature/x # cria um commit de merge, preserva o histórico exato de cada branch git rebase main # reescreve os commits da sua branch como se tivessem partido do main atual -- histórico linear, mas reescreve commits
.gitignore é o motivo real de nunca vazar uma senha de banco de produção sem querer: sem ele, é fácil dar git add . distraidamente e commitar um application-prod.properties com credenciais reais. Isso conecta direto com o capítulo 27 (configuração externa) e com o capítulo de Secrets mais adiante — a defesa em profundidade começa aqui, no controle de versão.Inicialize um repositório Git para o projeto da biblioteca (capítulos 17/18). Crie um .gitignore ignorando target/, *.class e .env. Faça o commit inicial. Crie uma branch feature/notificacao-sms, adicione (mesmo que só como esqueleto) uma classe SmsServico, commit, volte para main e faça o merge.
git init echo "target/ *.class .env" > .gitignore git add . git commit -m "Commit inicial: estrutura do projeto biblioteca" git checkout -b feature/notificacao-sms # cria SmsServico.java... git add . git commit -m "Adiciona esqueleto de SmsServico" git checkout main git merge feature/notificacao-sms
Até agora, todo exemplo assumiu que você compila com javac na mão. Projetos reais dependem de bibliotecas externas (JUnit, Mockito, e mais tarde o próprio Spring) — baixar e gerenciar isso manualmente não escala. Uma build tool resolve três problemas: gerenciar dependências, compilar/testar/empacotar o projeto com um único comando, e garantir que o build seja reproduzível em qualquer máquina.
<!-- pom.xml -->
<project>
<groupId>com.felipy</groupId>
<artifactId>biblioteca</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
mvn compile # compila o código em src/main/java mvn test # roda os testes de src/test/java mvn package # gera um .jar (ou .war) em target/ mvn clean # apaga o diretório target/
// build.gradle.kts (Kotlin DSL, padrão em projetos novos) plugins { java } group = "com.felipy" version = "1.0.0" repositories { mavenCentral() } dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") }
./gradlew build # compila, testa e empacota ./gradlew test # só roda os testes ./gradlew run # executa a aplicação (com o plugin "application")
| Maven | Gradle | |
|---|---|---|
| Formato | XML declarativo (pom.xml) | Script (Groovy ou Kotlin DSL) |
| Verbosidade | Mais verboso | Mais enxuto |
| Velocidade de build | Boa | Geralmente mais rápido (cache incremental) |
| Onde aparece mais | Projetos corporativos legados, Spring Initializr (opção padrão) | Projetos Android, projetos novos que priorizam flexibilidade |
pom.xml ou build.gradle como os de cima, com as dependências do Spring já declaradas. Entender a estrutura desses arquivos antes de ver o Spring Initializr transforma aquela tela de "mágica" em "ah, é só isso".<scope>compile</scope> <!-- padrão: disponível em compilação, teste e runtime --> <scope>test</scope> <!-- só disponível ao rodar testes (ex: JUnit, Mockito) --> <scope>provided</scope> <!-- disponível em compilação, mas fornecida pelo ambiente em runtime -->
<scope>test</scope>. Isso funciona, mas empacota bibliotecas de teste dentro do .jar de produção, aumentando o tamanho do artefato final sem necessidade.Escreva um pom.xml mínimo e válido para o projeto da biblioteca (capítulo 17/18), com groupId, artifactId, version, compilando para Java 21, e com JUnit 5 como dependência de escopo test.
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.felipy</groupId>
<artifactId>biblioteca</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Você já usa anotações desde o capítulo 05 (@Override) e capítulo 15 (@Test). Este capítulo explica o que elas realmente são e como o Spring lê @Autowired, @Service ou @RestController e faz "mágica" com eles — a resposta é reflection.
@Retention(RetentionPolicy.RUNTIME) // precisa existir em runtime para ser lida via reflection @Target(ElementType.METHOD) // só pode ser usada em métodos public @interface LogExecucao { String valor() default ""; // "atributo" da anotação, com valor padrão } public class Servico { @LogExecucao("processarPedido") public void processar() { System.out.println("processando..."); } }
Sozinha, uma anotação não faz nada — ela é só metadado. É preciso um código separado que lê essa anotação via reflection e decide o que fazer com ela. É exatamente isso que frameworks como o Spring fazem por trás dos panos.
Class<?> classe = Servico.class; for (Method metodo : classe.getDeclaredMethods()) { if (metodo.isAnnotationPresent(LogExecucao.class)) { LogExecucao anotacao = metodo.getAnnotation(LogExecucao.class); System.out.println("Método " + metodo.getName() + " marcado com valor: " + anotacao.valor()); } } // reflection também permite criar objetos e chamar métodos dinamicamente, // sem "new" explícito no código -- é assim que o Spring instancia seus beans: Servico instancia = (Servico) classe.getDeclaredConstructor().newInstance(); Method m = classe.getMethod("processar"); m.invoke(instancia); // chama servico.processar() dinamicamente
@Service em uma classe e @Autowired em um campo, o Spring, na inicialização da aplicação, faz um scan via reflection em todos os pacotes configurados, procurando classes anotadas. Para cada uma, ele cria a instância (às vezes chamada de bean) usando reflection, e para cada campo @Autowired encontrado, ele injeta a dependência certa — também via reflection, chamando Field.set(objeto, valor) por baixo dos panos. Não existe mágica: é reflection, aplicada em escala, orquestrada por um container.Tudo em reflection começa por um objeto Class<?>, que é a representação em runtime de um tipo. Existem três formas de obtê-lo:
Class<?> c1 = Livro.class; // quando você já conhece o tipo em compilação Class<?> c2 = meuLivro.getClass(); // a partir de uma instância já existente Class<?> c3 = Class.forName("com.felipy.Livro"); // pelo NOME, como String -- usado por drivers JDBC e frameworks
Class<?> c = Livro.class; c.getName(); // "com.felipy.Livro" -- nome totalmente qualificado c.getSimpleName(); // "Livro" c.getSuperclass(); // a classe pai (Object, se não houver extends explícito) c.getInterfaces(); // array de interfaces implementadas c.isInterface(); // false -- e existe isEnum(), isArray(), isAnnotation()... // campos: getFields() só PUBLIC (inclusive herdados); getDeclaredFields() TODOS, só desta classe for (Field f : c.getDeclaredFields()) { System.out.println(f.getName() + " : " + f.getType()); } // o mesmo padrão vale para métodos e construtores: c.getMethods(); // públicos, incluindo herdados c.getDeclaredMethods(); // todos os declarados NESTA classe, qualquer modificador c.getDeclaredConstructors(); // todos os construtores declarados
get* vs getDeclared* é a pegadinha número um de quem começa em reflection: getFields() só enxerga membros public (inclusive herdados de superclasses), enquanto getDeclaredFields() enxerga todos os membros declarados diretamente na classe — private incluído — mas não os herdados. Para inspecionar uma hierarquia inteira de forma completa (como o Spring faz ao procurar campos @Autowired em qualquer nível), é preciso subir manualmente com getSuperclass() em loop.Field campoPrivado = Livro.class.getDeclaredField("titulo"); campoPrivado.setAccessible(true); // "quebra" o encapsulamento -- use com responsabilidade! String valor = (String) campoPrivado.get(meuLivro); campoPrivado.set(meuLivro, "Novo Título"); // altera mesmo sendo private // criando um objeto sem "new" explícito no código-fonte: Constructor<Livro> construtor = Livro.class.getDeclaredConstructor(String.class, String.class, int.class); Livro instancia = construtor.newInstance("1984", "Orwell", 328); // chamando um método privado dinamicamente: Method metodoPrivado = Livro.class.getDeclaredMethod("validar"); metodoPrivado.setAccessible(true); metodoPrivado.invoke(instancia);
setAccessible(true) é poderoso e perigoso: ele contorna private/protected deliberadamente, quebrando encapsulamento (capítulo 04) por completo. Frameworks usam isso de forma controlada e bem testada; código de aplicação normal quase nunca deveria precisar disso. Desde o sistema de módulos do Java 9+ (JPMS), módulos podem inclusive bloquear esse acesso para pacotes não explicitamente abertos — mais uma camada de proteção contra o uso indevido dessa API.Um proxy dinâmico cria, em tempo de execução, uma implementação de uma interface que intercepta toda chamada de método antes de (opcionalmente) delegar para o objeto real. É assim que o Spring implementa recursos como @Transactional e logging automático (AOP — Programação Orientada a Aspectos) sem exigir que você escreva código repetitivo em cada método.
interface Servico { void executar(); } class ServicoReal implements Servico { @Override public void executar() { System.out.println("Executando lógica de negócio"); } } Servico real = new ServicoReal(); Servico proxy = (Servico) Proxy.newProxyInstance( Servico.class.getClassLoader(), new Class<?>[]{ Servico.class }, (proxyObj, metodo, args) -> { System.out.println("[LOG] Antes de " + metodo.getName()); // código "injetado" ao redor da chamada Object resultado = metodo.invoke(real, args); // delega para o objeto real System.out.println("[LOG] Depois de " + metodo.getName()); return resultado; } ); proxy.executar(); // [LOG] Antes de executar // Executando lógica de negócio // [LOG] Depois de executar
@Transactional no Spring, o objeto que você realmente recebe do container não é a sua classe original — é um proxy dinâmico exatamente como o de cima, gerado automaticamente na inicialização. Esse proxy abre a transação, chama seu método de verdade via reflection, e faz commit ou rollback dependendo se uma exceção foi lançada. É por isso que @Transactional não funciona quando um método chama outro método da mesma classe diretamente (this.outroMetodo()) — essa chamada nunca passa pelo proxy, então nunca é interceptada.| Anotação | Para que serve |
|---|---|
@Override | Confirma sobrescrita de método — erro de compilação se não for uma sobrescrita de fato |
@Deprecated | Marca um elemento como obsoleto, gera aviso ao usar |
@SuppressWarnings | Silencia um aviso específico do compilador |
@FunctionalInterface | Confirma que a interface tem exatamente um método abstrato (capítulo 13) |
Escreva um método inspecionar(Object objeto) que recebe qualquer objeto e imprime: o nome completo da classe, o nome da superclasse, e o nome e tipo de cada campo declarado (mesmo os private) — usando apenas getClass(), getDeclaredFields() e um for.
static void inspecionar(Object objeto) { Class<?> classe = objeto.getClass(); System.out.println("Classe: " + classe.getName()); System.out.println("Superclasse: " + classe.getSuperclass().getName()); for (Field f : classe.getDeclaredFields()) { System.out.println(" campo: " + f.getName() + " (" + f.getType().getSimpleName() + ")"); } } // inspecionar(new Livro("1984", "Orwell", 328));
Crie uma anotação @NaoNulo aplicável a campos (ElementType.FIELD), com retenção em runtime. Escreva uma classe utilitária Validador com um método estático validar(Object objeto) que usa reflection para percorrer todos os campos do objeto, e lança IllegalStateException se algum campo anotado com @NaoNulo estiver null (dica: campo.setAccessible(true) antes de campo.get(objeto) para acessar campos private).
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface NaoNulo {} public class Validador { public static void validar(Object objeto) throws IllegalAccessException { for (Field campo : objeto.getClass().getDeclaredFields()) { if (campo.isAnnotationPresent(NaoNulo.class)) { campo.setAccessible(true); if (campo.get(objeto) == null) { throw new IllegalStateException("Campo obrigatório nulo: " + campo.getName()); } } } } } // uso: class Usuario { @NaoNulo String nome; String apelido; // não obrigatório } Usuario u = new Usuario(); Validador.validar(u); // lança IllegalStateException: "nome" é null
Isso é essencialmente uma versão simplificada do que o Bean Validation (@NotNull do Jakarta/Spring) faz por trás dos panos.
Toda API REST (próximo capítulo) troca dados em JSON. O Spring Boot usa a biblioteca Jackson para converter automaticamente objetos Java em JSON (serialização) e vice-versa (desserialização) — e isso é feito, mais uma vez, via reflection.
public class Livro { private String titulo; private String autor; private int paginas; // getters obrigatórios -- é assim que o Jackson DESCOBRE os campos a serializar public String getTitulo() { return titulo; } public String getAutor() { return autor; } public int getPaginas() { return paginas; } } ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(new Livro("1984", "Orwell", 328)); // {"titulo":"1984","autor":"Orwell","paginas":328}
private diretamente — por padrão, ele usa introspecção via getters/setters (o padrão JavaBeans: qualquer getX() vira a propriedade JSON "x"). É por isso que uma classe sem getters é serializada como {} vazio, mesmo tendo campos preenchidos — um erro clássico de quem está começando com Spring e não entende por que o JSON de resposta veio vazio.String json = """ {"titulo": "Dom Casmurro", "autor": "Machado de Assis", "paginas": 256} """; Livro livro = mapper.readValue(json, Livro.class); // Jackson usa reflection para: 1) criar o objeto via construtor sem args, // 2) chamar os setters correspondentes a cada campo do JSON
@JsonCreator para funcionar.public class Usuario { private String nome; @JsonIgnore // nunca aparece no JSON de saída (ex: senha) private String senha; @JsonProperty("nome_completo") // renomeia o campo no JSON public String getNome() { return nome; } }
public record LivroDTO(String titulo, String autor, int paginas) {} // Jackson serializa/desserializa records automaticamente desde a versão 2.12+ -- // sem precisar de getters "get*" (usa os métodos de acesso do record: titulo(), autor()...)
Crie um record LivroDTO(String titulo, String autor, int paginas). Usando ObjectMapper (biblioteca Jackson), serialize uma instância para JSON e imprima o resultado. Depois, desserialize uma String JSON de volta para LivroDTO e imprima os campos.
public record LivroDTO(String titulo, String autor, int paginas) {} ObjectMapper mapper = new ObjectMapper(); LivroDTO dto = new LivroDTO("O Alquimista", "Paulo Coelho", 208); String json = mapper.writeValueAsString(dto); System.out.println(json); // {"titulo":"O Alquimista","autor":"Paulo Coelho","paginas":208} LivroDTO devolta = mapper.readValue(json, LivroDTO.class); System.out.println(devolta.titulo() + " - " + devolta.autor());
println não tem nível de severidade, não pode ser desligado seletivamente, não vai para arquivo automaticamente, e não tem timestamp nem contexto. Um framework de logging (SLF4J + Logback, o padrão de fato usado pelo Spring Boot) resolve tudo isso.
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class PedidoServico { private static final Logger log = LoggerFactory.getLogger(PedidoServico.class); public void finalizar(Pedido p) { log.info("Finalizando pedido {}", p.getId()); // placeholder "{}", não concatenação try { // ... } catch (PagamentoRecusadoException e) { log.error("Falha ao processar pagamento do pedido {}", p.getId(), e); // exception vai no final -- imprime a stack trace } } }
| Nível | Quando usar |
|---|---|
TRACE | Detalhe extremo, só em depuração profunda |
DEBUG | Informação útil durante desenvolvimento, desligada em produção |
INFO | Eventos relevantes do fluxo normal ("pedido criado", "aplicação iniciada") |
WARN | Algo inesperado, mas não quebrou o fluxo |
ERROR | Falha real que precisa de atenção |
log.info("Pedido {}", id) em vez de log.info("Pedido " + id) não é só estilo: a concatenação de String acontece sempre, mesmo que o nível INFO esteja desligado, desperdiçando processamento. Com o placeholder {}, o framework só monta a mensagem final se aquele nível estiver realmente ativo — relevante em métodos chamados milhares de vezes por segundo, típico de uma API em produção.URLs de banco, senhas, chaves de API e afins nunca devem estar escritos diretamente no código-fonte. O padrão é externalizar em arquivos de configuração (Spring Boot usa application.properties ou application.yml) ou variáveis de ambiente.
# application.properties (formato chave=valor) servidor.porta=8080 banco.url=jdbc:postgresql://localhost:5432/biblioteca banco.usuario=${DB_USER} # ${...} lê de variável de ambiente banco.senha=${DB_PASSWORD}
# application.yml (formato hierárquico, também aceito pelo Spring Boot) servidor: porta: 8080 banco: url: jdbc:postgresql://localhost:5432/biblioteca usuario: ${DB_USER} senha: ${DB_PASSWORD}
Lendo um arquivo .properties em Java puro (o que o Spring Boot faz automaticamente por trás dos panos):
Properties props = new Properties(); try (InputStream input = new FileInputStream("config.properties")) { props.load(input); } String porta = props.getProperty("servidor.porta");
application-dev.properties, application-prod.properties — trocando o banco de dados, nível de log, etc. entre desenvolvimento e produção sem alterar uma linha de código Java.Pegue a classe Biblioteca (com a injeção de NotificadorEmprestimo do capítulo 22) e substitua qualquer System.out.println por chamadas a um Logger SLF4J: log.info(...) para o empréstimo bem-sucedido e log.warn(...) quando o item não estiver disponível (antes de lançar a exceção).
public class Biblioteca { private static final Logger log = LoggerFactory.getLogger(Biblioteca.class); // ... campos existentes ... public void emprestar(String codigo) throws ItemIndisponivelException { ItemAcervo item = acervo.stream() .filter(i -> i.getCodigo().equals(codigo)) .findFirst() .orElseThrow(() -> new ItemIndisponivelException("Item não encontrado")); if (!item.disponivelParaEmprestimo()) { log.warn("Tentativa de emprestar item indisponível: {}", codigo); throw new ItemIndisponivelException("Item já emprestado"); } item.emprestar(); log.info("Item emprestado com sucesso: {}", codigo); notificador.notificar("Empréstimo realizado: " + item.getTitulo()); } }
Leia pedidos de CSV, valide cada linha, converta para objetos e produza relatório JSON. Linhas inválidas não podem impedir o processamento das demais.
BigDecimal para dinheiro e java.time para datas.SOLID é um acrônimo de cinco princípios de design orientado a objetos, formalizados por Robert C. Martin, que resumem boa parte da sabedoria acumulada dos capítulos anteriores.
Uma classe deve ter um único motivo para mudar. Se Funcionario calcula salário e salva no banco e gera um PDF de holerite, mudanças em qualquer uma dessas três responsabilidades forçam mexer na mesma classe.
// ❌ uma classe fazendo três coisas: class Funcionario { double calcularSalario() { ... } void salvarNoBanco() { ... } void gerarHoleritePdf() { ... } } // ✅ responsabilidades separadas: class Funcionario { double calcularSalario() { ... } } class FuncionarioRepositorio { void salvar(Funcionario f) { ... } } class HoleriteGenerator { void gerarPdf(Funcionario f) { ... } }
Classes devem estar abertas para extensão, fechadas para modificação. Em vez de um switch gigante que cresce a cada novo caso, use polimorfismo (é literalmente o que fizemos no exercício 6.1 com Forma).
// ❌ toda vez que aparece uma forma nova, esse método precisa mudar: double calcularArea(Object forma) { if (forma instanceof Circulo) { ... } else if (forma instanceof Quadrado) { ... } // ... um "else if" novo para cada forma futura } // ✅ Forma.area() já resolve isso -- adicionar uma forma nova não toca em código existente
Uma subclasse deve poder substituir sua superclasse sem quebrar o comportamento esperado por quem usa o código. O exemplo clássico do que viola esse princípio:
class Retangulo { protected double largura, altura; void setLargura(double l) { largura = l; } void setAltura(double a) { altura = a; } double area() { return largura * altura; } } // ❌ Quadrado "é um" retângulo geometricamente, mas quebra o CONTRATO: class Quadrado extends Retangulo { @Override void setLargura(double l) { largura = altura = l; } // efeito colateral inesperado! @Override void setAltura(double a) { largura = altura = a; } } // código que confia que setLargura só muda a largura QUEBRA silenciosamente com Quadrado
Prefira várias interfaces específicas a uma única interface "gigante" que força classes a implementar métodos que não fazem sentido para elas.
// ❌ interface inchada: interface Trabalhador { void trabalhar(); void comer(); } class Robo implements Trabalhador { public void trabalhar() { ... } public void comer() { /* robô não come! método forçado e sem sentido */ } } // ✅ interfaces segregadas: interface Trabalhador { void trabalhar(); } interface SerVivo { void comer(); } class Robo implements Trabalhador { public void trabalhar() { ... } }
Dependa de abstrações (interfaces), não de implementações concretas. Isso é o que permite trocar um MySqlRepositorio por um MongoRepositorio sem tocar na lógica de negócio.
// ❌ acoplado à implementação concreta: class PedidoService { private MySqlRepositorio repo = new MySqlRepositorio(); // travado nessa implementação } // ✅ depende da abstração, implementação é injetada de fora: interface PedidoRepositorio { void salvar(Pedido p); } class PedidoService { private final PedidoRepositorio repo; PedidoService(PedidoRepositorio repo) { this.repo = repo; } // injeção de dependência }
PedidoService dependesse de MySqlRepositorio diretamente, você não conseguiria substituí-lo por um mock em teste. SOLID e testabilidade caminham juntos — código difícil de testar quase sempre é sintoma de algum princípio SOLID sendo violado.Uma máxima que resume boa parte do design orientado a objetos moderno: prefira "tem um" (composição) a "é um" (herança) sempre que a relação não for genuinamente uma especialização. Herança acopla fortemente subclasse e superclasse; composição é mais flexível e evita hierarquias frágeis como a de Retangulo/Quadrado vista acima.
// em vez de "Carro extends Motor" (Carro NÃO é um tipo de Motor): class Carro { private final Motor motor; // Carro TEM UM Motor -- composição Carro(Motor motor) { this.motor = motor; } void acelerar() { motor.aumentarRotacao(); } }
Leia esta classe e aponte por escrito quais princípios SOLID ela viola e como corrigir:
class RelatorioVendas { void buscarVendasNoBanco() { /* SQL direto aqui */ } void calcularTotal() { ... } void formatarComoPdf() { ... } void enviarPorEmail() { /* SMTP direto aqui */ } }
SRP violado: a classe busca dados, calcula, formata e envia e-mail — quatro motivos de mudança diferentes (mudar o banco, mudar a regra de cálculo, mudar o formato do relatório, trocar o provedor de e-mail cada um forçaria mexer na mesma classe). DIP violado: "SQL direto" e "SMTP direto" são acoplamento a implementações concretas, deveriam depender de interfaces como VendaRepositorio e ServicoEmail, injetadas de fora. Correção: separar em VendaRepositorio (busca), CalculadoraVendas (cálculo), RelatorioPdfGenerator (formatação) e ServicoEmail (envio), cada uma com uma única responsabilidade e dependendo de interfaces.
Este é, sem exagero, o capítulo mais importante para entender o Spring. O Spring é, na essência, um container de Inversão de Controle (IoC) que faz injeção de dependência automaticamente. Antes de ver isso "de graça" via anotações, vamos construir manualmente para entender o que realmente está acontecendo.
public class PedidoServico { private final EmailServico email = new EmailServico(); // ❌ PedidoServico CRIA sua própria dependência public void finalizar(Pedido p) { // ... email.enviar(p.getEmailCliente(), "Pedido confirmado"); } }
Problemas: PedidoServico está travado em EmailServico — impossível trocar por um SmsServico sem editar essa classe, e impossível testar finalizar() sem realmente enviar e-mails (nada de mock, capítulo 15).
public interface Notificador { void enviar(String destino, String mensagem); } public class EmailServico implements Notificador { @Override public void enviar(String destino, String mensagem) { /* SMTP */ } } public class PedidoServico { private final Notificador notificador; // depende da ABSTRAÇÃO (capítulo 16, o "D" de SOLID) // injeção via CONSTRUTOR -- a dependência vem de FORA, PedidoServico não cria nada public PedidoServico(Notificador notificador) { this.notificador = notificador; } public void finalizar(Pedido p) { notificador.enviar(p.getEmailCliente(), "Pedido confirmado"); } } // quem "monta" a aplicação decide a implementação real: Notificador notificador = new EmailServico(); PedidoServico servico = new PedidoServico(notificador); // injeção manual
Agora testar fica trivial (com um mock ou uma implementação falsa), e trocar para SMS é criar SmsServico implements Notificador sem tocar em PedidoServico.
Quando o número de dependências cresce, montar tudo manualmente em um main vira um emaranhado. Um container de IoC resolve isso: você registra "receitas" de como criar cada tipo, e o container resolve a árvore de dependências automaticamente.
public class ContainerSimples { private final Map<Class<?>, Object> instancias = new HashMap<>(); public <T> void registrar(Class<T> tipo, T instancia) { instancias.put(tipo, instancia); } @SuppressWarnings("unchecked") public <T> T resolver(Class<T> tipo) { return (T) instancias.get(tipo); } } // "montagem" da aplicação, uma única vez: ContainerSimples container = new ContainerSimples(); container.registrar(Notificador.class, new EmailServico()); container.registrar(PedidoServico.class, new PedidoServico(container.resolver(Notificador.class))); PedidoServico servico = container.resolver(PedidoServico.class);
ApplicationContext do Spring faz — só que em escala industrial: ele faz um scan via reflection (capítulo 20) por classes anotadas com @Component/@Service/@Repository, decide sozinho a ordem de criação analisando quem depende de quem, cria cada instância (chamada de bean) e injeta as dependências nos construtores ou campos @Autowired — automaticamente. Quando você aprender Spring, cada @Autowired vai literalmente significar "o container, resolva essa dependência para mim", exatamente como container.resolver(...) fez aqui.| Tipo | Como | Recomendação |
|---|---|---|
| Via construtor | Parâmetro do construtor, como no exemplo acima | Preferida — permite campos final, deixa dependências obrigatórias explícitas, facilita testes |
| Via setter | Um método setNotificador(Notificador n) | Para dependências opcionais |
| Via campo | Direto no campo (no Spring: @Autowired private Notificador n;) | Mais conveniente, mas dificulta testes e esconde dependências — evite quando possível |
Pegue a classe Biblioteca (capítulo 17). Crie uma interface NotificadorEmprestimo com void notificar(String mensagem). Refatore Biblioteca para receber um NotificadorEmprestimo via construtor, e chame notificar(...) dentro de emprestar(...) quando o empréstimo for bem-sucedido. Escreva um teste JUnit que usa um NotificadorEmprestimo "falso" (uma classe simples que só guarda a última mensagem recebida em uma lista) para verificar que a notificação foi disparada, sem enviar nada de verdade.
public interface NotificadorEmprestimo { void notificar(String mensagem); } public class Biblioteca { private final List<ItemAcervo> acervo = new ArrayList<>(); private final NotificadorEmprestimo notificador; public Biblioteca(NotificadorEmprestimo notificador) { this.notificador = notificador; } public void emprestar(String codigo) throws ItemIndisponivelException { ItemAcervo item = acervo.stream() .filter(i -> i.getCodigo().equals(codigo)) .findFirst() .orElseThrow(() -> new ItemIndisponivelException("Item não encontrado")); if (!item.disponivelParaEmprestimo()) { throw new ItemIndisponivelException("Item já emprestado"); } item.emprestar(); notificador.notificar("Empréstimo realizado: " + item.getTitulo()); } public void adicionar(ItemAcervo i) { acervo.add(i); } } // --- classe de teste "falsa" (test double) --- class NotificadorFalso implements NotificadorEmprestimo { List<String> mensagens = new ArrayList<>(); @Override public void notificar(String mensagem) { mensagens.add(mensagem); } } @Test void deveNotificarAoEmprestar() throws ItemIndisponivelException { NotificadorFalso fake = new NotificadorFalso(); Biblioteca biblioteca = new Biblioteca(fake); // injeção manual do "mock" biblioteca.adicionar(new Livro("Clean Code", "L001", "Robert Martin")); biblioteca.emprestar("L001"); assertEquals(1, fake.mensagens.size()); }
O Spring é literalmente construído sobre estes padrões — reconhecê-los agora significa reconhecer o Spring depois, em vez de decorar anotações sem entender o que elas automatizam.
Garante que exista uma única instância de uma classe em toda a aplicação.
public class ConfiguracaoApp { private static final ConfiguracaoApp INSTANCIA = new ConfiguracaoApp(); private ConfiguracaoApp() {} // construtor PRIVADO -- ninguém de fora cria uma nova public static ConfiguracaoApp getInstance() { return INSTANCIA; } }
@Bean/@Component é Singleton por padrão — o container cria uma única instância e a reutiliza em toda a aplicação, exatamente essa ideia, só que gerenciada pelo framework em vez de escrita manualmente com construtor privado.Centraliza a lógica de como criar um objeto, escondendo a decisão de qual implementação concreta usar.
public class NotificadorFactory { public static Notificador criar(String tipo) { return switch (tipo) { case "email" -> new EmailServico(); case "sms" -> new SmsServico(); default -> throw new IllegalArgumentException("Tipo desconhecido: " + tipo); }; } }
Encapsula algoritmos intercambiáveis atrás de uma interface comum — já vimos isso na prática no exercício 8.1 (MetodoPagamento).
public interface EstrategiaDesconto { double aplicar(double valor); } class DescontoBlackFriday implements EstrategiaDesconto { public double aplicar(double v) { return v * 0.5; } } class SemDesconto implements EstrategiaDesconto { public double aplicar(double v) { return v; } } // o "cliente" escolhe a estratégia em runtime, sem if/else espalhado
Isola a lógica de acesso a dados atrás de uma interface, como se fosse uma coleção em memória. É o padrão que o Spring Data JPA leva ao extremo — você declara uma interface e o framework gera a implementação inteira sozinho.
public interface LivroRepositorio { void salvar(Livro livro); Optional<Livro> buscarPorId(long id); List<Livro> listarTodos(); } // implementação hoje: em memória. Amanhã: JDBC (capítulo 24). Depois: Spring Data JPA. // o código que USA LivroRepositorio não muda em NENHUM desses casos. public class LivroRepositorioEmMemoria implements LivroRepositorio { private final Map<Long, Livro> dados = new HashMap<>(); @Override public void salvar(Livro l) { dados.put(l.getId(), l); } @Override public Optional<Livro> buscarPorId(long id) { return Optional.ofNullable(dados.get(id)); } @Override public List<Livro> listarTodos() { return List.copyOf(dados.values()); } }
Um objeto ("sujeito") notifica múltiplos "observadores" quando seu estado muda — a base conceitual de eventos (e do ApplicationEventPublisher do Spring).
public interface ObservadorPedido { void aoFinalizarPedido(Pedido p); } public class PedidoServico { private final List<ObservadorPedido> observadores = new ArrayList<>(); public void adicionarObservador(ObservadorPedido o) { observadores.add(o); } public void finalizar(Pedido p) { // ... lógica de finalização ... for (ObservadorPedido o : observadores) o.aoFinalizarPedido(p); // notifica todos, sem saber quem são } }
| Padrão | No Spring, aparece como |
|---|---|
| Singleton | Escopo padrão de todo @Bean/@Component |
| Factory | Métodos @Bean dentro de classes @Configuration |
| Strategy | Múltiplas implementações de uma interface, injetadas conforme perfil/qualificador |
| Repository | Interfaces JpaRepository/CrudRepository do Spring Data |
| Observer | ApplicationEvent + @EventListener |
| Dependency Injection | O próprio núcleo do container (@Autowired) |
Defina a interface LivroRepositorio mostrada acima. Implemente LivroRepositorioEmMemoria. Escreva um teste JUnit garantindo que salvar seguido de buscarPorId retorna o livro correto, e que buscarPorId para um id inexistente retorna Optional.empty().
@Test void deveSalvarEBuscarLivro() { LivroRepositorio repo = new LivroRepositorioEmMemoria(); Livro livro = new Livro(1L, "Clean Code", "Robert Martin"); repo.salvar(livro); assertTrue(repo.buscarPorId(1L).isPresent()); assertEquals("Clean Code", repo.buscarPorId(1L).get().getTitulo()); assertTrue(repo.buscarPorId(999L).isEmpty()); }
SOLID (capítulo 16) organiza classes; Clean Code organiza código dentro de cada método e classe — nomes, tamanho de função, comentários, estrutura. É a diferença entre um sistema bem arquitetado que ainda assim é doloroso de ler linha a linha.
int d; // dias desde a última atualização? List<int[]> l = getList(); if (x.getStatus() == 1) { // o que é "1"? processar(x); }
int diasDesdeUltimaAtualizacao; List<Celula> celulasVivas = tabuleiro.getCelulasVivas(); if (pedido.getStatus() == StatusPedido.APROVADO) { // enum, não número mágico processarPedidoAprovado(pedido); }
Isso é o SRP do capítulo 16 aplicado no nível de método, não de classe. Um método que precisa de comentários para separar "seções" internas geralmente deveria ser quebrado em métodos menores, cada um nomeado pela intenção daquela seção.
// ❌ um método fazendo três coisas, precisando de comentários pra separar: public void processarPedido(Pedido p) { // validar if (p.getItens().isEmpty()) throw new PedidoInvalidoException(); // calcular total double total = 0; for (var item : p.getItens()) total += item.getPreco() * item.getQtd(); // notificar notificador.enviar("Pedido de R$" + total); } // ✅ cada intenção é um método nomeado -- os comentários viram desnecessários: public void processarPedido(Pedido p) { validar(p); double total = calcularTotal(p); notificarCliente(total); }
calcularTotal), sem exigir tradução extra. Comentários bons explicam o porquê de uma decisão não óbvia (por que esse algoritmo específico, por que essa exceção é ignorada de propósito), nunca o o que — isso o código já diz por si.Refatorar só é seguro com uma rede de segurança — é por isso que o capítulo 15 (testes) e o TDD vieram antes deste capítulo, não depois. Sem testes, "refatorar" é só "reescrever e torcer".
| Refatoração | Quando aplicar |
|---|---|
| Extract Method | Um bloco de código dentro de um método merece seu próprio nome |
| Rename Variable/Method | O nome atual não comunica a intenção real |
| Replace Conditional with Polymorphism | Um if/else ou switch gigante decidindo comportamento por tipo — vira o padrão do exercício 6.1 (capítulo 06) |
| Introduce Parameter Object | Um método com 5+ parâmetros — agrupe em um objeto com nome próprio |
Refatore o método abaixo aplicando Extract Method e nomes melhores, sem mudar o comportamento:
public double calc(List<Item> l) { double t = 0; for (Item i : l) { double p = i.getPreco(); if (i.getQtd() > 10) p = p * 0.9; // desconto t += p * i.getQtd(); } if (t > 500) t = t * 0.95; // desconto adicional return t; }
public double calcularTotalComDescontos(List<Item> itens) { double subtotal = calcularSubtotal(itens); return aplicarDescontoPorVolumeTotal(subtotal); } private double calcularSubtotal(List<Item> itens) { double subtotal = 0; for (Item item : itens) { subtotal += precoComDescontoPorQuantidade(item); } return subtotal; } private double precoComDescontoPorQuantidade(Item item) { double preco = item.getPreco(); boolean qualificaDescontoVolume = item.getQtd() > 10; if (qualificaDescontoVolume) preco *= 0.9; return preco * item.getQtd(); } private double aplicarDescontoPorVolumeTotal(double subtotal) { boolean qualificaDescontoTotal = subtotal > 500; return qualificaDescontoTotal ? subtotal * 0.95 : subtotal; }
O projeto que fecha a trilha pré-Spring: um container de injeção de dependência baseado em anotações, construído do zero. Depois deste projeto, quando você abrir seu primeiro projeto Spring Boot, @Component e @Autowired vão parecer familiares — porque você já construiu uma versão simplificada deles com as próprias mãos.
@Componente (@Retention(RUNTIME), @Target(TYPE)), marcando classes que o container deve gerenciar.@Injetar (@Target(FIELD)), marcando campos que o container deve preencher automaticamente.MiniContainer com um método registrar(Class<?> tipo) que guarda os tipos anotados com @Componente em um Map<Class<?>, Object> (as instâncias criadas — reaproveitando a ideia de Singleton do capítulo 23).iniciar(): para cada tipo registrado, cria a instância via reflection (construtor sem argumentos) e guarda no map.getDeclaredFields(); para cada campo com @Injetar, busque a instância do tipo certo no map e injete com Field.set(...) (setAccessible(true) antes).<T> T resolver(Class<T> tipo), devolvendo a instância já criada e com as dependências injetadas.Biblioteca e NotificadorEmprestimo/EmailServico dos capítulos anteriores: anote-as com @Componente, o campo de dependência com @Injetar, registre ambas no container, chame iniciar() e resolva Biblioteca pronta para uso, sem nenhum new manual no código cliente.Uma possível solução, reunindo anotações, reflection, generics, coleções e o padrão de injeção de dependência vistos na trilha inteira:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Componente {} @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Injetar {} public class MiniContainer { private final List<Class<?>> tiposRegistrados = new ArrayList<>(); private final Map<Class<?>, Object> instancias = new HashMap<>(); public void registrar(Class<?> tipo) { if (!tipo.isAnnotationPresent(Componente.class)) { throw new IllegalArgumentException(tipo.getName() + " não é @Componente"); } tiposRegistrados.add(tipo); } public void iniciar() throws ReflectiveOperationException { // 1ª passada: cria todas as instâncias (construtor sem argumentos) for (Class<?> tipo : tiposRegistrados) { Object instancia = tipo.getDeclaredConstructor().newInstance(); instancias.put(tipo, instancia); } // 2ª passada: injeta dependências em cada campo @Injetar for (Object instancia : instancias.values()) { for (Field campo : instancia.getClass().getDeclaredFields()) { if (campo.isAnnotationPresent(Injetar.class)) { Object dependencia = resolverPorTipoCompativel(campo.getType()); campo.setAccessible(true); campo.set(instancia, dependencia); } } } } private Object resolverPorTipoCompativel(Class<?> tipoAlvo) { return instancias.values().stream() .filter(i -> tipoAlvo.isAssignableFrom(i.getClass())) // aceita interface OU implementação exata .findFirst() .orElseThrow(() -> new IllegalStateException("Nenhum componente compatível com " + tipoAlvo.getName())); } @SuppressWarnings("unchecked") public <T> T resolver(Class<T> tipo) { return (T) instancias.get(tipo); } } // --- usando o mini-container --- @Componente public class EmailServico implements NotificadorEmprestimo { @Override public void notificar(String msg) { System.out.println("[email] " + msg); } } @Componente public class Biblioteca { @Injetar private NotificadorEmprestimo notificador; // preenchido automaticamente pelo container! private final List<ItemAcervo> acervo = new ArrayList<>(); public void adicionar(ItemAcervo i) { acervo.add(i); } public void emprestar(String codigo) { /* ... usa notificador normalmente ... */ } } // main: MiniContainer container = new MiniContainer(); container.registrar(EmailServico.class); container.registrar(Biblioteca.class); container.iniciar(); Biblioteca biblioteca = container.resolver(Biblioteca.class); // biblioteca já está pronta, com "notificador" injetado -- ZERO "new" manual!
Compare este MiniContainer com @SpringBootApplication: a ideia central — descobrir definições, instanciar, resolver por tipo e coordenar ciclo de vida — é semelhante. O Spring acrescenta escopos, qualificadores, callbacks, proxies, post-processors e scan de pacotes. Dependências circulares não são um recurso a ser “resolvido”: ciclos por construtor falham e normalmente revelam responsabilidades mal separadas.
O Spring MVC / Spring Web (o que você vai usar para criar APIs) é uma camada de conveniência em cima do protocolo HTTP e do estilo arquitetural REST. Entender os conceitos puros evita que anotações como @GetMapping pareçam arbitrárias.
| Método | Uso convencional (REST) | Idempotente? |
|---|---|---|
GET | Buscar um recurso, sem efeitos colaterais | Sim |
POST | Criar um novo recurso | Não |
PUT | Substituir um recurso inteiro por completo | Sim |
PATCH | Atualizar parcialmente um recurso | Não, em geral |
DELETE | Remover um recurso | Sim |
Idempotente significa: repetir a mesma requisição várias vezes tem o mesmo efeito de fazê-la uma única vez. DELETE /livros/5 chamado duas vezes deixa o sistema no mesmo estado (livro removido); POST /livros chamado duas vezes cria dois livros.
| Faixa | Significado | Exemplos comuns |
|---|---|---|
| 2xx | Sucesso | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirecionamento | 301 Moved Permanently |
| 4xx | Erro do cliente | 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found |
| 5xx | Erro do servidor | 500 Internal Server Error |
--- REQUISIÇÃO --- POST /livros HTTP/1.1 Host: api.biblioteca.com Content-Type: application/json Authorization: Bearer eyJhbGc... {"titulo": "1984", "autor": "Orwell", "paginas": 328} --- RESPOSTA --- HTTP/1.1 201 Created Content-Type: application/json Location: /livros/42 {"id": 42, "titulo": "1984", "autor": "Orwell", "paginas": 328}
O header Content-Type: application/json avisa ao servidor (e ao cliente, na resposta) que o corpo (body) da mensagem está em JSON — informação que o Jackson (capítulo 25) usa para decidir como interpretar os bytes.
Uma API REST bem desenhada nomeia URLs como substantivos (recursos), e usa o método HTTP para expressar a ação — nunca verbos na URL.
// ❌ estilo RPC, verbos na URL: GET /buscarLivroPorId?id=5 POST /criarNovoLivro POST /deletarLivro?id=5 // ✅ estilo REST, recursos + métodos HTTP: GET /livros // listar todos GET /livros/5 // buscar um específico POST /livros // criar PUT /livros/5 // substituir por completo DELETE /livros/5 // remover
@GetMapping("/livros/{id}"), @PostMapping("/livros"), @PutMapping("/livros/{id}"), @DeleteMapping("/livros/{id}"). Um controller Spring nada mais é do que uma classe cujos métodos são mapeados para combinações de "verbo HTTP + caminho de URL" — o mapeamento que você acabou de desenhar aqui em pseudo-REST.HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.biblioteca.com/livros"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 201
System.out.println(response.body()); // JSON de resposta
Para o sistema de biblioteca dos capítulos anteriores, desenhe (no papel, sem implementar) os endpoints REST para: listar todos os itens, buscar um item por código, emprestar um item, devolver um item, e cadastrar um novo livro. Para cada um, defina o verbo HTTP, o caminho da URL, e o código de status esperado em caso de sucesso.
GET /itens → 200 OK (lista todos)
GET /itens/{codigo} → 200 OK ou 404 Not Found
POST /itens → 201 Created (cadastra um novo livro)
POST /itens/{codigo}/emprestimo → 200 OK ou 409 Conflict (já emprestado)
POST /itens/{codigo}/devolucao → 200 OK
Repare que "emprestar" e "devolver" não são recursos no sentido estrito, mas ações sobre um recurso — uma convenção comum é tratá-los como sub-recursos (POST /itens/{codigo}/emprestimo) em vez de verbos soltos na URL principal, mantendo o estilo REST mesmo quando a ação não é um CRUD simples.
Até aqui, todo "banco de dados" do curso foi um List ou Map em memória — os dados somem quando o programa fecha. SQL é a linguagem para conversar com um banco de dados relacional, que guarda dados permanentemente, organizados em tabelas.
livros é como a classe Livro — cada linha é um new Livro(...) com valores específicos. As colunas são os atributos, e o tipo de cada coluna (INTEGER, VARCHAR, BOOLEAN) é como o tipo de cada atributo Java. A diferença é que, ao contrário de um ArrayList, os dados de uma tabela sobrevivem ao fechar o programa — porque vivem no disco, gerenciados pelo banco.CREATE TABLE autores (
id SERIAL PRIMARY KEY, -- SERIAL: número que se auto-incrementa
nome VARCHAR(150) NOT NULL,
nascimento DATE
);
CREATE TABLE livros (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
paginas INTEGER CHECK (paginas > 0), -- restrição de valor
autor_id INTEGER REFERENCES autores(id), -- CHAVE ESTRANGEIRA -- liga as duas tabelas
disponivel BOOLEAN DEFAULT true
);
A autor_id REFERENCES autores(id) é uma chave estrangeira (foreign key) — ela garante, no próprio banco, que todo livro aponte para um autor que realmente existe. Essa é a diferença central entre "vários ArrayList soltos" e um banco relacional de verdade: as relações são impostas, não apenas uma convenção que seu código Java promete respeitar.
INSERT INTO autores (nome, nascimento) VALUES ('Machado de Assis', '1839-06-21');
INSERT INTO livros (titulo, paginas, autor_id) VALUES ('Dom Casmurro', 256, 1);
SELECT * FROM livros; -- todas as colunas, todas as linhas
SELECT titulo, paginas FROM livros WHERE paginas > 200;
SELECT * FROM livros ORDER BY paginas DESC LIMIT 5;
UPDATE livros SET disponivel = false WHERE id = 1;
DELETE FROM livros WHERE id = 1;
Stream (capítulo 13), SQL vai parecer familiar: WHERE é igual a .filter(...), ORDER BY é igual a .sorted(...), e SELECT titulo, paginas é igual a um .map(...) que projeta só os campos que você quer. A mentalidade de "pipeline de transformação de dados" que você já treinou nos streams se transfere quase 1:1 para SQL.Sem JOIN, uma consulta em livros te dá só o autor_id (um número) — não o nome do autor. JOIN combina linhas de duas tabelas com base em uma condição de igualdade.
SELECT livros.titulo, autores.nome FROM livros INNER JOIN autores ON livros.autor_id = autores.id; -- só retorna livros que TÊM autor correspondente SELECT livros.titulo, autores.nome FROM livros LEFT JOIN autores ON livros.autor_id = autores.id; -- retorna TODOS os livros, mesmo os sem autor (nome viria NULL)
INNER JOIN como o resultado de um Set.retainAll(...) — só o que existe nos dois lados sobrevive. LEFT JOIN é como manter tudo do lado esquerdo, e preencher com null o que não tiver correspondência do lado direito — parecido com o getOrDefault(chave, null) de um Map (capítulo 11), aplicado linha a linha.| Tipo de JOIN | O que retorna |
|---|---|
INNER JOIN | Só as linhas que têm correspondência nos dois lados |
LEFT JOIN | Todas as linhas da esquerda + correspondências (ou NULL) da direita |
RIGHT JOIN | O espelho do LEFT JOIN — todas da direita |
FULL JOIN | Tudo dos dois lados, casando onde possível |
SELECT autores.nome, COUNT(livros.id) AS total_livros
FROM autores
LEFT JOIN livros ON livros.autor_id = autores.id
GROUP BY autores.nome
ORDER BY total_livros DESC;
-- equivalente ao Collectors.groupingBy(...) + counting() do capítulo 13!
BEGIN; UPDATE contas SET saldo = saldo - 100 WHERE id = 1; -- débito UPDATE contas SET saldo = saldo + 100 WHERE id = 2; -- crédito COMMIT; -- só agora as duas mudanças ficam permanentes, JUNTAS -- se algo desse errado no meio, "ROLLBACK;" desfaz TUDO desde o BEGIN
UPDATE, sem transação o dinheiro simplesmente desaparece. Esse é o conceito por trás de @Transactional no Spring (que você vai ver adiante): ele abre um BEGIN, executa seu método, e decide COMMIT ou ROLLBACK automaticamente dependendo se uma exceção foi lançada — exatamente o dynamic proxy que você já estudou no capítulo 20.Sem índice, o banco varre a tabela inteira linha por linha para achar o que você pediu (full table scan) — ok para 100 linhas, catastrófico para 10 milhões. Um índice é uma estrutura extra (geralmente uma árvore B) que permite pular direto para os dados relevantes.
CREATE INDEX idx_livros_autor_id ON livros(autor_id);
-- toda coluna usada com frequência em WHERE ou JOIN é candidata a índice
INSERT/UPDATE, mais rápido em leitura). É exatamente esse trade-off que existe em índices de banco.INSERT/UPDATE/DELETE) precisa atualizar todos os índices da tabela afetada. Criar índice em toda coluna "por garantia" deixa escritas lentas sem necessidade — crie índices baseados em consultas reais e lentas, não por precaução.Crie as tabelas autores e livros como no exemplo acima. Insira 3 autores e 5 livros (alguns autores com mais de um livro). Escreva uma consulta que lista o título de cada livro junto com o nome do autor, ordenado por título. Depois, escreva uma consulta que mostra quantos livros cada autor tem, incluindo autores com zero livros.
SELECT livros.titulo, autores.nome
FROM livros
INNER JOIN autores ON livros.autor_id = autores.id
ORDER BY livros.titulo;
SELECT autores.nome, COUNT(livros.id) AS total
FROM autores
LEFT JOIN livros ON livros.autor_id = autores.id
GROUP BY autores.nome
ORDER BY total DESC;
-- LEFT JOIN é essencial aqui: com INNER JOIN, autores sem livro nenhum
-- simplesmente desapareceriam do resultado
Crie uma tabela emprestimos (id SERIAL, livro_id INTEGER, data_emprestimo DATE). Escreva uma transação que: (1) marca disponivel = false no livro, (2) insere um registro em emprestimos. Envolva as duas operações em BEGIN/COMMIT, e explique em texto por que rodar essas duas operações sem transação poderia deixar o sistema em um estado inconsistente se o processo falhasse entre as duas.
BEGIN; UPDATE livros SET disponivel = false WHERE id = 3; INSERT INTO emprestimos (livro_id, data_emprestimo) VALUES (3, CURRENT_DATE); COMMIT;
Sem transação, se o processo caísse depois do UPDATE mas antes do INSERT, o livro ficaria marcado como indisponível para sempre, sem nenhum registro de empréstimo explicando por quê — um estado inconsistente impossível de auditar. Com transação, ou as duas operações acontecem juntas, ou nenhuma acontece.
SQL é o idioma; PostgreSQL é o "sotaque" específico que você vai usar no dia a dia — o banco relacional open-source mais popular do ecossistema Spring, com recursos avançados (JSON nativo, full-text search, extensões) que muitos concorrentes não têm.
# já vimos isso no capítulo 30 -- é assim que praticamente todo mundo
# roda Postgres em desenvolvimento hoje em dia:
docker run -d --name pg -e POSTGRES_PASSWORD=senha123 -p 5432:5432 -v pgdata:/var/lib/postgresql/data postgres:16# instalador oficial em postgresql.org/download/windows
# ou via winget:
winget install PostgreSQL.PostgreSQLbrew install postgresql@16 brew services start postgresql@16
# cliente de linha de comando -- funciona igual, instalado localmente ou via Docker: psql -U postgres -h localhost \l -- listar bancos \c biblioteca -- conectar a um banco \dt -- listar tabelas \d livros -- descrever a estrutura da tabela livros \q -- sair
psql de uma vez. Deixe uma aba com \? (lista todos os comandos) aberta enquanto pratica os primeiros exercícios do capítulo anterior — o hábito vem do uso repetido, não da memorização.| Tipo Postgres | Equivalente Java | Observação |
|---|---|---|
SERIAL / BIGSERIAL | int/long auto-incrementado | Ideal para chaves primárias simples |
VARCHAR(n) / TEXT | String | TEXT sem limite de tamanho, VARCHAR(n) com limite |
NUMERIC(p,s) | BigDecimal | Use para dinheiro, nunca double (capítulo 15 já avisou sobre imprecisão) |
TIMESTAMP / DATE | LocalDateTime / LocalDate | Conecta direto com o capítulo 21 (java.time) |
BOOLEAN | boolean | |
JSONB | String/objeto serializado | Postgres consegue indexar e consultar dentro do JSON — recurso raro em bancos relacionais |
EXPLAIN ANALYZE SELECT * FROM livros WHERE autor_id = 5;
Isso mostra o plano de execução real da consulta: se o banco está usando um índice (rápido) ou fazendo um Seq Scan — varredura sequencial da tabela inteira (lento em tabelas grandes). É a ferramenta que separa "acho que está lento" de "sei exatamente por quê".
-- sem índice em autor_id: Seq Scan on livros (cost=0.00..18.50 rows=5 width=64) (actual time=0.02..0.15 rows=5 loops=1) Filter: (autor_id = 5) -- depois de "CREATE INDEX idx_livros_autor ON livros(autor_id);": Index Scan using idx_livros_autor on livros (cost=0.15..8.30 rows=5 width=64) (actual time=0.01..0.02 rows=5 loops=1) Index Cond: (autor_id = 5)
EXPLAIN é como pedir ao banco "me mostre seu raciocínio" — parecido com pedir para alguém explicar passo a passo como resolveu um problema, em vez de só aceitar a resposta final. Seq Scan é "eu li linha por linha até achar"; Index Scan é "eu fui direto no índice remissivo e pulei pra página certa" (lembra da analogia do capítulo anterior).String url = "jdbc:postgresql://localhost:5432/biblioteca"; // exatamente o mesmo padrão do capítulo 24 -- agora com um Postgres real rodando Connection conexao = DriverManager.getConnection(url, "postgres", "senha123");
double/float para dinheiro — use NUMERIC no banco e BigDecimal no Java.EXPLAIN ANALYZE antes de "otimizar" qualquer coisa — otimização sem medir é só um palpite caro.DELETE ou UPDATE sem WHERE em produção — sim, isso já aconteceu com todo mundo pelo menos uma vez.Usando o Postgres rodando via Docker (capítulo 30), crie uma tabela livros com pelo menos 3 colunas, e insira 1000 linhas de teste (dica: INSERT INTO livros (titulo, paginas) SELECT 'Livro ' || i, (random()*500)::int FROM generate_series(1,1000) AS i;). Rode EXPLAIN ANALYZE em uma busca por paginas > 400 sem índice, depois crie um índice em paginas e rode de novo, comparando o plano de execução.
CREATE TABLE livros (id SERIAL PRIMARY KEY, titulo TEXT, paginas INTEGER); INSERT INTO livros (titulo, paginas) SELECT 'Livro ' || i, (random()*500)::int FROM generate_series(1,1000) AS i; EXPLAIN ANALYZE SELECT * FROM livros WHERE paginas > 400; -- provavelmente Seq Scan, já que a tabela é pequena o suficiente para o -- planner decidir que vale mais a pena ler tudo do que usar índice CREATE INDEX idx_livros_paginas ON livros(paginas); EXPLAIN ANALYZE SELECT * FROM livros WHERE paginas > 400; -- com uma tabela pequena, o Postgres pode CONTINUAR escolhendo Seq Scan -- -- e essa é a lição real: índice não é garantia automática de velocidade, -- o "planner" decide com base em estatísticas reais da tabela. O efeito -- fica claro em tabelas de centenas de milhares de linhas ou mais.
Você versiona código com Git (capítulo 29). Mas quem versiona o schema do banco? Sem um processo formal, o schema vira um segredo que só existe na cabeça de quem mexeu por último — e sincronizar isso entre sua máquina, a de um colega e o servidor de produção vira um pesadelo manual.
src/main/resources/db/migration/ ├── V1__criar_tabela_autores.sql ├── V2__criar_tabela_livros.sql ├── V3__adicionar_coluna_isbn.sql └── V4__criar_indice_paginas.sql
-- V1__criar_tabela_autores.sql
CREATE TABLE autores (
id SERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL
);
-- V3__adicionar_coluna_isbn.sql (aplicada DEPOIS que V1 e V2 já rodaram)
ALTER TABLE livros ADD COLUMN isbn VARCHAR(20);
O Flyway mantém uma tabela de controle (flyway_schema_history) registrando quais versões já foram aplicadas em cada banco. Ao iniciar a aplicação, ele compara essa tabela com os arquivos disponíveis e aplica só o que ainda falta — automaticamente, na ordem certa, em qualquer ambiente (sua máquina, CI, produção).
ddl-auto=update como mecanismo de evolução do schema. Alterações inferidas do modelo não passam por revisão SQL, não documentam a intenção, variam entre bancos e não oferecem uma estratégia confiável de implantação ou reversão. Use ddl-auto=validate para detectar incompatibilidade e mantenha mudanças estruturais em migrations versionadas, revisadas e testadas.| Prefixo | Significado |
|---|---|
V | Versionada — roda uma única vez, em ordem, e nunca deveria ser editada depois de aplicada em algum ambiente |
R | Repetível — roda toda vez que o conteúdo do arquivo muda (útil para views, procedures) |
V5__corrige_isbn.sql), nunca reescreva a antiga.# application.properties spring.flyway.enabled=true spring.jpa.hibernate.ddl-auto=validate # o Spring Boot detecta o Flyway no classpath e roda as migrations # AUTOMATICAMENTE antes da aplicação subir, toda vez
Escreva três arquivos de migration Flyway para o projeto da biblioteca: V1 criando a tabela autores, V2 criando livros com chave estrangeira para autores, e V3 adicionando uma coluna ano_publicacao INTEGER em livros. Nomeie os arquivos seguindo exatamente a convenção do Flyway.
-- V1__criar_tabela_autores.sql CREATE TABLE autores ( id SERIAL PRIMARY KEY, nome VARCHAR(150) NOT NULL ); -- V2__criar_tabela_livros.sql CREATE TABLE livros ( id SERIAL PRIMARY KEY, titulo VARCHAR(200) NOT NULL, autor_id INTEGER REFERENCES autores(id) ); -- V3__adicionar_ano_publicacao.sql ALTER TABLE livros ADD COLUMN ano_publicacao INTEGER;
JDBC (Java Database Connectivity) é a API padrão do Java para conversar com bancos relacionais. O Spring Data JPA (que você vai usar no módulo Spring) é construído em cima de JDBC — entender esta camada evita que o JPA pareça mágica.
String url = "jdbc:postgresql://localhost:5432/biblioteca"; try (Connection conexao = DriverManager.getConnection(url, "usuario", "senha"); PreparedStatement stmt = conexao.prepareStatement( "SELECT titulo, autor FROM livros WHERE paginas > ?")) { stmt.setInt(1, 300); // preenche o "?" -- posição 1 try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { System.out.println(rs.getString("titulo") + " - " + rs.getString("autor")); } } } catch (SQLException e) { throw new RuntimeException("Erro ao consultar livros", e); // exception chaining (capítulo 10) }
Repare o uso de try-with-resources (capítulo 10) duplo — Connection, PreparedStatement e ResultSet implementam AutoCloseable e precisam ser fechados sempre, mesmo em caso de erro.
// ❌ SQL Injection -- um usuário malicioso pode injetar "'; DROP TABLE livros; --" String sql = "SELECT * FROM livros WHERE titulo = '" + tituloDigitadoPeloUsuario + "'"; // ✅ PreparedStatement separa o valor da estrutura SQL PreparedStatement stmt = conexao.prepareStatement("SELECT * FROM livros WHERE titulo = ?"); stmt.setString(1, tituloDigitadoPeloUsuario);
try (PreparedStatement stmt = conexao.prepareStatement( "INSERT INTO livros (titulo, autor, paginas) VALUES (?, ?, ?)")) { stmt.setString(1, "Dom Casmurro"); stmt.setString(2, "Machado de Assis"); stmt.setInt(3, 256); int linhasAfetadas = stmt.executeUpdate(); // INSERT/UPDATE/DELETE usam executeUpdate(), não executeQuery() }
Abrir uma Connection nova a cada operação é caro (handshake de rede, autenticação). Um pool de conexões (ex: HikariCP, que é o padrão usado pelo Spring Boot) mantém um conjunto de conexões já abertas e as reutiliza entre requisições.
DataSource no Spring Boot (geralmente só com algumas linhas em application.properties), o framework vai montar automaticamente um Connection pool HikariCP por trás — e o JdbcTemplate/Spring Data JPA vão usar exatamente os mesmos conceitos de PreparedStatement e mapeamento de ResultSet que você acabou de ver, só que com boilerplate reduzido drasticamente. Entender o JDBC puro é entender o que essas camadas de conveniência estão economizando do seu código.public List<Livro> buscarTodos(Connection conexao) throws SQLException { List<Livro> livros = new ArrayList<>(); try (Statement stmt = conexao.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM livros")) { while (rs.next()) { Livro livro = new Livro( rs.getLong("id"), rs.getString("titulo"), rs.getString("autor"), rs.getInt("paginas") ); livros.add(livro); } } return livros; }
@Entity, @Column) e reflection (capítulo 20) para descobrir os mapeamentos, no lugar de você escrever esse while (rs.next()) manualmente para cada tabela.Implemente LivroRepositorioJdbc recebendo um DataSource. Cada operação adquire e devolve uma conexão pelo try-with-resources. Implemente salvar, buscarPorId e listarTodos; depois explique como uma camada de serviço forneceria a mesma conexão para várias operações dentro de uma transação.
public class LivroRepositorioJdbc implements LivroRepositorio { private final DataSource dataSource; public LivroRepositorioJdbc(DataSource dataSource) { this.dataSource = dataSource; } @Override public void salvar(Livro livro) { try (Connection conexao = dataSource.getConnection(); PreparedStatement stmt = conexao.prepareStatement( "INSERT INTO livros (titulo, autor, paginas) VALUES (?, ?, ?)")) { stmt.setString(1, livro.getTitulo()); stmt.setString(2, livro.getAutor()); stmt.setInt(3, livro.getPaginas()); stmt.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("Erro ao salvar livro", e); } } @Override public Optional<Livro> buscarPorId(long id) { try (Connection conexao = dataSource.getConnection(); PreparedStatement stmt = conexao.prepareStatement( "SELECT * FROM livros WHERE id = ?")) { stmt.setLong(1, id); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { return Optional.of(new Livro(rs.getLong("id"), rs.getString("titulo"), rs.getString("autor"), rs.getInt("paginas"))); } return Optional.empty(); } } catch (SQLException e) { throw new RuntimeException("Erro ao buscar livro", e); } } @Override public List<Livro> listarTodos() { List<Livro> livros = new ArrayList<>(); try (Connection conexao = dataSource.getConnection(); Statement stmt = conexao.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM livros")) { while (rs.next()) { livros.add(new Livro(rs.getLong("id"), rs.getString("titulo"), rs.getString("autor"), rs.getInt("paginas"))); } } catch (SQLException e) { throw new RuntimeException("Erro ao listar livros", e); } return livros; } }
Construa um sistema de contas, categorias e lançamentos recorrentes. Comece pelo esquema e pelas invariantes; a interface pode continuar no terminal.
double para saldo. Defina precisão no Java e no banco e prove como o arredondamento funciona.Este capítulo deveria parecer fácil — você já construiu, com as próprias mãos, uma versão simplificada de tudo que vem aqui, no capítulo 28. Agora é só trocar seu MiniContainer pelo ApplicationContext real do Spring.
// seu capítulo 28: → Spring real: // @Componente → @Component (ou especializações abaixo) // @Injetar → @Autowired // MiniContainer.registrar() → @ComponentScan (automático, via reflection) // MiniContainer.resolver() → context.getBean(Tipo.class) @Component // genérico -- "isso é um bean gerenciado pelo Spring" public class EmailServico implements NotificadorEmprestimo { ... } @Service // especialização -- comunica INTENÇÃO: "isso é lógica de negócio" public class Biblioteca { private final NotificadorEmprestimo notificador; @Autowired // injeção via construtor -- a MELHOR forma (capítulo 22) public Biblioteca(NotificadorEmprestimo notificador) { this.notificador = notificador; } }
Desde o Spring 4.3, @Autowired é opcional quando a classe tem um único construtor — o Spring injeta automaticamente. Isso não é mágica nova: é o mesmo princípio de reflection do capítulo 20, aplicado por convenção.
| Anotação | Camada | Por quê usar em vez de @Component puro |
|---|---|---|
@Component | Genérico | Qualquer bean que não se encaixa nas categorias abaixo |
@Service | Lógica de negócio | Comunica intenção — e algumas ferramentas de análise de código usam isso para identificar a camada de serviço |
@Repository | Acesso a dados | Além de marcar, o Spring converte exceções de banco específicas em exceções unificadas do Spring (DataAccessException) |
@Controller/@RestController | Camada web | Ativa o mapeamento de requisições HTTP (próximo capítulo) |
@Service, @Repository e @Controller são especializações de @Component e comunicam papéis. A diferença não é sempre puramente semântica: @Repository participa da tradução de exceções de persistência e @Controller/@RestController são interpretados pela infraestrutura web.@Component @Scope("singleton") // PADRÃO -- uma única instância para toda a aplicação (capítulo 23!) public class ConfiguracaoApp { ... } @Component @Scope("prototype") // uma instância NOVA a cada vez que é injetada/solicitada public class RelatorioTemporario { ... }
@Service é a mesma race condition do capítulo 14, só que ainda mais fácil de introduzir sem perceber. Beans deveriam, na maioria dos casos, ser stateless (sem estado próprio entre chamadas) — toda a "memória" do sistema deveria estar no banco, não em campos de instância do bean.Nem toda dependência é sua — bibliotecas externas não têm como carregar @Component. Para essas, você declara manualmente como criar o bean:
@Configuration public class AppConfig { @Bean public ObjectMapper objectMapper() { // capítulo 25 -- Jackson não é sua classe, não pode ter @Component return new ObjectMapper(); } }
MiniContainer (capítulo 28): primeiro o Spring escaneia e cria todos os beans, depois injeta as dependências entre eles. Se um bean não é encontrado, quase sempre é porque ele está fora do pacote escaneado pelo @ComponentScan (implícito em @SpringBootApplication) — o mesmo problema de "esquecer de chamar registrar(...)" no seu container manual.Pegue as classes EmailServico e Biblioteca do capítulo 28 (com @Componente/@Injetar) e reescreva usando as anotações reais do Spring: @Service em ambas, injeção via construtor. Escreva também uma classe @Configuration com um @Bean para um ObjectMapper, simulando uma dependência externa que você não controla.
@Service public class EmailServico implements NotificadorEmprestimo { @Override public void notificar(String msg) { System.out.println("[email] " + msg); } } @Service public class Biblioteca { private final NotificadorEmprestimo notificador; public Biblioteca(NotificadorEmprestimo notificador) { this.notificador = notificador; } // @Autowired implícito } @Configuration public class AppConfig { @Bean public ObjectMapper objectMapper() { return new ObjectMapper(); } }
Sem DevTools, toda mudança em um controller ou service exige parar a aplicação, recompilar e subir de novo manualmente — um ciclo de segundos a minutos, repetido centenas de vezes por dia de trabalho.
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> <!-- nunca vai para o build de produção --> </dependency>
| Recurso | O que faz |
|---|---|
| Restart automático | Detecta mudança de código compilado e reinicia a aplicação sozinho |
| LiveReload | Recarrega o navegador automaticamente após o restart (com extensão do navegador) |
| Propriedades de desenvolvimento | Desabilita cache de templates e outras otimizações que atrapalham durante o desenvolvimento |
<optional>true</optional> e scope=runtime não são detalhes decorativos: eles garantem que o DevTools nunca seja empacotado no .jar final de produção, mesmo que alguém esqueça de removê-lo do pom.xml. O próprio Spring Boot detecta se está rodando de um .jar empacotado e desativa o DevTools automaticamente como segurança extra — mas não vale confiar só nisso.Build → Compile automatically). Vale configurar isso uma vez e esquecer, em vez de reiniciar manualmente achando que é bug do DevTools.Adicione a dependência do DevTools ao pom.xml do projeto da biblioteca, com o escopo correto. Explique em uma frase por que optional=true é importante mesmo com scope=runtime já configurado.
optional=true evita que projetos que dependem do seu projeto (se ele for usado como biblioteca por outro módulo) herdem a dependência do DevTools transitivamente — scope=runtime sozinho não impede essa propagação transitiva em todos os cenários de build.
Lembra de escrever getters, setters, construtores e equals/hashCode/toString na mão nos capítulos 04 e 05? Isso é exatamente o que Lombok gera automaticamente — sem reflection em runtime, direto no bytecode durante a compilação.
public class Livro { private Long id; private String titulo; private int paginas; public Livro(Long id, String titulo, int paginas) { this.id = id; this.titulo = titulo; this.paginas = paginas; } public Long getId() { return id; } public String getTitulo() { return titulo; } public void setTitulo(String titulo) { this.titulo = titulo; } public int getPaginas() { return paginas; } public void setPaginas(int paginas) { this.paginas = paginas; } @Override public boolean equals(Object o) { /* ~10 linhas */ } @Override public int hashCode() { /* ~3 linhas */ } @Override public String toString() { /* ~3 linhas */ } } // ~35 linhas para uma classe de 3 campos
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode @ToString public class Livro { private Long id; private String titulo; private int paginas; } // 8 linhas, mesmo resultado -- o código "chato" nunca existiu de verdade // no .java, mas existe no .class compilado final
| Anotação | Gera |
|---|---|
@Getter/@Setter | Getters/setters de todos os campos (ou de um específico, aplicado no campo) |
@NoArgsConstructor | Construtor vazio |
@AllArgsConstructor | Construtor com todos os campos |
@Data | Combo: Getter + Setter + EqualsAndHashCode + ToString + construtor com campos final |
@Builder | Gera o Builder Pattern inteiro (capítulo 09) automaticamente |
@Slf4j | Injeta um Logger pronto (capítulo 27), sem precisar declarar manualmente |
.class gerado, adicionando os métodos como se você os tivesse digitado. É por isso que, ao decompilar uma classe com @Getter, você vê os métodos getX() normalmente, mesmo nunca tendo escrito essa palavra no .java.equals/hashCode gerado usa todos os campos por padrão, incluindo relacionamentos — o que pode disparar carregamento LAZY inesperado (capítulo 46) ou comparações incorretas antes do id ser gerado pelo banco. Para entidades JPA, prefira anotações específicas (@Getter, @Setter) e escreva equals/hashCode manualmente baseado só no id, como você já fez no exercício 5.2.Pegue a classe Livro encapsulada do exercício 4.1 (getters, setters, construtor) e reescreva usando as anotações Lombok adequadas, mantendo o mesmo comportamento público.
@Getter @AllArgsConstructor public class Livro { private String titulo; private String autor; private int paginas; public void setPaginas(int paginas) { // setter customizado -- Lombok não gera validação, então mantém manual if (paginas > 0) this.paginas = paginas; } }
Aqui o pseudo-REST desenhado no capítulo 26 vira código de verdade. Um controller Spring é uma classe cujos métodos são mapeados para combinações de verbo HTTP + caminho de URL — exatamente como você previu naquele capítulo.
@RestController // @Controller + @ResponseBody: retorno vira JSON automaticamente (via Jackson, capítulo 25) @RequestMapping("/livros") public class LivroController { private final LivroServico servico; public LivroController(LivroServico servico) { this.servico = servico; } // injeção, capítulo 22 @GetMapping public List<LivroDTO> listarTodos() { return servico.listarTodos(); } @GetMapping("/{id}") public ResponseEntity<LivroDTO> buscarPorId(@PathVariable Long id) { return servico.buscarPorId(id) .map(ResponseEntity::ok) // 200 OK com o corpo .orElseGet(() -> ResponseEntity.notFound().build()); // 404, capítulo 26 } @PostMapping public ResponseEntity<LivroDTO> criar(@RequestBody LivroDTO dto) { LivroDTO criado = servico.salvar(dto); return ResponseEntity.status(HttpStatus.CREATED).body(criado); // 201 } @DeleteMapping("/{id}") public ResponseEntity<Void> deletar(@PathVariable Long id) { servico.deletar(id); return ResponseEntity.noContent().build(); // 204 } }
| Anotação | Equivale a |
|---|---|
@GetMapping | GET (capítulo 26) |
@PostMapping | POST |
@PutMapping | PUT |
@DeleteMapping | DELETE |
@PathVariable | Captura um segmento da URL (/livros/{id}) |
@RequestParam | Captura query string (?titulo=Duna) |
@RequestBody | Desserializa o corpo JSON da requisição em objeto Java |
@PathVariable/@RequestBody), repassa para quem sabe fazer (o @Service) e devolve a resposta formatada certinha. Se seu controller tem lógica de negócio complexa dentro, ele deixou de ser recepcionista e virou o cozinheiro — sinal de que essa lógica deveria estar no Service (mesma lição de SRP do capítulo 16).@RestController nunca "sabe" que existe JSON explicitamente no código — você retorna objetos Java normais (LivroDTO, List<LivroDTO>) e o Spring, por trás dos panos, usa o Jackson (capítulo 25) automaticamente para serializar, baseado no Content-Type negociado com o cliente. É por isso que entender getters/records + Jackson antes deste capítulo evita achar essa conversão mágica.Sem copiar o exemplo, implemente um AutorController com listar, buscar, criar e deletar. A criação deve retornar 201 com Location; busca ausente retorna 404; deleção inexistente também possui contrato explícito. Escreva testes MockMvc para cada status.
Critérios: controller não contém regra de negócio; usa DTO; constrói Location a partir da requisição; converte ausência em 404; valida o corpo; e os testes verificam status, header e JSON. Compare uma solução com ResponseEntity.of(optional) e outra com exceção de domínio traduzida globalmente.
Este é o capítulo em que todo o JDBC manual do capítulo 24 vira uma interface de poucas linhas. Se você entendeu PreparedStatement, ResultSet e o padrão Repository (capítulo 23), o que o Spring Data JPA automatiza vai parecer natural, não mágico.
@Entity @Table(name = "livros") public class Livro { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // equivale a SERIAL do capítulo 34 private Long id; @Column(nullable = false, length = 200) private String titulo; private int paginas; @ManyToOne @JoinColumn(name = "autor_id") // a chave estrangeira do capítulo 33 private Autor autor; // construtor sem args OBRIGATÓRIO (Hibernate cria via reflection, capítulo 20) // getters e setters... }
public interface LivroRepository extends JpaRepository<Livro, Long> { // isso é TUDO. O Spring Data JPA gera a implementação inteira em runtime, // via um Dynamic Proxy (capítulo 20!) que traduz cada chamada em SQL. List<Livro> findByAutorNome(String nome); // vira: WHERE autor.nome = ? List<Livro> findByPaginasGreaterThan(int minimo); // vira: WHERE paginas > ? Optional<Livro> findByTitulo(String titulo); // vira: WHERE titulo = ? (retorna Optional!) } // já vem de graça, sem você escrever nada: livroRepository.save(livro); // INSERT ou UPDATE livroRepository.findById(5L); // SELECT ... WHERE id = ? -- Optional<Livro>, capítulo 21 livroRepository.findAll(); // SELECT * FROM livros livroRepository.deleteById(5L); // DELETE ... WHERE id = ?
findByAutorNome) funciona porque o Spring lê o nome do método via reflection e monta a query correspondente automaticamente — é como se o método fosse escrito em uma "mini-linguagem" que descreve a consulta, e o Spring traduzisse isso para SQL na hora de rodar. É exatamente o mesmo espírito do seu @NaoNulo do capítulo 20: uma convenção lida via reflection, virando comportamento automático.@Entity public class Autor { @Id @GeneratedValue private Long id; private String nome; @OneToMany(mappedBy = "autor", cascade = CascadeType.ALL) private List<Livro> livros = new ArrayList<>(); }
| Anotação | Relação | Exemplo |
|---|---|---|
@ManyToOne | Vários para um | Vários livros → um autor |
@OneToMany | Um para vários | Um autor → vários livros (o "lado inverso" do de cima) |
@OneToOne | Um para um | Um usuário → um perfil |
@ManyToMany | Vários para vários | Livros ↔ categorias, via tabela de junção |
ddl-auto=validate em produção, nunca update — o schema é gerenciado pelo Flyway (capítulo 35), não pelo Hibernate.FetchType.LAZY em relacionamentos @ManyToOne/@OneToMany — carregar tudo eager por padrão é a raiz do problema do próximo capítulo (N+1).@Entity diretamente em um controller — sempre converta para DTO (capítulo 47) antes de retornar via API.findByX) parecer "mágico demais", escreva a query SQL equivalente à mão primeiro (usando o que você aprendeu no capítulo 33), depois compare com o nome do método. Isso constrói a intuição de tradução método↔SQL rapidamente, em vez de depender de decorar padrões de nomenclatura.Modele Pedido e ItemPedido com relacionamento um-para-muitos, ownership explícito e remoção coerente com o agregado. Crie queries por status e por intervalo de criação. Teste persistência, orphan removal, lazy loading e a constraint que impede quantidade não positiva.
Critérios: ItemPedido referencia o dono da relação; métodos auxiliares mantêm os dois lados consistentes; cascade/orphan removal têm justificativa de ciclo de vida; queries retornam tipos adequados; e o teste inspeciona o comportamento após flush e clear, não apenas a coleção em memória.
O capítulo 25 já mencionou DTOs de leve. Agora que você tem entidades JPA reais (capítulo 45), o problema fica concreto: por que não simplesmente retornar a @Entity direto no controller?
LAZY não carregado fora de uma transação ativa lança LazyInitializationException — um erro clássico e confuso de quem pula direto para produção sem DTO.@Entity como o estoque interno de uma loja — cheio de código de fornecedor, custo de aquisição, localização exata na prateleira. O DTO é a vitrine — mostra só nome, foto e preço, o que o cliente realmente precisa ver. Ninguém deixa o cliente vasculhar o estoque diretamente; sempre existe uma "vitrine" controlada entre o mundo externo e os dados internos.public record LivroDTO(Long id, String titulo, int paginas, String nomeAutor) { public static LivroDTO from(Livro livro) { return new LivroDTO( livro.getId(), livro.getTitulo(), livro.getPaginas(), livro.getAutor().getNome() // achata o relacionamento -- o cliente da API nem precisa saber que existe uma tabela "autores" ); } } // no service: public List<LivroDTO> listarTodos() { return livroRepository.buscarTodosComAutor() // JOIN FETCH, capítulo 46 -- evita N+1 aqui! .stream() .map(LivroDTO::from) // method reference, capítulo 13 .toList(); }
Mapeamento manual é ótimo para entender o problema, mas em uma entidade com 15 campos vira repetição cansativa. MapStruct gera o código de mapeamento em tempo de compilação (não reflection em runtime, ao contrário do Jackson) — rápido e com erros pegos ainda em build.
@Mapper(componentModel = "spring") // gera um @Component pronto para injeção! public interface LivroMapper { @Mapping(target = "nomeAutor", source = "autor.nome") // campo com nome diferente LivroDTO toDTO(Livro livro); List<LivroDTO> toDTOList(List<Livro> livros); } // MapStruct gera, em tempo de compilação, uma classe LivroMapperImpl // com exatamente o código que você escreveria manualmente -- sem custo // de reflection em runtime, ao contrário do Jackson (capítulo 25)
.class gerado e ver um método comum, sem reflection nenhuma) — por isso é consideravelmente mais rápido em cenários de alto volume.Escreva o LivroDTO (record) e o mapeamento manual from(Livro) como no exemplo. Depois, reescreva o mesmo mapeamento usando uma interface @Mapper do MapStruct. Compare as duas abordagens e escreva, em uma frase, quando cada uma faz mais sentido.
Ver os dois exemplos completos acima. Manual faz mais sentido em projetos pequenos ou quando o mapeamento tem lógica especial não-trivial (cálculos, formatação condicional). MapStruct compensa quando existem muitas entidades com muitos campos e o mapeamento é majoritariamente "campo bate com campo", já que elimina a repetição sem custo de performance em runtime.
Duas peças que sempre andam juntas: validar o que entra na API, e tratar de forma consistente o que dá errado — sem nenhuma delas, uma API real vira um campo minado de 500 Internal Server Error genéricos e stack traces vazando para o cliente.
Lembra do @NaoNulo que você construiu no exercício 20.1? Isso é exatamente o Bean Validation, versão oficial e madura.
public record LivroDTO( @NotBlank(message = "Título é obrigatório") String titulo, @Positive(message = "Páginas deve ser maior que zero") int paginas, @Email String emailContatoEditora ) {} @PostMapping public ResponseEntity<LivroDTO> criar(@Valid @RequestBody LivroDTO dto) { // se a validação falhar, o método NUNCA é executado -- // o Spring já responde 400 Bad Request antes de chegar aqui return ResponseEntity.ok(servico.salvar(dto)); }
| Anotação | Valida |
|---|---|
@NotNull | Não pode ser null |
@NotBlank | String não nula e não vazia (ignorando espaços) |
@Positive / @Min / @Max | Restrições numéricas |
@Email | Formato de e-mail válido |
@Size(min=, max=) | Tamanho de String/Coleção |
Sem isso, cada controller precisaria de seu próprio try/catch repetido. Um @ControllerAdvice intercepta exceções lançadas em qualquer controller da aplicação, um único lugar para decidir como cada tipo de erro vira resposta HTTP.
@ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ItemIndisponivelException.class) // sua exceção do capítulo 17! public ResponseEntity<ErroDTO> tratarItemIndisponivel(ItemIndisponivelException e) { return ResponseEntity.status(HttpStatus.CONFLICT) // 409 .body(new ErroDTO("ITEM_INDISPONIVEL", e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) // disparada por @Valid quando falha public ResponseEntity<ErroDTO> tratarValidacao(MethodArgumentNotValidException e) { String mensagens = e.getBindingResult().getFieldErrors().stream() .map(erro -> erro.getField() + ": " + erro.getDefaultMessage()) .collect(Collectors.joining("; ")); // streams, capítulo 13 return ResponseEntity.badRequest() // 400 .body(new ErroDTO("VALIDACAO", mensagens)); } @ExceptionHandler(Exception.class) // rede de segurança -- NUNCA deixe stack trace vazar public ResponseEntity<ErroDTO> tratarErroGenerico(Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) // 500 .body(new ErroDTO("ERRO_INTERNO", "Algo deu errado. Tente novamente.")); } } public record ErroDTO(String codigo, String mensagem) {}
@ExceptionHandler mais específicos deveriam vir antes do genérico — exatamente a mesma regra dos blocos catch em cascata do capítulo 10 (exercício 10.2), só que agora aplicada a nível de aplicação inteira em vez de um único método. O @ControllerAdvice é, no fundo, um dynamic proxy interceptando exceções não capturadas — o mesmo mecanismo do capítulo 20.e.getMessage()) na resposta do handler de Exception.class — ela pode conter detalhes internos (nome de tabela, query SQL, caminho de arquivo) que ajudam um atacante a entender sua infraestrutura. Log o detalhe internamente, devolva uma mensagem genérica para o cliente.@ControllerAdvice como o "cardápio de erros" da sua API — cada tipo de falha do domínio (item indisponível, saldo insuficiente, validação) deveria ter uma entrada clara, assim como você documentaria diferentes tipos de exceção customizada no capítulo 10. Um bom teste: se alguém que nunca viu seu código conseguir entender o que deu errado só pelo codigo retornado, o design está bom.Adicione validação Bean Validation ao LivroDTO (título obrigatório, páginas positivas). Escreva um @ControllerAdvice tratando MethodArgumentNotValidException (400) e ItemIndisponivelException (409), mais um handler genérico para Exception (500, sem vazar detalhes internos).
Ver o GlobalExceptionHandler completo acima — cobre exatamente os três casos pedidos.
O List<Livro> findAll() do capítulo 45 funciona bem com 20 livros de exemplo. Com 50 mil, devolver tudo de uma vez derruba a API e o front-end. Paginação é obrigatória em qualquer listagem real.
public interface LivroRepository extends JpaRepository<Livro, Long> { Page<Livro> findByTituloContainingIgnoreCase(String titulo, Pageable pageable); } @GetMapping public Page<LivroDTO> listar( @RequestParam(required = false) String titulo, @PageableDefault(size = 20, sort = "titulo") Pageable pageable) { return livroRepository .findByTituloContainingIgnoreCase(titulo == null ? "" : titulo, pageable) .map(LivroDTO::from); } // GET /livros?page=0&size=20&sort=titulo,desc&titulo=duna
O Page<T> retornado já vem com metadados prontos: total de elementos, total de páginas, se é a primeira/última — tudo que o front-end precisa para montar os controles de "próxima página".
<!-- pom.xml --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>${springdoc.version}</version> </dependency> <!-- defina springdoc.version no pom conforme a matriz de compatibilidade da sua versão do Spring Boot; acesse /swagger-ui.html -->
@Operation(summary = "Lista livros com paginação e filtro opcional por título") @GetMapping public Page<LivroDTO> listar(...) { ... }
size sem limite máximo configurável pelo cliente — sem isso, alguém pode pedir ?size=999999999 e derrubar o banco.sort=id como fallback) — sem ordem fixa, a mesma página pode retornar itens diferentes entre requisições.Adicione ao LivroRepository um método Page<Livro> findByTituloContainingIgnoreCase(String titulo, Pageable pageable). Escreva o endpoint GET /livros aceitando filtro opcional por título e paginação com tamanho padrão de 20, ordenado por título.
Além do query method, limite size no controller, imponha desempate por ID e teste filtro vazio, página além do fim e tentativa de tamanho excessivo. Como extensão, implemente cursor por (titulo,id) e explique por que é mais estável sob inserções concorrentes.
Este é o bug de performance mais comum em aplicações Spring Data JPA — silencioso, fácil de introduzir, e devastador em produção com volume real de dados.
// o código parece completamente inocente: List<Livro> livros = livroRepository.findAll(); // 1 query: SELECT * FROM livros for (Livro livro : livros) { System.out.println(livro.getAutor().getNome()); // se @ManyToOne for EAGER (padrão!), dispara // 1 query NOVA por livro para buscar o autor } // resultado: 1 (livros) + N (um SELECT de autor por livro) = N+1 queries!
Com 20 livros, isso são 21 idas ao banco em vez de uma ou duas. Com 10.000 livros, o sistema trava.
@Entity public class Livro { @ManyToOne(fetch = FetchType.EAGER) // PADRÃO do @ManyToOne se você não especificar! private Autor autor; } // findAll() + acessar .getAutor() em cada um = N+1
// opção 1: LAZY por padrão (não resolve sozinho, só adia o problema) @ManyToOne(fetch = FetchType.LAZY) private Autor autor; // opção 2: JOIN FETCH explícito -- resolve de verdade, tudo em UMA query @Query("SELECT l FROM Livro l JOIN FETCH l.autor") List<Livro> buscarTodosComAutor(); // opção 3: EntityGraph -- declarativo, mesmo efeito do JOIN FETCH @EntityGraph(attributePaths = "autor") List<Livro> findAll();
# application.properties -- mostra CADA query SQL executada:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Com isso ligado, o N+1 fica visível imediatamente no console: você vê uma query SELECT * FROM livros, seguida de vinte queries repetidas SELECT * FROM autores WHERE id = ? — o padrão salta aos olhos assim que você sabe o que procurar.
FetchType.LAZY sozinha não resolve N+1 — ela só adia: em vez de buscar o autor no momento do findAll(), o Hibernate busca no momento exato em que você chama .getAutor(), ainda uma query por vez. A solução de verdade é sempre buscar o que você sabe que vai precisar em uma única consulta com JOIN FETCH ou @EntityGraph — antecipando a necessidade, em vez de deixar o ORM decidir sozinho, linha por linha.JOIN FETCH desde o início — é muito mais fácil prevenir N+1 na hora de escrever do que caçá-lo depois com show-sql em um sistema que já está lento em produção.Usando as entidades Autor/Livro do capítulo anterior com spring.jpa.show-sql=true, escreva um endpoint que lista todos os livros e imprime o nome do autor de cada um. Confirme no log a quantidade de queries geradas. Depois, reescreva o repository com um método usando JOIN FETCH e confirme que a quantidade de queries caiu para uma só.
// versão com N+1 (usando findAll() padrão + acesso ao relacionamento): List<Livro> livros = livroRepository.findAll(); livros.forEach(l -> System.out.println(l.getAutor().getNome())); // log mostra: 1 SELECT de livros + N SELECTs de autor (um por livro) // correção -- método dedicado no repository: public interface LivroRepository extends JpaRepository<Livro, Long> { @Query("SELECT l FROM Livro l JOIN FETCH l.autor") List<Livro> buscarTodosComAutor(); } // agora: Listlivros = livroRepository.buscarTodosComAutor(); // log mostra UMA ÚNICA query, com JOIN, trazendo livro + autor juntos
Essa é a primeira dor de cabeça que praticamente todo mundo enfrenta ao conectar um front-end separado a uma API Spring pela primeira vez — geralmente descoberta via um erro confuso no console do navegador, não do back-end.
meusite.com não pode fazer requisições para api.outrosite.com sem permissão explícita. É como um segurança de prédio que só deixa passar visitantes cujo nome está numa lista pré-aprovada pela recepção — mesmo que o visitante seja legítimo, sem estar na lista ele é barrado na porta.// erro típico no console do navegador ao chamar a API sem CORS configurado:
// "Access to fetch at 'http://localhost:8080/livros' from origin
// 'http://localhost:3000' has been blocked by CORS policy"
Note que isso é um bloqueio do navegador, não da sua API — testar o mesmo endpoint via Postman (capítulo futuro) ou curl funciona normalmente, porque essas ferramentas não aplicam same-origin policy. Esse é o motivo de tanta gente ficar confusa: "funciona no Postman mas não no meu front" é quase sempre CORS.
@Configuration public class CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000", "https://meufrontend.com") // lista explícita! .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true); // necessário se usar cookies de autenticação } }; } }
allowedOrigins("*") como padrão de produção. O wildcard permite leitura por qualquer origem em requisições sem credenciais; navegadores não permitem combiná-lo com credenciais. Para cookies ou autorização entre origens, liste origens confiáveis explicitamente, processe CORS antes do Spring Security e mantenha CSRF coerente com o modelo de autenticação.Antes mesmo de autenticação (próximo capítulo), uma API pública precisa se proteger contra excesso de requisições — seja abuso deliberado ou um bug em algum cliente fazendo requisição em loop infinito.
// exemplo conceitual com Bucket4j (biblioteca de rate limiting): Bucket bucket = Bucket.builder() .addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1)))) // 100 requisições/minuto .build(); if (!bucket.tryConsume(1)) { return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); // 429 }
429 Too Many Requests quando aparecer. A implementação robusta em escala geralmente vive numa camada de infraestrutura (API Gateway, CDN) antes mesmo de chegar no seu Spring Boot.Escreva a configuração de CORS do Spring permitindo requisições apenas de http://localhost:5173 (porta padrão do Vite, ferramenta de front-end comum), métodos GET/POST/PUT/DELETE, com suporte a credenciais. Explique em uma frase por que allowedOrigins("*") não funcionaria junto com allowCredentials(true) (dica: é uma restrição de segurança do próprio navegador).
registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true);
Navegadores modernos recusam a combinação origin: * + credentials: true por design — permitir cookies/credenciais de qualquer origem simultaneamente anularia a proteção que CORS existe para dar. Por isso, sempre que precisar de allowCredentials(true), é obrigatório listar origens específicas.
Crie uma API de chamados com clientes, técnicos, comentários, prioridade e transições de status. A API precisa expressar regras do domínio, não apenas CRUD de tabelas.
Chamado fechado não recebe comentário; apenas chamado aberto pode ser atribuído; toda transição registra autor e instante; prioridade crítica exige justificativa.
Um teste unitário verifica automaticamente que uma unidade de código (geralmente um método) se comporta como esperado, isolada do resto do sistema. Em Java, o padrão de mercado é o JUnit 5 (Jupiter).
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ContaBancariaTest { @Test void deveDepositarCorretamente() { // Arrange (organizar) ContaBancaria conta = new ContaBancaria("Ana", 100); // Act (agir) conta.depositar(50); // Assert (verificar) assertEquals(150, conta.getSaldo()); } @Test void deveRecusarSaqueMaiorQueSaldo() { ContaBancaria conta = new ContaBancaria("Ana", 100); assertFalse(conta.sacar(200)); assertEquals(100, conta.getSaldo()); // saldo não deve ter mudado } @Test void deveLancarExcecaoParaMoedasDiferentes() { Dinheiro real = new Dinheiro(10, "BRL"); Dinheiro dolar = new Dinheiro(10, "USD"); assertThrows(IllegalArgumentException.class, () -> real.somar(dolar)); } }
O padrão AAA — Arrange, Act, Assert (organizar, agir, verificar) é a estrutura universal de um teste bem escrito: prepare o cenário, execute a ação, confirme o resultado. Cada teste deve testar uma única coisa.
assertEquals(esperado, atual); // valores iguais assertTrue(condicao); assertFalse(condicao); assertNull(objeto); assertNotNull(objeto); assertThrows(MinhaExcecao.class, () -> metodoQueDeveFalhar()); assertEquals(3.14159, resultado, 0.001); // double: sempre com margem de erro (delta)!
double com assertEquals sem um delta. Ponto flutuante tem imprecisão inerente — 0.1 + 0.2 == 0.3 é false em Java (e em quase toda linguagem). Sempre use a versão com terceiro parâmetro de tolerância.class ContaBancariaTest { private ContaBancaria conta; @BeforeEach // roda antes de CADA teste -- evita repetir setup void setup() { conta = new ContaBancaria("Ana", 100); } @AfterEach // roda depois de CADA teste -- útil para limpar recursos void tearDown() { /* fechar conexão, apagar arquivo temporário... */ } @Test void testeUmUsandoConta() { assertEquals(100, conta.getSaldo()); } }
@ParameterizedTest @ValueSource(ints = {-1, -10, -100}) void deveRecusarSaldoInicialNegativo(int valorInvalido) { ContaBancaria conta = new ContaBancaria("Ana", valorInvalido); assertEquals(0, conta.getSaldo()); // roda 3 vezes, uma para cada valor }
Quando uma classe depende de outra (ex: um serviço que depende de um banco de dados ou de uma API externa), testar a classe real acoplada a essa dependência deixa o teste lento e frágil. Um mock (com a biblioteca Mockito) simula essa dependência.
import static org.mockito.Mockito.*; @Test void deveEnviarEmailAoFinalizarPedido() { ServicoEmail emailMock = mock(ServicoEmail.class); // dependência "falsa" Pedido pedido = new Pedido(emailMock); pedido.finalizar(); verify(emailMock).enviar(any()); // confirma que o método FOI chamado, sem mandar e-mail de verdade }
O ciclo Red → Green → Refactor: (1) escreva um teste que falha, porque o código ainda não existe; (2) escreva o código mínimo para o teste passar; (3) refatore o código com confiança, porque o teste garante que você não quebrou o comportamento.
Escreva uma classe de teste completa para ContaBancaria (capítulo 04) cobrindo: depósito válido, saque válido, saque recusado por saldo insuficiente, e saldo inicial negativo virando zero. Use @BeforeEach para criar a conta antes de cada teste.
class ContaBancariaTest { private ContaBancaria conta; @BeforeEach void setup() { conta = new ContaBancaria("Ana", 100); } @Test void deveDepositarCorretamente() { conta.depositar(50); assertEquals(150, conta.getSaldo()); } @Test void deveSacarQuandoHaSaldo() { assertTrue(conta.sacar(30)); assertEquals(70, conta.getSaldo()); } @Test void deveRecusarSaqueSemSaldo() { assertFalse(conta.sacar(500)); assertEquals(100, conta.getSaldo()); } @Test void deveZerarSaldoInicialNegativo() { ContaBancaria outra = new ContaBancaria("Bruno", -50); assertEquals(0, outra.getSaldo()); } }
Pratique o ciclo Red-Green-Refactor: escreva primeiro o teste (que vai falhar, pois a classe nem existe) para uma classe Fila<T> com enfileirar(T item) e desenfileirar() (FIFO — o primeiro que entra é o primeiro que sai), verificando que desenfileirar uma fila vazia lança NoSuchElementException. Só depois implemente a classe até o teste passar.
// 1) RED -- o teste, escrito primeiro: class FilaTest { @Test void deveDesenfileirarNaOrdemDeEntrada() { Fila<String> fila = new Fila<>(); fila.enfileirar("a"); fila.enfileirar("b"); assertEquals("a", fila.desenfileirar()); assertEquals("b", fila.desenfileirar()); } @Test void deveLancarExcecaoQuandoVazia() { Fila<String> fila = new Fila<>(); assertThrows(java.util.NoSuchElementException.class, fila::desenfileirar); } } // 2) GREEN -- implementação mínima para passar: public class Fila<T> { private final java.util.LinkedList<T> itens = new java.util.LinkedList<>(); public void enfileirar(T item) { itens.addLast(item); } public T desenfileirar() { if (itens.isEmpty()) throw new java.util.NoSuchElementException("Fila vazia"); return itens.removeFirst(); } } // 3) REFACTOR -- com os testes verdes, dá pra trocar a implementação interna // (ex: usar um array circular) sem medo de quebrar o comportamento.
O capítulo 22 (exercício 22.1) já criou um "dublê" de teste manualmente — uma classe NotificadorFalso escrita à mão. Mockito automatiza completamente essa criação, gerando dublês dinamicamente (via proxy, capítulo 20) sem você precisar escrever uma única classe extra.
| Tipo | O que faz | Exemplo |
|---|---|---|
| Dummy | Só preenche um parâmetro obrigatório, nunca é realmente usado | Um objeto passado só porque o construtor exige, mas o teste não interage com ele |
| Stub | Retorna respostas pré-programadas fixas, não verifica interação | "Sempre retorne este valor quando chamado" |
| Mock | Além de retornar respostas programadas, permite verificar se e como foi chamado | "Confirme que enviar() foi chamado exatamente uma vez" |
| Fake | Implementação funcional simplificada, com lógica real (mas mais simples que a de produção) | O LivroRepositorioEmMemoria do capítulo 23 é um Fake |
| Spy | Envolve um objeto real, registrando chamadas, mas deixando o comportamento real acontecer (a menos que você sobrescreva partes específicas) | Útil para verificar interações em código legado difícil de refatorar |
Stub simples não precisa da sobrecarga de verificação de um Mock completo, e a biblioteca Mockito, apesar do nome, gera tanto Stubs quanto Mocks verdadeiros, dependendo de como você a usa.@ExtendWith(MockitoExtension.class) // integra Mockito com o ciclo de vida JUnit 5 (capítulo 15) class BibliotecaTest { @Mock // Mockito CRIA um dublê dinâmico da interface -- sem você escrever // NENHUMA classe NotificadorFalso manual, como no capítulo 22 private NotificadorEmprestimo notificadorMock; @InjectMocks // Mockito cria uma Biblioteca REAL, injetando os @Mock // declarados acima automaticamente no construtor private Biblioteca biblioteca; @Test void deveNotificarAoEmprestar() throws ItemIndisponivelException { biblioteca.adicionar(new Livro("Clean Code", "L001", "Robert Martin")); biblioteca.emprestar("L001"); verify(notificadorMock).notificar(any()); // CONFIRMA que o método FOI chamado } }
@Mock private LivroRepositorio repositorioMock; @InjectMocks private LivroServico servico; @Test void deveRetornarLivroQuandoExiste() { Livro livroFalso = new Livro(1L, "1984", "Orwell"); // programando: "QUANDO buscarPorId(1L) for chamado, RETORNE isso" when(repositorioMock.buscarPorId(1L)).thenReturn(Optional.of(livroFalso)); LivroDTO resultado = servico.buscarPorId(1L); assertEquals("1984", resultado.titulo()); // o servico NUNCA tocou em um banco de dados real -- repositorioMock // simplesmente devolveu o valor programado, instantaneamente } @Test void deveLancarExcecaoQuandoNaoEncontrado() { when(repositorioMock.buscarPorId(999L)).thenReturn(Optional.empty()); assertThrows(LivroNaoEncontradoException.class, () -> servico.buscarPorId(999L)); }
verify(repositorioMock).salvar(any(Livro.class)); // foi chamado, exatamente 1 vez (padrão) verify(repositorioMock, times(2)).buscarPorId(any()); // foi chamado exatamente 2 vezes verify(repositorioMock, never()).deletar(any()); // NUNCA foi chamado verify(notificadorMock, atLeastOnce()).notificar(any()); // pelo menos 1 vez // capturando o ARGUMENTO exato passado, para inspecionar seu conteúdo: ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); verify(notificadorMock).notificar(captor.capture()); assertTrue(captor.getValue().contains("Clean Code")); // confere o CONTEÚDO da mensagem
Reescreva o teste do exercício 22.1 (que usava a classe NotificadorFalso escrita manualmente) usando Mockito: @Mock para NotificadorEmprestimo, @InjectMocks para Biblioteca, e verify() para confirmar que notificar() foi chamado exatamente uma vez com uma mensagem contendo o título do livro (usando ArgumentCaptor).
@ExtendWith(MockitoExtension.class) class BibliotecaMockitoTest { @Mock private NotificadorEmprestimo notificadorMock; @InjectMocks private Biblioteca biblioteca; @Test void deveNotificarComTituloCorretoAoEmprestar() throws ItemIndisponivelException { biblioteca.adicionar(new Livro("Clean Code", "L001", "Robert Martin")); biblioteca.emprestar("L001"); ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); verify(notificadorMock, times(1)).notificar(captor.capture()); assertTrue(captor.getValue().contains("Clean Code")); } }
Implemente reservas de salas com conflitos de horário, cancelamento e política de antecedência. Comece pelos testes das regras, não pela interface.
Este projeto integra todos os tópicos do curso: pilares da POO, exceções customizadas, coleções, generics, streams e um teste unitário. A ideia: um pequeno sistema de empréstimo de livros de biblioteca.
ItemAcervo com private titulo, codigo, um construtor, getters, e um método abstrato descricaoCompleta().Livro e Revista estendendo ItemAcervo, cada uma implementando descricaoCompleta() à sua maneira (polimorfismo).Emprestavel com void emprestar() e void devolver(), e um método default boolean disponivelParaEmprestimo().ItemIndisponivelException.Biblioteca com um List<ItemAcervo> interno (generics + coleções): métodos adicionar(ItemAcervo item), buscarPorTitulo(String parte) usando streams (filter + collect), e emprestar(String codigo) que lança ItemIndisponivelException se o item já estiver emprestado.static em ItemAcervo contando quantos itens já foram cadastrados no total.Uma possível solução, juntando fundamentos, pilares e tópicos avançados:
public class ItemIndisponivelException extends Exception { public ItemIndisponivelException(String msg) { super(msg); } } public interface Emprestavel { void emprestar(); void devolver(); boolean isEmprestado(); default boolean disponivelParaEmprestimo() { return !isEmprestado(); } } public abstract class ItemAcervo implements Emprestavel { private static int totalItens = 0; private final String titulo; private final String codigo; private boolean emprestado = false; protected ItemAcervo(String titulo, String codigo) { this.titulo = titulo; this.codigo = codigo; totalItens++; } public abstract String descricaoCompleta(); public String getTitulo() { return titulo; } public String getCodigo() { return codigo; } @Override public void emprestar() { emprestado = true; } @Override public void devolver() { emprestado = false; } @Override public boolean isEmprestado() { return emprestado; } public static int getTotalItens() { return totalItens; } } public class Livro extends ItemAcervo { private final String autor; public Livro(String titulo, String codigo, String autor) { super(titulo, codigo); this.autor = autor; } @Override public String descricaoCompleta() { return getTitulo() + " (livro) — " + autor; } } public class Revista extends ItemAcervo { private final int edicao; public Revista(String titulo, String codigo, int edicao) { super(titulo, codigo); this.edicao = edicao; } @Override public String descricaoCompleta() { return getTitulo() + " (revista) — edição " + edicao; } } public class Biblioteca { private final List<ItemAcervo> acervo = new ArrayList<>(); public void adicionar(ItemAcervo item) { acervo.add(item); } public List<ItemAcervo> buscarPorTitulo(String parte) { return acervo.stream() .filter(i -> i.getTitulo().toLowerCase().contains(parte.toLowerCase())) .collect(Collectors.toList()); } public void emprestar(String codigo) throws ItemIndisponivelException { ItemAcervo item = acervo.stream() .filter(i -> i.getCodigo().equals(codigo)) .findFirst() .orElseThrow(() -> new ItemIndisponivelException("Item não encontrado")); if (!item.disponivelParaEmprestimo()) { throw new ItemIndisponivelException("Item já emprestado: " + item.getTitulo()); } item.emprestar(); } } // --- teste JUnit --- class BibliotecaTest { private Biblioteca biblioteca; private Livro livro; @BeforeEach void setup() { biblioteca = new Biblioteca(); livro = new Livro("Clean Code", "L001", "Robert Martin"); biblioteca.adicionar(livro); } @Test void deveEmprestarItemDisponivel() throws ItemIndisponivelException { biblioteca.emprestar("L001"); assertTrue(livro.isEmprestado()); } @Test void deveLancarExcecaoAoEmprestarItemJaEmprestado() throws ItemIndisponivelException { biblioteca.emprestar("L001"); assertThrows(ItemIndisponivelException.class, () -> biblioteca.emprestar("L001")); } @Test void deveBuscarPorTituloParcial() { List<ItemAcervo> resultado = biblioteca.buscarPorTitulo("clean"); assertEquals(1, resultado.size()); } }
Antes de configurar Spring Security (próximo capítulo), é essencial entender o que existe para decidir — sem esse pano de fundo, JWT vira "decoreba de configuração" em vez de escolha consciente.
HTTP, por natureza, é stateless — cada requisição chega sem memória de nenhuma requisição anterior. "Fazer login" é, na prática, um jeito de fingir que existe estado, usando um dos padrões abaixo.
1. Cliente envia login/senha
2. Servidor valida, cria uma sessão, guarda em memória/Redis: sessao_abc123 -> usuario_42
3. Servidor devolve um cookie: Set-Cookie: JSESSIONID=abc123
4. Toda requisição seguinte, o navegador manda o cookie automaticamente
5. Servidor consulta "abc123 -> usuario_42" para saber quem está fazendo a requisição
O servidor guarda o estado ("quem é abc123") — por isso não é verdadeiramente stateless. Escalar isso para múltiplos servidores exige compartilhar essa sessão entre eles (ex: guardando no Redis, capítulo 38).
1. Cliente envia login/senha
2. Servidor valida e gera um JWT assinado, contendo: { "sub": "usuario_42", "exp": 1234567890 }
3. Cliente guarda o token e manda em toda requisição: Authorization: Bearer eyJhbGc...
4. Servidor apenas VERIFICA A ASSINATURA -- não precisa consultar banco/sessão nenhuma
para saber quem é o usuário (a menos que precise revogar o token antes da expiração)
Um JWT tem três partes separadas por ponto: header.payload.signature. O payload é só Base64 (legível por qualquer um, nunca coloque senha ali!) — a segurança vem inteiramente da signature, que prova que o servidor foi quem gerou aquele token e ninguém alterou o conteúdo no meio do caminho.
| Sessão | JWT | |
|---|---|---|
| Estado | No servidor (stateful) | No próprio token (stateless) |
| Escalar múltiplos servidores | Precisa de sessão compartilhada (Redis) | Trivial — qualquer servidor com a chave pode validar |
| Revogar antes de expirar | Fácil — apaga a sessão no servidor | Difícil — o token continua "válido" até expirar, a menos que mantenha uma lista de revogação |
| Uso comum | Aplicações web tradicionais, monolito com front renderizado no servidor | API + front separados (SPA), mobile, microsserviços |
"Entrar com Google/GitHub" é OAuth2 na prática: sua aplicação nunca vê a senha do usuário — o Google autentica, e devolve para sua aplicação um token comprovando "esse usuário se autenticou com sucesso, aqui está o e-mail dele".
PedidoServico delegava a criação do Notificador para quem "montava" a aplicação. Você confia na abstração, não precisa reimplementar a parte difícil.Pegue um JWT de exemplo (pode gerar um em jwt.io, usando o payload {"sub":"usuario_42","exp":1999999999}) e identifique as três partes separadas por ponto. Decodifique manualmente a parte do meio (payload) usando Base64 e confirme que consegue ler o conteúdo sem precisar de nenhuma chave secreta — reforçando por que dados sensíveis nunca deveriam ir ali.
Um JWT tem o formato header.payload.signature. Copiando só a parte do meio e decodificando de Base64 (qualquer decodificador online ou echo "..." | base64 -d no terminal), o conteúdo {"sub":"usuario_42","exp":1999999999} aparece em texto legível, sem exigir nenhuma chave — confirmando que JWT é assinado, não criptografado, e por isso nunca deve carregar informação sensível.
Spring Security é notoriamente a parte do ecossistema Spring com curva de aprendizado mais íngreme — não porque seja mal desenhado, mas porque resolve um problema genuinamente complexo (autenticação + autorização) de forma extremamente configurável.
Toda requisição HTTP passa por uma cadeia de filtros antes de chegar ao seu @RestController. O Spring Security se insere nessa cadeia, interceptando a requisição para validar autenticação antes mesmo do controller ser chamado.
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) // desabilitado para APIs stateless com JWT (explicado abaixo) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // sem sessão! .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/auth/registrar").permitAll() // público .requestMatchers(HttpMethod.GET, "/livros/**").permitAll() // leitura pública .requestMatchers("/admin/**").hasRole("ADMIN") // só admin .anyRequest().authenticated() // resto exige login ) .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) // seu filtro JWT customizado .build(); } }
@Service public class JwtServico { private final String chaveSecreta = "..."; // NUNCA hardcoded de verdade -- vem de variável de ambiente (capítulo 27!) public String gerarToken(Usuario usuario) { return Jwts.builder() .subject(usuario.getEmail()) .claim("role", usuario.getRole()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 3600_000)) // 1 hora .signWith(chaveComoBytes()) .compact(); } public boolean validar(String token) { try { Jwts.parser().verifyWith(chaveComoBytes()).build().parseSignedClaims(token); return true; } catch (JwtException e) { // expirado, assinatura inválida, malformado... return false; } } }
@PreAuthorize("hasRole('ADMIN')") // checa ANTES do método executar -- outro dynamic proxy, capítulo 20! @DeleteMapping("/livros/{id}") public ResponseEntity<Void> deletar(@PathVariable Long id) { ... } @PreAuthorize("#id == authentication.principal.id") // só o PRÓPRIO usuário pode editar seu perfil @PutMapping("/usuarios/{id}") public ResponseEntity<Void> atualizar(@PathVariable Long id, ...) { ... }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // hash unidirecional com "salt" -- nunca reversível } // ao registrar: usuario.setSenha(passwordEncoder.encode(senhaDigitada)); // ao logar: if (passwordEncoder.matches(senhaDigitada, usuario.getSenha())) { // compara hash, nunca a senha crua // gera o JWT }
csrf().disable() só é seguro em APIs verdadeiramente stateless (JWT, sem cookies de sessão) — nunca desabilite CSRF em uma aplicação que usa sessão tradicional.@PreAuthorize refinado ou OAuth2. Segurança se aprende construindo camada por camada, exatamente como você fez com o resto deste curso.Desenhe (em pseudo-código ou texto estruturado) o fluxo completo de registro e login para o sistema de biblioteca: endpoint de registro que faz hash da senha com BCrypt antes de salvar; endpoint de login que valida a senha e gera um JWT; e a configuração de SecurityFilterChain liberando /auth/** publicamente e exigindo autenticação para /livros/** em métodos que não sejam GET.
@PostMapping("/auth/registrar") public ResponseEntity<Void> registrar(@RequestBody RegistroDTO dto) { Usuario usuario = new Usuario(dto.email(), passwordEncoder.encode(dto.senha())); usuarioRepository.save(usuario); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PostMapping("/auth/login") public ResponseEntity<TokenDTO> login(@RequestBody LoginDTO dto) { Usuario usuario = usuarioRepository.findByEmail(dto.email()) .orElseThrow(() -> new CredenciaisInvalidasException()); if (!passwordEncoder.matches(dto.senha(), usuario.getSenha())) { throw new CredenciaisInvalidasException(); } return ResponseEntity.ok(new TokenDTO(jwtServico.gerarToken(usuario))); }
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/livros/**").permitAll()
.anyRequest().authenticated()
)
Todo JWT, toda senha, todo dado sensível que você protegeu nos últimos dois capítulos viaja pela rede como texto simples se a conexão não for HTTPS — qualquer um na mesma rede Wi-Fi poderia interceptar e ler tudo. HTTPS é o que torna todo o resto deste curso realmente seguro na prática.
HTTPS usa TLS (Transport Layer Security) para criptografar a comunicação entre cliente e servidor. Um certificado digital — emitido por uma Autoridade Certificadora (CA) confiável — prova que o servidor é realmente quem diz ser, evitando que alguém finja ser sua API (ataque conhecido como man-in-the-middle).
Authorization: Bearer eyJ... do capítulo 52 viaja em texto puro — qualquer um na rede pode roubar o token e se passar pelo usuário.Secure (que só é enviado sobre HTTPS) andam juntos em produção séria.Explique, em texto, por que enviar um JWT (capítulo 52) sobre uma conexão HTTP simples (não HTTPS) anula boa parte da segurança que o BCrypt e a assinatura do token oferecem, mesmo que a senha nunca tenha sido enviada em texto puro no login.
Mesmo com senha protegida por BCrypt no banco e o JWT assinado corretamente, se a conexão não for HTTPS, o token em si viaja em texto puro em toda requisição subsequente — qualquer pessoa capturando o tráfego de rede (ex: em um Wi-Fi público) pode copiar esse token e reutilizá-lo para se passar pelo usuário até o token expirar, sem nunca precisar saber a senha. HTTPS protege o transporte; BCrypt e assinatura JWT protegem o conteúdo — os dois são necessários, um não substitui o outro.
Adicione identidade e autorização ao help desk. Papéis mínimos: cliente, técnico e administrador. Autenticação não basta: cada recurso precisa validar propriedade e permissão.
"Na minha máquina funciona" é o problema que Docker resolve. Um container empacota sua aplicação junto com tudo que ela precisa para rodar (runtime Java, bibliotecas, variáveis de ambiente) em uma unidade isolada e portátil, idêntica em qualquer máquina.
# baixe o Docker Desktop em docker.com (exige WSL2 habilitado) wsl --install # se ainda não tiver o WSL2 # depois instale o Docker Desktop pelo site e reinicie
brew install --cask docker
# abra o Docker Desktop uma vez para iniciar o daemon# Ubuntu/Debian -- use o repositório apt oficial documentado em docs.docker.com.
# Evite executar scripts remotos diretamente com "curl | sh".
# Depois de instalar docker-ce e os plugins, valide:
sudo systemctl status docker
docker versiondocker --version
docker run hello-world # confirma que tudo está funcionando
| Conceito | O que é | Analogia |
|---|---|---|
| Imagem | Um "molde" somente leitura, com o sistema de arquivos e configuração da aplicação | A classe (capítulo 01) |
| Container | Uma instância em execução dessa imagem, isolada | O objeto (capítulo 01) |
| Volume | Armazenamento persistente fora do ciclo de vida do container | Um HD externo plugado no container |
docker pull postgres:16 # baixa a imagem do Docker Hub docker run -d --name meu-postgres -e POSTGRES_PASSWORD=senha123 -p 5432:5432 postgres:16 # -d: roda em background (detached) # -e: variável de ambiente # -p host:container: mapeia a porta 5432 da sua máquina para a 5432 do container docker ps # containers rodando docker logs meu-postgres # ver a saída/log do container docker exec -it meu-postgres bash # abre um terminal DENTRO do container docker stop meu-postgres docker rm meu-postgres # remove o container (a imagem continua no disco)
root dentro de um container em produção sem necessidade — é uma superfície de ataque desnecessária.docker run -d --name meu-postgres -e POSTGRES_PASSWORD=senha123 \ -v dados_postgres:/var/lib/postgresql/data \ -p 5432:5432 postgres:16 # "dados_postgres" é um volume nomeado, gerenciado pelo Docker -- # mesmo removendo e recriando o container, os dados do banco sobrevivem docker volume ls docker volume rm dados_postgres # só aí os dados são realmente apagados
docker run em vez de instalar o banco na máquina. Isso não é só conveniência: significa que seu ambiente de desenvolvimento é descartável e reproduzível — se algo quebrar, você apaga o container e sobe outro do zero em segundos, sem "desinstalar e reinstalar" nada no seu sistema operacional.Suba um container Postgres 16 com senha biblioteca123, expondo a porta 5432, com um volume nomeado db_biblioteca para persistência. Confirme que está rodando com docker ps, entre no container com docker exec -it e rode psql -U postgres para confirmar acesso ao banco.
docker run -d --name pg-biblioteca \ -e POSTGRES_PASSWORD=biblioteca123 \ -v db_biblioteca:/var/lib/postgresql/data \ -p 5432:5432 \ postgres:16 docker ps docker exec -it pg-biblioteca psql -U postgres
Um Dockerfile é a receita que descreve como construir sua própria imagem — no nosso caso, a imagem da aplicação Java que você escreveu ao longo do curso.
# Dockerfile ingênuo -- funciona, mas é PROBLEMÁTICO:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
CMD ["java", "-jar", "target/biblioteca-1.0.0.jar"]
.jar já compilado rodando. Imagens assim facilmente passam de 500MB-1GB.# --- ESTÁGIO 1: build (usa o JDK completo, mas é DESCARTADO no final) --- FROM eclipse-temurin:21-jdk AS build WORKDIR /app COPY .mvn .mvn COPY mvnw pom.xml ./ RUN ./mvnw -B -DskipTests dependency:go-offline COPY src ./src RUN ./mvnw -B package -DskipTests # --- ESTÁGIO 2: runtime (imagem final, enxuta, só com o necessário) --- FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/target/biblioteca-1.0.0.jar app.jar EXPOSE 8080 RUN useradd --system --uid 10001 app USER 10001 ENTRYPOINT ["java", "-jar", "app.jar"]
O estágio final usa apenas o runtime Java e copia somente o .jar pronto. A aplicação roda como usuário sem privilégios. O tamanho exato depende da imagem e das camadas; meça em vez de assumir. Imagens Alpine usam musl e podem ter diferenças de compatibilidade, portanto não devem ser escolhidas apenas pelo tamanho.
| Comando | Para que serve |
|---|---|
FROM | Imagem base a partir da qual construir |
WORKDIR | Diretório de trabalho dentro do container |
COPY | Copia arquivos do host para dentro da imagem |
RUN | Executa um comando durante o build da imagem |
EXPOSE | Documenta qual porta a aplicação usa (não abre a porta sozinho — isso é o -p do docker run) |
ENTRYPOINT/CMD | Comando executado quando o container inicia |
docker build -t biblioteca-api:1.0 . # constrói a imagem a partir do Dockerfile na pasta atual docker run -d -p 8080:8080 biblioteca-api:1.0 docker images # lista imagens locais, compare o tamanho antes/depois do multi-stage
pom.xml são copiados, então dependency:go-offline cria uma camada antes de copiar src. Se apenas o código mudar, a camada de dependências pode ser reaproveitada. Copiar src antes de baixar as dependências invalidaria essa vantagem.Escreva um Dockerfile multi-stage para o projeto da biblioteca: estágio 1 com JDK compilando pelo Maven Wrapper, estágio 2 com JRE copiando só o .jar e executando sem privilégios. Construa, rode, inspecione usuário e tamanho e compare com uma versão single-stage.
FROM eclipse-temurin:21-jdk AS build WORKDIR /app COPY .mvn .mvn COPY mvnw pom.xml ./ RUN ./mvnw -B -DskipTests dependency:go-offline COPY src ./src RUN ./mvnw -B package -DskipTests FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 RUN useradd --system --uid 10001 app USER 10001 ENTRYPOINT ["java", "-jar", "app.jar"]
docker build -t biblioteca-api . docker run -d -p 8080:8080 --name biblioteca biblioteca-api docker images | grep biblioteca
Um sistema real não é um container sozinho — é a API, o banco, talvez um Redis e um Kafka, todos rodando juntos e conversando entre si. Docker Compose descreve essa "orquestra" inteira em um único arquivo YAML.
# docker-compose.yml services: api: build: . # usa o Dockerfile da pasta atual ports: - "8080:8080" environment: DB_URL: jdbc:postgresql://db:5432/biblioteca # "db" -- o NOME do serviço vira hostname! DB_USER: postgres DB_PASSWORD: senha123 depends_on: - db db: image: postgres:16 environment: POSTGRES_DB: biblioteca POSTGRES_PASSWORD: senha123 volumes: - db_data:/var/lib/postgresql/data ports: - "5432:5432" volumes: db_data:
docker compose up -d # sobe TODOS os serviços definidos docker compose logs -f api # acompanha o log só do serviço "api" docker compose down # derruba tudo (mas mantém os volumes por padrão) docker compose down -v # derruba tudo E apaga os volumes (cuidado!)
db (o nome do serviço), não localhost. Esse é o ponto que mais confunde quem começa: de dentro do container da API, localhost se refere ao próprio container da API, não ao container do banco ao lado.Escreva um docker-compose.yml com dois serviços: api (build a partir do Dockerfile do capítulo 31, porta 8080) e db (Postgres 16, com volume nomeado e variáveis de ambiente para nome do banco, usuário e senha). Suba tudo com um único comando e confirme com docker compose ps que os dois serviços estão de pé.
services:
api:
build: .
ports:
- "8080:8080"
environment:
DB_URL: jdbc:postgresql://db:5432/biblioteca
DB_USER: postgres
DB_PASSWORD: biblioteca123
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_DB: biblioteca
POSTGRES_PASSWORD: biblioteca123
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
db_data:
docker compose up -d docker compose ps
Os testes do capítulo 15 usavam mocks — rápidos, mas cegos para o comportamento real do banco. Uma query JOIN FETCH mal escrita (capítulo 46) pode passar perfeitamente em um teste com mock e quebrar completamente contra um Postgres real. Testcontainers fecha essa lacuna.
@SpringBootTest @Testcontainers class LivroRepositoryTest { @Container // sobe um Postgres REAL em Docker, só para esta classe de teste static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16"); @DynamicPropertySource static void configurarPropriedades(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired private LivroRepository livroRepository; @Test void deveSalvarEBuscarLivro() { Livro livro = new Livro("Duna", 688); livroRepository.save(livro); Optional<Livro> encontrado = livroRepository.findById(livro.getId()); assertTrue(encontrado.isPresent()); } } // esse Postgres sobe do zero antes da classe rodar, e é destruído depois -- // as migrations do Flyway (capítulo 35) rodam automaticamente nele, exatamente // como rodariam em produção
spring.datasource.url) é sobrescrita dinamicamente, porque o Testcontainers escolhe uma porta aleatória disponível a cada execução — evitando conflito se você tiver outro Postgres rodando na porta padrão 5432 na sua máquina (como o do capítulo 30). Isso também significa que testes com Testcontainers podem rodar em paralelo, cada um com seu próprio banco isolado, sem nunca conflitar entre si.Escreva um teste com Testcontainers para o LivroRepository (capítulo 45), confirmando que findByTituloContainingIgnoreCase encontra um livro mesmo com capitalização diferente do título buscado (ex: buscar "duna" encontra "Duna"). Isso é algo que um mock jamais provaria, porque depende do comportamento real do LIKE ILIKE do Postgres.
@Test void deveEncontrarPorTituloIgnorandoCapitalizacao() { livroRepository.save(new Livro("Duna", 688)); Page<Livro> resultado = livroRepository .findByTituloContainingIgnoreCase("duna", PageRequest.of(0, 10)); assertEquals(1, resultado.getTotalElements()); } // esse teste só faz sentido de verdade contra um Postgres REAL -- // um mock simplesmente retornaria o que você programasse, sem validar // se a query SQL gerada realmente ignora capitalização
"NoSQL" não é um produto — é um guarda-chuva para bancos não relacionais com modelos e garantias muito diferentes. Alguns oferecem validação de schema, transações, replicação e consistência configurável; outros priorizam disponibilidade, latência ou escala. Avalie o produto e a configuração concretos, não uma suposta regra universal de NoSQL.
| Família | Unidade de dado | Exemplo | Bom para |
|---|---|---|---|
| Documento | Documento JSON-like, esquema flexível | MongoDB | Dados semi-estruturados, protótipos rápidos, catálogos de produto |
| Chave-valor | Par chave → valor simples | Redis | Cache, sessão, contadores, filas simples |
| Coluna larga | Linhas com colunas dinâmicas por família | Cassandra | Escrita massiva, séries temporais em grande escala |
| Grafo | Nós e relacionamentos | Neo4j | Redes sociais, recomendações, dados fortemente interconectados |
É "qual encaixa melhor no formato dos meus dados e no meu padrão de acesso". Um sistema real frequentemente usa os dois — Postgres para dados transacionais estruturados (pedidos, pagamentos, usuários) e MongoDB/Redis para partes específicas onde flexibilidade ou velocidade importam mais que rigidez relacional.
| Relacional (SQL) | Documento (NoSQL) | |
|---|---|---|
| Esquema | Fixo, definido antes (migrations) | Flexível, cada documento pode variar |
| Relacionamentos | JOINs nativos e eficientes | Geralmente aninha dados ou referencia manualmente |
| Transações complexas | ACID e relacionamentos são centrais | Varia por produto; MongoDB oferece transações, mas o modelo deve evitar depender delas indiscriminadamente |
| Escala horizontal | Depende do produto, particionamento e operação | Muitos foram desenhados para distribuição, ainda com custo operacional |
| Exemplo de bom uso | Sistema financeiro, estoque, pedidos | Catálogo de produtos com atributos variáveis, logs, cache de sessão |
Para cada cenário, decida se SQL (Postgres) ou um banco de documentos (MongoDB) encaixa melhor, justificando em uma frase: (a) sistema de pedidos de um e-commerce com estoque e pagamento; (b) catálogo de produtos onde cada categoria tem atributos completamente diferentes (roupas têm tamanho/cor, eletrônicos têm voltagem/garantia); (c) log de eventos de uma aplicação, gravado em alto volume e raramente consultado por relacionamento.
(a) SQL/Postgres — pedidos, estoque e pagamento têm relacionamentos fortes e exigem transações consistentes (débito de estoque + criação de pedido precisam ser atômicos, como vimos no capítulo 33). (b) MongoDB — atributos variáveis por categoria de produto é exatamente o caso onde esquema flexível economiza a dor de ter colunas vazias ou tabelas de atributos genéricas complicadas. (c) MongoDB (ou um banco de coluna larga em escala maior) — logs são escritos em alto volume, raramente precisam de JOIN, e o formato pode variar entre tipos de evento; forçar isso em tabelas relacionais rígidas costuma ser desnecessariamente trabalhoso.
docker run -d --name mongo -p 27017:27017 -v mongodata:/data/db mongo:7
brew tap mongodb/brew brew install mongodb-community@7.0 brew services start mongodb-community@7.0
# instalador oficial em mongodb.com/try/download/communitymongosh # abre o shell interativo do Mongo, similar ao psql
Em vez de linhas em uma tabela, o MongoDB guarda documentos BSON (um binário eficiente parecido com JSON) dentro de coleções (o equivalente a uma tabela, mas sem esquema fixo).
db.livros.insertOne({
titulo: "Duna",
autor: "Frank Herbert",
paginas: 688,
generos: ["ficção científica", "aventura"], // array direto no documento!
editora: { nome: "Aleph", pais: "Brasil" } // objeto aninhado, sem precisar de JOIN
});
db.livros.find({ paginas: { $gt: 300 } }); // equivalente a WHERE paginas > 300
db.livros.find({ generos: "aventura" }); // busca dentro do array
db.livros.updateOne({ titulo: "Duna" }, { $set: { disponivel: false } });
db.livros.deleteOne({ titulo: "Duna" });
// EMBEDDING: dados aninhados dentro do mesmo documento // bom quando os dados são sempre lidos juntos e não crescem sem limite { titulo: "Duna", autor: { nome: "Frank Herbert", nacionalidade: "EUA" } } // REFERENCING: guarda só o ID, como uma chave estrangeira "manual" // bom quando os dados são grandes, mudam com frequência, ou são // compartilhados entre muitos documentos { titulo: "Duna", autorId: ObjectId("64f...") }
db.livros.find({ paginas: { $gte: 200, $lte: 500 } }); // entre 200 e 500
db.livros.aggregate([
{ $match: { disponivel: true } },
{ $group: { _id: "$autor", total: { $sum: 1 } } },
{ $sort: { total: -1 } }
]);
// o "aggregate" é o equivalente ao GROUP BY + JOIN combinados do SQL
mongosh é para MongoDB o que o psql é para Postgres — pratique digitando consultas direto nele antes de escrever qualquer código Java. Entender a "forma" da consulta em isolamento evita confundir erro de sintaxe do Mongo com erro de código Java quando você conectar as duas coisas via Spring Data MongoDB mais adiante.Usando mongosh conectado a um MongoDB rodando via Docker, crie uma coleção livros e insira 3 documentos, cada um com titulo, autor (objeto aninhado com nome e nacionalidade) e um array generos. Escreva uma consulta que retorna só livros de ficção científica, e outra que conta quantos livros cada nacionalidade de autor tem, usando aggregate.
db.livros.insertMany([
{ titulo: "Duna", autor: { nome: "Frank Herbert", nacionalidade: "EUA" }, generos: ["ficção científica"] },
{ titulo: "Neuromancer", autor: { nome: "William Gibson", nacionalidade: "EUA" }, generos: ["ficção científica", "cyberpunk"] },
{ titulo: "Dom Casmurro", autor: { nome: "Machado de Assis", nacionalidade: "Brasil" }, generos: ["romance"] }
]);
db.livros.find({ generos: "ficção científica" });
db.livros.aggregate([
{ $group: { _id: "$autor.nacionalidade", total: { $sum: 1 } } }
]);
Redis guarda tudo em memória RAM — é por isso que é absurdamente rápido (microssegundos, não milissegundos) e por isso que não é o lugar certo para seus dados "de verdade" e permanentes.
docker run -d --name redis -p 6379:6379 redis:7
brew install redis brew services start redis
redis-cli # shell interativo
SET usuario:1:nome "Felipy" # string simples GET usuario:1:nome SET livro:42:visualizacoes 0 INCR livro:42:visualizacoes # operação atômica -- lembra da race condition do capítulo 14! SET usuario:1:sessao "token-opaco" EX 3600 # cria a chave já com TTL atômico TTL usuario:1:sessao # quanto tempo falta para expirar LPUSH fila:emails "enviar-boas-vindas@user1" # lista -- fila simples RPOP fila:emails SADD tags:livro:42 "ficção" "aventura" # set -- sem duplicatas, como o capítulo 11 HSET usuario:1 nome "Felipy" cidade "Custódia" # hash -- como um objeto/Map dentro da chave HGET usuario:1 nome
synchronized? O comando INCR do Redis é atômico por natureza — Redis processa comandos um de cada vez, em uma única thread, então nunca existe a janela de "duas leituras antes de uma escrita" que causava aquele bug. É por isso que Redis é usado com frequência para contadores compartilhados entre múltiplas instâncias de uma aplicação, mesmo rodando em servidores diferentes.public Livro buscarLivro(long id) { String chave = "livro:" + id; String cacheado = redis.get(chave); if (cacheado != null) { return desserializar(cacheado); // achou no cache -- retorna sem tocar no banco } Livro livro = repositorio.buscarPorId(id); // não achou -- vai no Postgres redis.set(chave, serializar(livro), Duration.ofMinutes(10)); // guarda para a PRÓXIMA vez return livro; }
redis.delete(chave)). Esquecer de invalidar o cache ao atualizar dados é a causa mais comum de "por que o sistema está mostrando informação errada" em produção — e o próprio Spring tem anotações (@CacheEvict) para automatizar isso quando você chegar no módulo Spring.Usando o Redis rodando via Docker e a classe LivroRepositorioJdbc do capítulo 24, implemente uma classe LivroServicoComCache que primeiro tenta buscar do Redis; se não encontrar, busca do repositório JDBC e grava no Redis com TTL de 5 minutos antes de retornar. Escreva um pequeno teste manual que busca o mesmo livro duas vezes e confirma (via log) que a segunda vez veio do cache, não do banco.
public class LivroServicoComCache { private final LivroRepositorio repositorio; private final JedisPool redisPool; // cliente Redis (biblioteca Jedis) public LivroServicoComCache(LivroRepositorio repositorio, JedisPool redisPool) { this.repositorio = repositorio; this.redisPool = redisPool; } public Optional<Livro> buscarPorId(long id) { String chave = "livro:" + id; try (Jedis redis = redisPool.getResource()) { String cacheado = redis.get(chave); if (cacheado != null) { System.out.println("[CACHE HIT] " + chave); return Optional.of(desserializar(cacheado)); } System.out.println("[CACHE MISS] " + chave + " -- indo ao banco"); Optional<Livro> livro = repositorio.buscarPorId(id); livro.ifPresent(l -> redis.setex(chave, 300, serializar(l))); // 300s = 5 minutos return livro; } } }
Um sistema real nunca usa um único banco — usa pelo menos três "versões" dele, com propósitos diferentes. Confundir esses ambientes é uma das formas mais comuns de causar incidentes graves.
| Ambiente | Onde roda | Dados |
|---|---|---|
| Desenvolvimento (dev) | Docker local (capítulo 30-32), na sua máquina | Fictícios, descartáveis, recriados livremente |
| Teste (automatizado) | Container efêmero via Testcontainers (capítulo futuro), sobe e morre a cada execução | Gerados pelo próprio teste, isolados de tudo |
| Staging/Homologação | Ambiente gerenciado, similar à produção | Cópia (anonimizada) ou similar à produção, para validar antes do deploy real |
| Produção | Provedor gerenciado (ver abaixo) | Dados reais de usuários reais — tratamento máximo de cuidado |
É possível, mas significa que você vira responsável por backups, atualizações de segurança, monitoramento de disco cheio, réplicas de alta disponibilidade — tudo manualmente. Provedores de banco gerenciado assumem essa operação por você:
| Provedor | Característica |
|---|---|
| Supabase | Postgres gerenciado + autenticação + API automática, camada gratuita generosa, ótimo para projetos pessoais/MVPs |
| Neon | Postgres serverless — escala a zero quando não usado, cobrança por uso real |
| Railway / Render | Banco + aplicação no mesmo lugar, deploy simples, bom para projetos pequenos/médios |
| AWS RDS | Padrão corporativo, altamente configurável, exige mais conhecimento de infraestrutura |
| MongoDB Atlas | Equivalente gerenciado para MongoDB |
DELETE sem WHERE nesse cenário é irreversível.Para o projeto da biblioteca construído ao longo do curso, escreva (em texto) uma proposta de configuração de ambientes: onde rodaria o banco de dev, como os testes automatizados (capítulo 15/futuro Testcontainers) se conectariam a um banco, e qual provedor gerenciado você escolheria para produção, justificando a escolha.
Dev: Postgres via docker compose (capítulo 32), rodando localmente, com dados fictícios recriados sempre que necessário. Testes automatizados: Testcontainers sobe um Postgres novo e isolado a cada execução da suíte de testes, garantindo que nenhum teste dependa de estado deixado por outro. Produção: Supabase ou Neon — para um projeto de biblioteca de porte pequeno/médio, a camada gratuita/inicial cobre bem, com backups automáticos incluídos, sem exigir que você mesmo administre um servidor de banco.
Quando seu back-end depende de outro serviço (outra API, um serviço de pagamento externo), o que acontece quando esse serviço fica lento ou fora do ar? Sem proteção, sua aplicação também trava — um efeito cascata que pode derrubar um sistema inteiro por causa de uma única dependência externa instável.
@Service public class PagamentoExternoServico { @CircuitBreaker(name = "servico-pagamento", fallbackMethod = "pagamentoIndisponivel") public RespostaPagamento processar(Pagamento pagamento) { return clienteHttp.chamarServicoExterno(pagamento); // pode falhar, pode demorar } // chamado automaticamente quando o circuito está "aberto" (desarmado) public RespostaPagamento pagamentoIndisponivel(Pagamento pagamento, Throwable t) { return new RespostaPagamento("INDISPONIVEL", "Tente novamente em instantes"); } }
| Estado | Comportamento |
|---|---|
| Fechado (normal) | Chamadas passam normalmente; falhas são contadas |
| Aberto | Depois de muitas falhas seguidas, para de tentar chamar o serviço real — chama o fallbackMethod direto, instantaneamente |
| Meio-aberto | Depois de um tempo, deixa passar algumas chamadas de teste para ver se o serviço voltou a funcionar |
@Transactional (capítulo 33) e @PreAuthorize (capítulo 52), @CircuitBreaker funciona via dynamic proxy (capítulo 20) — o método real só é chamado através de um wrapper que monitora falhas e decide se deve ou não deixar a chamada passar. É o mesmo mecanismo de interceptação reaparecendo pela quarta vez no curso, cada vez resolvendo um problema transversal diferente (transação, autorização, resiliência) sem espalhar código repetido em cada método de negócio.Escreva uma classe ServicoFrete com um método calcularFrete(String cep) anotado com @CircuitBreaker, simulando uma chamada a uma API externa de frete. Escreva o método de fallback correspondente, retornando um valor de frete padrão fixo quando o serviço externo estiver indisponível.
@Service public class ServicoFrete { @CircuitBreaker(name = "servico-frete", fallbackMethod = "freteFallback") public double calcularFrete(String cep) { return clienteHttp.consultarFreteExterno(cep); // pode lançar exceção ou demorar demais } public double freteFallback(String cep, Throwable t) { return 25.00; // valor padrão -- melhor que travar o checkout inteiro } }
Sua API não vive isolada — frequentemente ela mesma precisa consumir outra API (serviço de frete, gateway de pagamento, API de terceiros). O capítulo 62 mostrou o front-end fazendo isso com fetch/axios; este capítulo mostra o back-end Spring fazendo o equivalente.
@Service public class CepServico { private final WebClient webClient; public CepServico(WebClient.Builder builder) { this.webClient = builder.baseUrl("https://viacep.com.br").build(); } @CircuitBreaker(name = "cep-servico", fallbackMethod = "enderecoFallback") // capítulo 55! public EnderecoDTO buscarEndereco(String cep) { return webClient.get() .uri("/ws/{cep}/json", cep) .retrieve() .bodyToMono(EnderecoDTO.class) .block(); // versão síncrona simples -- .block() espera o resultado } public EnderecoDTO enderecoFallback(String cep, Throwable t) { return new EnderecoDTO("Endereço indisponível"); } }
@Bean public RestTemplate restTemplate() { return new RestTemplate(); } // uso: EnderecoDTO endereco = restTemplate.getForObject( "https://viacep.com.br/ws/{cep}/json", EnderecoDTO.class, cep);
RestTemplate está deprecated em favor do RestClient síncrono. Use WebClient quando o fluxo for não bloqueante, reativo ou streaming. Em Spring Framework 6, RestTemplate permanece comum em legado, mas RestClient oferece a API síncrona moderna.@RestController chamando @Service chamando o banco, capítulo 44) é uma conversa cara a cara, uma chamada via WebClient para uma API externa é uma ligação telefônica para um fornecedor terceiro — mais lenta, sujeita a falhar por motivos fora do seu controle, e por isso sempre deveria estar protegida por circuit breaker (capítulo 55), timeout e, quando fizer sentido, cache (capítulo 71).WebClient retorna Mono<T> ou Flux<T> e preserva I/O não bloqueante enquanto a cadeia inteira permanece reativa. Chamar .block() transforma a fronteira em síncrona e não deve ocorrer em uma thread de event loop. Para uma aplicação MVC síncrona, RestClient costuma comunicar melhor a intenção; para WebFlux, componha o Mono sem bloquear.Escreva um CepServico usando WebClient para consultar a API pública ViaCEP, protegido por @CircuitBreaker com um fallback retornando um endereço genérico "indisponível" em caso de falha.
Inclua timeout de conexão e resposta, mapeamento de 4xx/5xx, circuit breaker e teste com servidor HTTP simulado. O fallback não deve transformar CEP inválido em sucesso; falha funcional e indisponibilidade transitória precisam de resultados diferentes. Em MVC síncrono, apresente também a versão com RestClient.
Você já viu o efeito de AOP quatro vezes no curso — @Transactional, @PreAuthorize, @CircuitBreaker, @Cacheable (próximo capítulo) — sempre com a mesma explicação de "dynamic proxy intercepta a chamada". Este capítulo formaliza esse padrão para você escrever o seu próprio aspecto.
@Aspect @Component public class LogExecucaoAspect { private static final Logger log = LoggerFactory.getLogger(LogExecucaoAspect.class); // "pointcut": QUAIS métodos interceptar -- todo método em qualquer classe do pacote servico @Around("execution(* com.felipy.biblioteca.servico..*(..))") public Object medirTempo(ProceedingJoinPoint joinPoint) throws Throwable { long inicio = System.nanoTime(); Object resultado = joinPoint.proceed(); // chama o método REAL -- exatamente como metodo.invoke() do capítulo 20 long duracaoMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - inicio); log.info("{} executou em {}ms", joinPoint.getSignature(), duracaoMs); return resultado; } }
| Anotação | Quando executa |
|---|---|
@Before | Antes do método real |
@After | Depois do método real (sucesso ou exceção) |
@AfterReturning | Só se o método retornar normalmente |
@AfterThrowing | Só se o método lançar exceção |
@Around | Envolve a chamada inteira — o mais poderoso, permite decidir se o método real roda ou não |
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MedirTempo {} @Aspect @Component public class MedirTempoAspect { @Around("@annotation(MedirTempo)") // intercepta qualquer método marcado com @MedirTempo public Object medir(ProceedingJoinPoint jp) throws Throwable { /* ... */ } } // uso, em qualquer service: @MedirTempo public void processarPedido(Pedido p) { ... }
@LogExecucao lá era só metadado lido manualmente. Aqui, o @Aspect registra automaticamente um dynamic proxy (capítulo 20) para toda classe gerenciada pelo Spring cujo método bata com o pointcut — exatamente o mesmo mecanismo por trás de @Transactional, @PreAuthorize e @CircuitBreaker, só que agora você escreve a regra de interceptação em vez de só consumir uma pronta.this.metodoAnotado()) nunca passa pelo proxy, então o aspecto nunca dispara — a chamada interna não passa pela "câmera de segurança" que só observa chamadas vindas de fora da classe. Isso já apareceu no capítulo 52, e reaparece aqui pela mesma razão estrutural.@Transactional, @Cacheable, @PreAuthorize). Escreva seu próprio @Aspect quando identificar uma preocupação transversal genuinamente específica do seu domínio, repetida em muitos lugares — não para logar uma única chamada, onde um log direto já resolveria mais simplesmente.Crie uma anotação @Auditavel e um @Aspect correspondente que, usando @Around, registra em log o nome do método, os argumentos recebidos e se a execução terminou com sucesso ou exceção. Aplique em um método de Biblioteca (ex: emprestar).
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Auditavel {} @Aspect @Component public class AuditoriaAspect { private static final Logger log = LoggerFactory.getLogger(AuditoriaAspect.class); @Around("@annotation(Auditavel)") public Object auditar(ProceedingJoinPoint jp) throws Throwable { log.info("Chamando {}", jp.getSignature().getName()); // não registre args: podem conter senha, token ou PII try { Object resultado = jp.proceed(); log.info("{} concluído com sucesso", jp.getSignature().getName()); return resultado; } catch (Throwable e) { log.warn("{} lançou exceção: {}", jp.getSignature().getName(), e.getMessage()); throw e; // SEMPRE relance -- um aspecto nunca deve engolir a exceção original } } }
Você implementou cache-aside na mão no exercício 38.1 (checar Redis, se não achar buscar do banco, gravar de volta). O Spring formaliza esse padrão inteiro em uma única anotação.
@Service public class LivroServico { @Cacheable(value = "livros", key = "#id") // mesma lógica do exercício 38.1, automática public LivroDTO buscarPorId(Long id) { System.out.println("Buscando no banco -- só aparece no CACHE MISS"); return repository.findById(id).map(LivroDTO::from).orElseThrow(); } @CacheEvict(value = "livros", key = "#id") // invalida o cache quando o dado muda -- o problema do capítulo 38! public void atualizar(Long id, LivroDTO dto) { // ... atualiza no banco ... } @CachePut(value = "livros", key = "#result.id") // #result referencia o retorno do método public LivroDTO criar(LivroDTO dto) { ... } }
# application.properties -- conectando ao Redis real (capítulo 38):
spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
@Cacheable é literalmente um aspecto AOP (capítulo 70) construído pelo próprio time do Spring: o método real (buscarPorId) só é chamado se a chave não existir no cache — o proxy intercepta a chamada, checa o Redis primeiro, e só delega para o código real em caso de cache miss. É exatamente o padrão que você implementou manualmente no exercício 38.1, formalizado em uma anotação.@CacheEvict correspondente em métodos de atualização é a causa mais comum de "por que o sistema mostra dado desatualizado" com Spring Cache — a anotação não invalida sozinha, você precisa declarar explicitamente qual operação de escrita deveria limpar qual cache.@Cacheable apenas depois de identificar, com dados reais (não suposição), que um método específico é chamado com frequência e é caro o suficiente para justificar a complexidade extra de invalidação. Cache prematuro tende a introduzir bugs de dado desatualizado sem ganho de performance mensurável.Adicione @Cacheable ao método buscarPorId do LivroServico, e @CacheEvict ao método de atualização correspondente. Habilite o cache no application.properties apontando para o Redis local do capítulo 30/38.
Inclua @EnableCaching, TTL, chave estável, política para resultado ausente e teste que conte acessos ao repositório. Verifique invalidação após commit e documente o comportamento com duas instâncias. Demonstre por teste que self-invocation não atravessa o proxy de cache.
Como você sabe se sua aplicação em produção está "de pé" e saudável, sem precisar logar manualmente no servidor? Actuator expõe endpoints prontos com informações operacionais da aplicação.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
GET /actuator/health {"status": "UP"} GET /actuator/health (com detalhes habilitados) { "status": "UP", "components": { "db": {"status": "UP"}, "diskSpace": {"status": "UP"} } }
/actuator/health é um sinal operacional. Kubernetes pode usar probes para retirar uma instância do tráfego ou reiniciá-la conforme a configuração. Docker e Compose, isoladamente, apenas marcam o container como unhealthy; o HEALTHCHECK não cria uma política automática de restart.# docker-compose.yml -- usando o health check para o Compose saber quando a API está pronta: services: api: build: . healthcheck: test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/actuator/health/readiness || exit 1"] interval: 10s timeout: 5s retries: 3 start_period: 30s # a imagem precisa conter wget; se não contiver, use um probe disponível # ou faça a checagem externamente. Não adicione curl sem ajustar a imagem.
| Endpoint | Mostra |
|---|---|
/actuator/health | Status geral e de dependências (banco, disco) |
/actuator/metrics | Métricas de JVM, requisições, threads |
/actuator/info | Metadados customizáveis da aplicação (versão, build) |
/actuator/loggers | Ver e alterar nível de log em runtime, sem reiniciar |
/actuator/env podem vazar variáveis de ambiente (incluindo secrets, capítulo seguinte). Em produção, restrinja quais endpoints ficam expostos e proteja-os atrás de autenticação (capítulo 52), geralmente liberando só /actuator/health publicamente para health checks de infraestrutura.Adicione o Actuator ao projeto da biblioteca. Configure um healthcheck no serviço api do docker-compose.yml (capítulo 32), apontando para /actuator/health, e ajuste o depends_on do serviço para esperar o healthcheck do banco também (dica: depends_on: db: condition: service_healthy).
services:
api:
build: .
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
O capítulo 27 já cobriu ${DB_USER} em application.properties. Falta o lado de produção: onde essas variáveis realmente vivem quando sua aplicação não está mais rodando na sua máquina.
# .env (arquivo LOCAL, nunca commitado)
DB_URL=jdbc:postgresql://localhost:5432/biblioteca
DB_USER=postgres
DB_PASSWORD=senha123
JWT_SECRET=uma-chave-bem-longa-e-aleatoria
# .gitignore -- capítulo 29, regra de ouro reforçada:
.env
*.env.local
| Provedor | Onde configurar |
|---|---|
| Railway / Render | Painel web → aba "Environment Variables", nunca no código |
| GitHub Actions (CI/CD) | "Repository Secrets" — criptografados, nunca aparecem em log |
| AWS | AWS Secrets Manager / Parameter Store — rotação automática possível |
| Docker Compose (produção) | Arquivo .env separado, referenciado mas não versionado |
ARG/ENV com uma senha durante o docker build (capítulo 31), ela pode ficar gravada permanentemente nas camadas da imagem, mesmo que você "remova" depois — qualquer um com acesso à imagem consegue extrair. Secrets deveriam ser injetados em runtime (no docker run ou no provedor de deploy), nunca embutidos durante o build..env ou um application-prod.properties com credenciais reais por engano, porque o .gitignore não estava configurado desde o primeiro commit. Ferramentas como git-secrets ou o próprio GitHub (que escaneia pushes públicos em busca de padrões de chave de API conhecidos) existem justamente porque esse erro é extremamente comum, não incomum..env sempre no .gitignore, desde o primeiro commit do projeto (capítulo 29).System.out.println(System.getenv()) "de debug" esquecido é um vazamento sério.Revise (mentalmente ou em um projeto real seu) se existe algum application.properties, .env ou arquivo de configuração com credencial real já commitado no histórico do Git. Escreva os passos que você tomaria para corrigir isso: (1) adicionar ao .gitignore, (2) remover do controle de versão sem apagar localmente, (3) rotacionar a credencial.
# 1. adiciona ao .gitignore para não commitar de novo echo "application-prod.properties" >> .gitignore # 2. remove do controle de versão SEM apagar o arquivo local git rm --cached application-prod.properties git commit -m "Remove credenciais do controle de versão" # 3. MAIS IMPORTANTE: troca a senha/chave real no provedor -- # o arquivo continua no histórico antigo do Git, então a credencial # antiga precisa ser considerada COMPROMETIDA e substituída
Os testes automatizados do capítulo 15 só protegem seu código se alguém realmente lembrar de rodá-los. CI (Integração Contínua) automatiza isso: a cada push, um servidor roda seus testes sozinho. CD (Entrega/Deploy Contínuo) vai além, publicando automaticamente se tudo passar.
# .github/workflows/ci.yml name: CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # baixa o código - name: Configurar Java uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Rodar testes # Testcontainers precisa de Docker -- já vem pronto no runner run: ./mvnw test - name: Build do jar run: ./mvnw package -DskipTests if: github.ref == 'refs/heads/main' # só builda de verdade na branch principal
Toda vez que alguém der git push ou abrir um Pull Request (capítulo 29), esse workflow roda automaticamente — sem precisar de nenhuma máquina ligada, o GitHub fornece o servidor (runner) sob demanda.
build-and-push:
needs: test # só roda se o job "test" passar antes
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Login no Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USER }} # capítulo 57!
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build e push
uses: docker/build-push-action@v5
with:
push: true
tags: felipysantsss/biblioteca-api:latest
secrets.DOCKERHUB_TOKEN nunca aparece em texto no arquivo YAML — ele é configurado uma única vez em "Settings → Secrets" do repositório GitHub (a mesma ideia do capítulo 57, só que gerenciado pela própria plataforma de CI), e o GitHub Actions automaticamente mascara qualquer valor de secret que apareça acidentalmente nos logs, substituindo por ***.main dispara o build da imagem Docker (capítulo 31) e push para um registrymain — pegar problemas antes do merge é o objetivo central.Escreva um workflow do GitHub Actions que roda a cada push e Pull Request, configura Java 21, e executa ./mvnw test para o projeto da biblioteca (incluindo os testes com Testcontainers do capítulo 54).
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- run: ./mvnw test
Quatro práticas que raramente aparecem em tutorial nenhum, mas que separam "sei fazer deploy" de "sei operar um sistema em produção".
Quanto mais diferente seu ambiente de dev é do de produção (versão do Postgres, versão do Java, variáveis diferentes), mais surpresas desagradáveis esperam no deploy. Environment parity é o princípio de manter dev, staging e produção o mais parecidos possível.
Ninguém pensa em backup até o dia em que precisa dele — e nesse dia, já é tarde para configurar. Todo provedor de banco gerenciado (capítulo 39) sério oferece backup automático e, frequentemente, point-in-time recovery (restaurar o banco para o estado exato de um momento específico, não só do último backup diário).
Um feature flag separa "o código está em produção" de "a funcionalidade está visível para os usuários" — permitindo fazer deploy de código incompleto ou arriscado com segurança, ativando gradualmente.
if (featureFlags.isEnabled("novo-checkout", usuario)) { return novoFluxoCheckout(pedido); } return fluxoCheckoutAtual(pedido); // comportamento padrão, sem risco
application.properties, às vezes uma ferramenta dedicada como LaunchDarkly) em vez de lógica fixa no código. Isso permite desligar uma funcionalidade problemática em produção sem precisar de um novo deploy — só mudando a flag.Sua API já tem clientes reais consumindo (o próprio front-end, capítulos futuros). Mudar um campo de resposta sem cuidado quebra tudo que depende dela silenciosamente.
GET /api/v1/livros -- versão atual, clientes antigos continuam funcionando GET /api/v2/livros -- nova versão, com mudanças incompatíveis, coexistindo com v1
MAJOR.MINOR.PATCH): mudanças que quebram compatibilidade incrementam a versão maior (v1 → v2), permitindo que clientes migrem no seu próprio ritmo, em vez de serem forçados a atualizar da noite para o dia porque a API mudou embaixo deles sem aviso.application.properties, e um prefixo /api/v1 nas suas rotas desde o início. A disciplina importa mais que a ferramenta.Adicione uma propriedade feature.busca-avancada.enabled=false ao application.properties. No LivroController, injete esse valor com @Value("${feature.busca-avancada.enabled}") e use-o para decidir, no endpoint de listagem, se aplica um filtro de busca avançado (pode ser um comentário simulando a lógica) ou o comportamento padrão.
@RestController public class LivroController { @Value("${feature.busca-avancada.enabled}") private boolean buscaAvancadaHabilitada; @GetMapping("/api/v1/livros") public List<LivroDTO> listar(@RequestParam(required=false) String q) { if (buscaAvancadaHabilitada && q != null) { return servico.buscaAvancada(q); // lógica nova, só ativa com a flag } return servico.listarTodos(); // comportamento padrão, sempre seguro } }
É a hora de tirar a API do localhost e colocá-la em um servidor real, acessível pela internet. Existem três níveis de controle/complexidade, e a escolha certa depende do estágio do projeto.
| Opção | Nível de controle | Esforço de setup | Bom para |
|---|---|---|---|
| Railway / Render | Baixo (PaaS) | Mínimo — conecta o repositório e pronto | Projetos pessoais, MVPs, aprendizado |
| VPS com Docker (DigitalOcean, Hetzner) | Médio | Você configura o servidor, mas usa Docker Compose | Mais controle, custo previsível |
| AWS EC2 / ECS | Alto | Maior curva de aprendizado, mais flexibilidade | Aplicações corporativas, escala grande |
# 1. conecta o repositório GitHub ao Railway (via interface web) # 2. Railway detecta o Dockerfile (capítulo 31) automaticamente # 3. configura as variáveis de ambiente no painel (capítulo 57): DB_URL=jdbc:postgresql://... JWT_SECRET=... # 4. a cada push na branch configurada, Railway builda e faz deploy sozinho -- # o CI/CD do capítulo 58 combinado com isso fecha o ciclo completo
# na sua máquina local ou via CI/CD: docker context create producao --docker "host=ssh://usuario@meu-servidor.com" docker context use producao docker compose up -d --build # builda e sobe DIRETO no servidor remoto
docker compose up -d --build) é exatamente o mesmo que você já usa localmente desde o capítulo 32 — a única diferença é o docker context apontando para uma máquina remota via SSH em vez da sua própria. Isso é a prova prática de "environment parity" (capítulo 59): se funciona no seu Compose local, a chance de funcionar igual em produção é muito maior, porque literalmente é a mesma configuração rodando em outro lugar.Provedores PaaS (Railway, Render) entregam um subdomínio próprio com HTTPS automático (capítulo 53) imediatamente. Para domínio próprio (api.seusite.com), você aponta um registro DNS CNAME para o endereço fornecido pelo provedor — a renovação do certificado continua automática.
docker compose up.Escreva um checklist de pré-deploy para o projeto da biblioteca antes de publicar em produção pela primeira vez, cobrindo pelo menos: variáveis de ambiente configuradas, migrations aplicadas, HTTPS ativo, CORS configurado para o domínio real do front-end, e healthcheck respondendo.
☐ Variáveis de ambiente (DB_URL, JWT_SECRET) configuradas no painel do provedor, não no código ☐ Flyway configurado com spring.flyway.enabled=true, ddl-auto=validate ☐ HTTPS confirmado (certificado ativo, sem aviso de "conexão não segura") ☐ CORS liberando apenas o domínio real do front-end de produção, não localhost ☐ /actuator/health respondendo "UP" publicamente, resto do Actuator protegido ☐ Backup automático do banco confirmado como ativo no provedor ☐ Logs acessíveis via painel do provedor para depuração pós-deploy
O capítulo 27 mencionou application-dev.properties e application-prod.properties de leve. Aqui aprofundamos como o Spring decide qual arquivo usar, e como isso se conecta com beans inteiros que só deveriam existir em certos ambientes.
# application.properties (compartilhado por todos os profiles) # application-dev.properties (só quando o profile "dev" está ativo) # application-prod.properties (só quando "prod" está ativo) # ativando um profile: java -jar app.jar --spring.profiles.active=prod # ou via variável de ambiente (capítulo 57): SPRING_PROFILES_ACTIVE=prod
@Service @Profile("dev") public class EmailServicoFake implements NotificadorEmprestimo { @Override public void notificar(String msg) { System.out.println("[SIMULADO] " + msg); // não envia e-mail de verdade em dev } } @Service @Profile("prod") public class EmailServicoReal implements NotificadorEmprestimo { @Override public void notificar(String msg) { /* SMTP real */ } }
NotificadorEmprestimo é um dublê que só finge enviar e-mail; em produção, entra o ator principal que faz a ligação de verdade. A peça (a lógica de negócio da Biblioteca, capítulo 22) nunca sabe a diferença, porque só conhece a interface.@Profile só decide, em tempo de inicialização, qual implementação concreta o Spring vai registrar como o bean daquela interface. Nenhum conceito novo, apenas uma nova forma de acionar padrões que você já domina profundamente.Crie EmailServicoFake (profile dev, só imprime no console) e EmailServicoReal (profile prod, simula envio real), ambos implementando NotificadorEmprestimo. Confirme que a Biblioteca (capítulo 22) continua funcionando sem nenhuma mudança de código, independente de qual profile está ativo.
Escreva dois testes de contexto com profiles diferentes e confirme qual bean existe em cada um. Faça o contexto falhar quando nenhum profile fornecer a dependência e explique por que credencial não deve ficar em application-prod.yml. Como alternativa, use propriedade condicional e compare com @Profile.
Leve um dos projetos Spring até um ambiente remoto. O objetivo não é “ficou online”; é conseguir implantar, observar, diagnosticar e reverter.
Interrompa o banco, provoque latência, envie entrada inválida e reinicie uma instância. Para cada evento, registre sinal observado, hipótese, diagnóstico e ação corretiva.
Uma thread é um fluxo independente de execução dentro do mesmo processo. Múltiplas threads compartilham a mesma memória (heap) — o que é ótimo para performance, mas é exatamente essa memória compartilhada que causa a maioria dos bugs de concorrência.
// forma 1: estender Thread (menos flexível -- Java só permite herança simples) class MinhaThread extends Thread { @Override public void run() { System.out.println("Rodando em: " + Thread.currentThread().getName()); } } new MinhaThread().start(); // start(), NUNCA run() diretamente! // forma 2: implementar Runnable (preferida -- permite herdar de outra classe) Runnable tarefa = () -> System.out.println("Rodando em: " + Thread.currentThread().getName()); new Thread(tarefa).start();
.run() diretamente executa o código na thread atual, como uma chamada de método comum — nenhuma thread nova é criada! Só .start() de fato agenda uma nova thread na JVM. Esse é um erro clássico de quem está aprendendo.public class Contador { private int valor = 0; public void incrementar() { valor++; } // NÃO é atômico! é ler + somar + gravar public int getValor() { return valor; } } // duas threads chamando incrementar() 100_000 vezes cada, ao mesmo tempo, // frequentemente NÃO resulta em 200_000 -- porque "valor++" não é uma // operação única: threads podem ler o mesmo valor antes de uma delas gravar.
valor = 5 e, antes de gravar 6, a Thread B também lê valor = 5 e grava 6, o incremento da Thread A se perde quando ela grava por cima com o mesmo 6. Esse é o bug de concorrência mais comum que existe, e é justamente por isso que synchronized e as classes Atomic* existem.public class Contador { private int valor = 0; public synchronized void incrementar() { valor++; } // só uma thread por vez executa isto public synchronized int getValor() { return valor; } } // ou um bloco sincronizado sobre um objeto específico -- mais granular: private final Object trava = new Object(); void operacaoCritica() { synchronized (trava) { // só uma thread por vez entra aqui } }
// AtomicInteger: incremento atômico sem precisar de synchronized manual import java.util.concurrent.atomic.AtomicInteger; AtomicInteger contador = new AtomicInteger(0); contador.incrementAndGet(); // thread-safe de verdade // ExecutorService: um pool de threads gerenciado, em vez de "new Thread()" solto import java.util.concurrent.*; ExecutorService pool = Executors.newFixedThreadPool(4); Future<Integer> resultado = pool.submit(() -> { return 2 + 2; // tarefa que retorna um valor }); System.out.println(resultado.get()); // bloqueia até o resultado ficar pronto pool.shutdown(); // sempre encerre o pool quando não precisar mais dele
Thread diretamente em uma aplicação real. O padrão em produção é usar ExecutorService (pool gerenciado) ou, em Java 21+, virtual threads (Thread.ofVirtual()), que são muito mais leves. synchronized continua essencial para entender por que essas abstrações existem.Crie a classe Contador (sem synchronized) do exemplo acima. No main, crie duas threads, cada uma chamando incrementar() 100.000 vezes, use .join() em ambas para esperar terminarem, e imprima getValor(). Rode algumas vezes e observe que o resultado nem sempre é 200.000. Depois adicione synchronized nos métodos e confirme que o resultado passa a ser sempre correto.
public class Contador { private int valor = 0; public synchronized void incrementar() { valor++; } public int getValor() { return valor; } } // main: Contador contador = new Contador(); Runnable tarefa = () -> { for (int i = 0; i < 100_000; i++) contador.incrementar(); }; Thread t1 = new Thread(tarefa); Thread t2 = new Thread(tarefa); t1.start(); t2.start(); t1.join(); t2.join(); // espera as duas terminarem antes de continuar System.out.println(contador.getValor()); // 200000, sempre, graças ao synchronized
Usando ExecutorService com um pool de 3 threads, submeta 5 tarefas que calculam o quadrado de um número (1 a 5) e retornam o resultado via Future. Colete todos os resultados em uma lista e imprima a soma total. Não esqueça de chamar shutdown() no final.
ExecutorService pool = Executors.newFixedThreadPool(3); List<Future<Integer>> futuros = new ArrayList<>(); for (int i = 1; i <= 5; i++) { final int n = i; futuros.add(pool.submit(() -> n * n)); } int soma = 0; for (Future<Integer> f : futuros) { soma += f.get(); // bloqueia até essa tarefa terminar } System.out.println("Soma dos quadrados: " + soma); // 55 pool.shutdown();
Até aqui, toda comunicação do curso foi síncrona: o cliente chama a API e espera a resposta ali mesmo (capítulo 26). Mensageria resolve o problema de comunicação assíncrona — um serviço manda uma mensagem e segue em frente, sem esperar quem vai processá-la, nem quando.
Se o serviço de e-mail estiver fora do ar no momento em que um pedido é finalizado, uma chamada HTTP síncrona falharia e travaria (ou quebraria) o fluxo de finalização do pedido — mesmo que enviar e-mail seja secundário ao pedido em si. Com mensageria, o pedido publica uma mensagem "pedido finalizado" e segue seu fluxo; o serviço de e-mail processa essa mensagem quando estiver disponível, sem acoplar o sucesso do pedido ao sucesso do envio de e-mail.
| Padrão | Como funciona | Exemplo |
|---|---|---|
| Fila (Queue) | Cada mensagem é consumida por um único consumidor | Processar pagamentos — cada pagamento processado uma vez, por um worker |
| Pub/Sub | Cada mensagem é entregue a todos os assinantes interessados | "Pedido criado" — o serviço de e-mail, o de estoque e o de analytics reagem, cada um à sua maneira, ao mesmo evento |
| Garantia | Significado | Risco |
|---|---|---|
| At-most-once | Mensagem entregue no máximo uma vez | Pode perder mensagens se algo falhar |
| At-least-once | Mensagem entregue uma ou mais vezes | Pode duplicar processamento — exige idempotência |
| Exactly-once | Mensagem processada exatamente uma vez | Mais caro/complexo de garantir de ponta a ponta; raramente 100% absoluto na prática |
Na prática, a maioria dos sistemas de mensageria (incluindo Kafka, próximo capítulo) opera em at-least-once: é mais barato garantir "entrega pelo menos uma vez, às vezes duas" do que "exatamente uma vez, sempre". Isso significa que seu consumidor precisa ser idempotente — processar a mesma mensagem duas vezes não pode causar efeito duplicado.
// ❌ NÃO idempotente -- processar a mesma mensagem 2x credita 2x: void processarPagamento(MensagemPagamento msg) { conta.creditar(msg.getValor()); } // ✅ idempotente -- usa um ID único da mensagem para NUNCA processar duas vezes: void processarPagamento(MensagemPagamento msg) { if (processadosRepositorio.jaProcessado(msg.getId())) { return; // já vimos essa mensagem antes -- ignora silenciosamente } conta.creditar(msg.getValor()); processadosRepositorio.marcarComoProcessado(msg.getId()); }
synchronized/AtomicInteger garantiam uma operação atômica dentro de uma aplicação, um registro de "IDs já processados" (geralmente com uma restrição UNIQUE no banco, capítulo 33) garante atomicidade de processamento entre múltiplas instâncias consumindo a mesma fila.Escreva uma classe ProcessadorEmprestimo com um método processar(String mensagemId, String codigoLivro) que empresta um livro (reaproveitando a lógica da Biblioteca dos capítulos anteriores). Use um Set<String> em memória para rastrear IDs de mensagem já processados, garantindo que processar a mesma mensagemId duas vezes não empreste o livro duas vezes.
public class ProcessadorEmprestimo { private final Biblioteca biblioteca; private final Set<String> mensagensProcessadas = new HashSet<>(); public ProcessadorEmprestimo(Biblioteca biblioteca) { this.biblioteca = biblioteca; } public synchronized void processar(String mensagemId, String codigoLivro) throws ItemIndisponivelException { if (mensagensProcessadas.contains(mensagemId)) { System.out.println("Mensagem " + mensagemId + " já processada -- ignorando"); return; } biblioteca.emprestar(codigoLivro); mensagensProcessadas.add(mensagemId); } }
Kafka é a ferramenta de mensageria mais usada em sistemas de médio/grande porte — não é exatamente uma fila tradicional, e sim um log distribuído: mensagens são gravadas em ordem e mantidas por um tempo configurável, permitindo que múltiplos consumidores leiam o mesmo histórico de formas independentes.
# docker-compose.yml -- Kafka moderno não precisa mais de Zookeeper separado (modo KRaft):
services:
kafka:
image: apache/kafka:3.7.0
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
docker compose up -d kafka
# dentro do container:
kafka-topics.sh --create --topic pedidos-finalizados --bootstrap-server localhost:9092 --partitions 3
kafka-topics.sh --list --bootstrap-server localhost:9092
// --- Producer: publica uma mensagem --- Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); KafkaProducer<String, String> producer = new KafkaProducer<>(props); producer.send(new ProducerRecord<>("pedidos-finalizados", "pedido-42", jsonDoEvento)); producer.close(); // --- Consumer: lê mensagens --- Properties consumerProps = new Properties(); consumerProps.put("bootstrap.servers", "localhost:9092"); consumerProps.put("group.id", "servico-email"); consumerProps.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); consumerProps.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps); consumer.subscribe(List.of("pedidos-finalizados")); while (true) { ConsumerRecords<String, String> registros = consumer.poll(Duration.ofMillis(500)); for (ConsumerRecord<String, String> r : registros) { System.out.println("Recebido: " + r.value()); } }
Vários consumidores com o mesmo group.id dividem as partições de um tópico entre si — cada mensagem vai para um consumidor do grupo (comportamento de fila). Consumidores com group.id diferentes recebem cada um uma cópia completa das mensagens (comportamento de pub/sub).
group.id como "qual equipe está lendo". Se o serviço de e-mail e o serviço de estoque têm group.id diferentes, os dois recebem toda mensagem — como duas pessoas diferentes lendo o mesmo grupo. Mas dentro do próprio serviço de e-mail, se você roda 3 instâncias com o mesmo group.id (para dividir a carga), cada mensagem vai para só uma das três — como revezar quem responde cada mensagem do grupo, sem duplicar trabalho.| Kafka | RabbitMQ | |
|---|---|---|
| Modelo | Log distribuído, mensagens retidas por tempo configurável | Fila tradicional, mensagem some ao ser confirmada (ack) |
| Replay | Consumidor pode reler mensagens antigas | Não, uma vez consumida e confirmada, some |
| Melhor para | Alto volume, múltiplos consumidores do mesmo evento, streaming de eventos | Roteamento de mensagens complexo, filas de prioridade, menor volume |
key) da mensagem determina a partição — mensagens com a mesma chave sempre vão para a mesma partição, preservando ordem entre elas.kafka-console-producer.sh e kafka-console-consumer.sh (ferramentas de linha de comando que vêm com o Kafka) publicando e lendo mensagens de texto simples. Ver o comportamento "cru" evita confundir problema de configuração do Spring com problema de entendimento do Kafka em si.Suba Kafka via Docker Compose. Crie o tópico pedidos-finalizados com 3 partições. Escreva uma classe PedidoProducer que publica uma mensagem JSON simples (id do pedido) sempre que chamada. Escreva uma classe NotificacaoConsumer que consome desse tópico e imprime cada mensagem recebida. Rode os dois e confirme que mensagens publicadas aparecem no consumidor.
kafka-topics.sh --create --topic pedidos-finalizados --bootstrap-server localhost:9092 --partitions 3
public class PedidoProducer { private final KafkaProducer<String, String> producer; public PedidoProducer() { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); this.producer = new KafkaProducer<>(props); } public void publicar(String pedidoId) { producer.send(new ProducerRecord<>("pedidos-finalizados", pedidoId, "{\"id\":\"" + pedidoId + "\"}")); } } public class NotificacaoConsumer { public void iniciar() { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("group.id", "servico-notificacao"); props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); consumer.subscribe(List.of("pedidos-finalizados")); while (true) { var registros = consumer.poll(Duration.ofMillis(500)); for (var r : registros) System.out.println("Notificando sobre: " + r.value()); } } }
O que você acabou de escrever manualmente no capítulo 41 (configurar Properties, criar producer/consumer, fazer o loop de poll) é exatamente o boilerplate que o Spring Kafka elimina — mesma relação de "entenda antes de automatizar" que já vimos com JDBC → Spring Data JPA.
// producer: uma linha, o Spring já gerencia a configuração via application.properties @Service public class PedidoEventPublisher { private final KafkaTemplate<String, String> kafkaTemplate; public PedidoEventPublisher(KafkaTemplate<String, String> kafkaTemplate) { // injeção, capítulo 22! this.kafkaTemplate = kafkaTemplate; } public void publicarPedidoFinalizado(String pedidoId) { kafkaTemplate.send("pedidos-finalizados", pedidoId, "{\"id\":\"" + pedidoId + "\"}"); } } // consumer: uma anotação substitui o loop de poll() inteiro @Service public class NotificacaoListener { @KafkaListener(topics = "pedidos-finalizados", groupId = "servico-notificacao") public void ouvir(String mensagem) { System.out.println("Notificando sobre: " + mensagem); } }
# application.properties
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=servico-notificacao
spring.kafka.consumer.auto-offset-reset=earliest
@KafkaListener, o Spring cria uma thread dedicada (relembra o capítulo 14) que fica rodando o equivalente ao while(true) { poll(...) } manual que você escreveu no capítulo anterior — só que gerenciado, com retry configurável e integração automática com o restante do container de beans. É exatamente o mesmo padrão de "reflection + anotação automatizando boilerplate" que apareceu em todo o capítulo 20.Reescreva o PedidoProducer e NotificacaoConsumer do exercício 41.1 usando KafkaTemplate e @KafkaListener. Configure o application.properties com o bootstrap-servers e group-id corretos.
@Service public class PedidoEventPublisher { private final KafkaTemplate<String, String> kafkaTemplate; public PedidoEventPublisher(KafkaTemplate<String, String> kafkaTemplate) { this.kafkaTemplate = kafkaTemplate; } public void publicar(String pedidoId) { kafkaTemplate.send("pedidos-finalizados", pedidoId, "{\"id\":\"" + pedidoId + "\"}"); } } @Service public class NotificacaoListener { @KafkaListener(topics = "pedidos-finalizados", groupId = "servico-notificacao") public void ouvir(String mensagem) { System.out.println("Notificando: " + mensagem); } }
Os capítulos 04 a 08 ensinaram cada pilar isoladamente, com exemplos práticos. Este capítulo volta a todos os quatro juntos, com o nível de profundidade teórica que se espera de alguém explicando esses conceitos em uma entrevista técnica sênior — cada um com diagrama, analogia adicional e a pergunta "o que realmente está acontecendo por dentro".
┌─────────────────────────────────────────┐
│ ContaBancaria │
│ ┌─────────────────────────────────────┐ │
│ │ ESTADO PRIVADO (invisível de fora) │ │
│ │ private double saldo = 1000; │ │
│ └─────────────────────────────────────┘ │
│ │
│ INTERFACE PÚBLICA (única porta de entrada)│
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ depositar(v) │ │ sacar(v) │ │
│ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────┘
▲ ▲
│ │
código externo código externo
(não pode fazer (só pode interagir
saldo = -500 através dos métodos
diretamente) que aplicam regras)
A ideia central, revisitada com mais rigor teórico: encapsulamento não é "esconder por esconder" — é estabelecer um contrato entre o objeto e o resto do sistema. O objeto promete: "eu garanto que meu estado interno sempre respeitará minhas invariantes (regras que sempre devem ser verdadeiras), desde que você só interaja comigo através da minha interface pública". Um invariante de ContaBancaria poderia ser "saldo nunca é negativo" — e essa garantia só é possível porque nenhum código externo consegue atribuir diretamente a saldo.
┌───────────────┐
│ Funcionario │ ← superclasse
│ - nome │
│ - salarioBase │
│ + calcularSalario()
└───────┬───────┘
│ extends
┌─────────────┴─────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Gerente │ │ Estagiario │
│ - bonus │ │ - bolsaAuxilio │
│ + calcularSalario()│ │ + calcularSalario()
│ (SOBRESCRITO) │ │ (SOBRESCRITO) │
└─────────────────┘ └─────────────────┘
Gerente e Estagiario HERDAM nome e salarioBase de Funcionario,
mas cada um REDEFINE calcularSalario() com sua própria regra --
isso já é uma prévia do PRÓXIMO pilar (polimorfismo)
Herança modela relações "é um" (is-a): um Gerente é um Funcionario. A palavra-chave extends não copia código magicamente — ela estabelece que toda instância de Gerente também é, simultaneamente, uma instância válida de Funcionario, herdando sua estrutura de memória e comportamento, podendo especializar partes dele.
Funcionario[] equipe = { new Gerente(...), new Estagiario(...) };
para cada "f" em equipe:
f.calcularSalario()
│
▼
┌─────────────────────────────────────────┐
│ A JVM olha o tipo REAL do objeto no │
│ HEAP (não o tipo da variável "f") │
│ e decide, EM TEMPO DE EXECUÇÃO, qual │
│ versão de calcularSalario() chamar -- │
│ isso se chama LIGAÇÃO DINÂMICA │
└─────────────────────────────────────────┘
│
┌────┴────┐
▼ ▼
Gerente Estagiario
(uma (uma lógica
lógica) diferente)
Tecnicamente, por trás dos panos, a JVM implementa isso através de uma estrutura chamada vtable (virtual method table): cada classe tem uma tabela associando nomes de métodos aos endereços de código real que devem ser executados. Quando você chama f.calcularSalario(), a JVM não decide em tempo de compilação qual código rodar — ela consulta a vtable do objeto real apontado por f em tempo de execução, e salta para o endereço correto ali registrado. É essa indireção, uma consulta extra a uma tabela, que torna polimorfismo possível — e também por que métodos final/private/static (capítulo 06) não precisam dessa consulta: o compilador já sabe exatamente qual código rodar, sem ambiguidade nenhuma.
Este é o pilar mais frequentemente mal-entendido — muita gente confunde "abstração" com "classe abstrata" (capítulo 07), quando na verdade classe abstrata é só uma ferramenta de linguagem para expressar abstração, não o conceito em si.
MUNDO REAL: um carro tem milhares de detalhes -- motor de combustão interna com centenas de peças, sistema elétrico complexo, suspensão, pneus com composição química específica... MODELAGEM PARA UM SISTEMA DE ALUGUEL DE CARROS: ┌───────────────────────────┐ │ Carro │ ← ABSTRAÇÃO: só os detalhes │ - placa │ relevantes para ESTE contexto │ - modelo │ específico (aluguel) entram │ - disponivel: boolean │ no modelo. O motor, a suspensão, │ + alugar() │ a composição do pneu -- tudo │ + devolver() │ IRRELEVANTE aqui, e por isso └───────────────────────────┘ corretamente OMITIDO
Abstração é o processo de decidir o que ignorar. Um sistema de aluguel de carros não precisa modelar a pressão dos pneus; um sistema de manutenção automotiva provavelmente precisaria. A "abstração certa" não é universal — depende inteiramente do problema que o software está resolvendo. Uma classe abstract (capítulo 07) e uma interface (capítulo 08) são as ferramentas sintáticas que Java oferece para expressar essa decisão de design no código, mas a decisão em si — o que incluir, o que descartar — acontece na sua cabeça antes de escrever qualquer palavra-chave.
ABSTRAÇÃO
(decide O QUE modelar)
│
▼
┌───────────────┐
│ ENCAPSULAMENTO │ (protege o COMO foi modelado)
└───────┬───────┘
│
▼
┌───────────┐
│ HERANÇA │ (reaproveita e especializa o modelo)
└─────┬─────┘
│
▼
┌──────────────┐
│ POLIMORFISMO │ (permite tratar especializações
└──────────────┘ de forma uniforme)
salarioBase com private), usa herança para modelar especializações (Gerente, Estagiario) sem duplicar código, e o polimorfismo é o que permite que o resto do sistema trate qualquer Funcionario de forma uniforme, sem precisar saber qual especialização exata está lidando com aquele objeto em cada momento. Um projeto que usa só um ou dois desses pilares raramente aproveita todo o potencial do paradigma orientado a objetos.Para o sistema de biblioteca construído ao longo do curso (capítulos 17, 22, 66), identifique explicitamente uma manifestação de cada um dos quatro pilares, citando a classe/interface específica e explicando por que ela representa aquele pilar.
Abstração: a classe abstrata ItemAcervo (capítulo 17) modela só o que é relevante para o empréstimo de itens de uma biblioteca (título, código, disponibilidade), ignorando deliberadamente detalhes irrelevantes como o material físico da capa ou o fornecedor de impressão. Encapsulamento: os campos de ItemAcervo são private, e o estado de "emprestado" só muda através dos métodos emprestar()/devolver(), nunca por atribuição direta externa — garantindo que o invariante "um item emprestado não pode ser emprestado de novo" seja sempre respeitado. Herança: Livro e Revista estendem ItemAcervo, reaproveitando toda a estrutura comum (título, código, contador estático de itens) e especializando só descricaoCompleta(). Polimorfismo: o método Biblioteca.buscarPorTitulo(...) (capítulo 17) trabalha com List<ItemAcervo>, tratando Livros e Revistas de forma uniforme, sem nunca precisar saber qual subtipo exato está processando em cada iteração.
Os capítulos 22, 28 e 43 já construíram — na mão e depois com Spring real — o mecanismo de injeção de dependência. Este capítulo separa dois conceitos que costumam ser confundidos como sinônimos, e detalha exatamente o que o container Spring faz, em ordem, do início ao fim da vida de um bean.
Essa distinção é sutil, mas separa quem realmente entende o padrão de quem só decorou o termo.
SEM IoC (controle NORMAL do fluxo -- seu código decide tudo): ┌─────────────────────────────────────────┐ │ PedidoServico decide: │ │ 1. QUANDO criar um EmailServico │ │ 2. COMO criar (new EmailServico()) │ │ 3. QUAL implementação usar │ │ │ │ new EmailServico() ← seu código no │ │ controle total │ └─────────────────────────────────────────┘ COM IoC (o controle é INVERTIDO -- um container externo decide): ┌─────────────────────────────────────────┐ │ Spring Container decide: │ │ 1. QUANDO criar (na inicialização) │ │ 2. COMO criar (via reflection) │ │ 3. QUAL implementação usar (baseado │ │ em qual @Component foi registrado) │ │ │ │ PedidoServico só DECLARA "eu preciso │ │ de um Notificador" e RECEBE de fora -- │ │ perdeu o controle sobre a criação │ └─────────────────────────────────────────┘
Inversão de Controle é o princípio geral: em vez do seu código controlar o fluxo de criação e orquestração de objetos, essa responsabilidade é transferida ("invertida") para um framework/container externo. Injeção de Dependência é uma técnica específica de implementar IoC — a mais comum, mas não a única. Outras formas de IoC incluem o Service Locator pattern (onde você pede ativamente ao container por uma dependência, em vez de recebê-la automaticamente) e frameworks de eventos, onde o controle de "quando seu código roda" é invertido para o framework que dispara callbacks.
Quando a aplicação Spring Boot inicia, para cada bean gerenciado, este é o percurso completo:
┌──────────────────────────────────────────────────────────────┐
│ 1. INSTANCIAÇÃO │
│ O Spring encontra a classe anotada (@Component/@Service/etc, │
│ capítulo 43) via @ComponentScan (reflection, capítulo 20) e │
│ chama o construtor -- criando o objeto "cru", sem dependências│
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ 2. INJEÇÃO DE DEPENDÊNCIAS │
│ Se a injeção é via CONSTRUTOR (capítulo 22, a preferida), │
│ isso já aconteceu no passo 1 -- o Spring resolve o GRAFO │
│ de dependências primeiro (quem precisa de quem) para saber │
│ em que ORDEM instanciar. Se é via campo (@Autowired em field),│
│ a injeção acontece AQUI, depois da instanciação, via │
│ reflection (Field.set(), capítulo 20) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ 3. BeanNameAware / ApplicationContextAware (se implementados) │
│ Callbacks opcionais -- o bean "sabe" seu próprio nome no │
│ container, ou tem acesso ao container inteiro, se precisar │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ 4. @PostConstruct │
│ Método anotado, chamado DEPOIS que TODAS as dependências já │
│ foram injetadas -- o lugar certo para inicialização que │
│ DEPENDE de outro bean já estar pronto (algo que o construtor │
│ sozinho não garantiria com segurança) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ 5. BEAN PRONTO PARA USO │
│ Fica assim durante toda a vida da aplicação (se escopo │
│ singleton, capítulo 43 -- o padrão) ou até ser descartado, │
│ se escopo prototype │
└──────────────────────────────────────────────────────────────┘
│
(aplicação encerrando...)
▼
┌──────────────────────────────────────────────────────────────┐
│ 6. @PreDestroy │
│ Método anotado, chamado ANTES do container destruir o bean -- │
│ o lugar certo para liberar recursos (fechar conexões, │
│ parar threads próprias, capítulo 14) │
└──────────────────────────────────────────────────────────────┘
@Service public class ConexaoExternaServico { private Connection conexaoPersistente; @PostConstruct public void inicializar() { // roda DEPOIS de todas as dependências injetadas -- // seguro para usar qualquer @Autowired daqui conexaoPersistente = abrirConexaoComServicoExterno(); System.out.println("Conexão externa estabelecida"); } @PreDestroy public void encerrar() { conexaoPersistente.fechar(); // libera o recurso antes da JVM desligar System.out.println("Conexão externa encerrada"); } }
@PostConstruct existe, se você já tem o construtor? Porque no momento em que o construtor roda, para injeção via campo (@Autowired em atributo, capítulo 22), as dependências ainda não foram injetadas — elas só são preenchidas depois, no passo 2 do ciclo acima. Chamar um método que usa uma dependência ainda não injetada dentro do próprio construtor causaria NullPointerException. Com injeção via construtor (a forma preferida, exatamente porque evita essa armadilha), as dependências já chegam prontas no momento da instanciação — reduzindo bastante a necessidade prática de @PostConstruct, mas ele continua essencial quando a inicialização envolve algo além de simplesmente receber dependências (abrir uma conexão de rede, validar configuração externa).ApplicationContext estende capacidades de BeanFactory com eventos, mensagens, recursos e integração com post-processors. Por padrão, singletons não lazy são pré-instanciados durante o refresh, permitindo descobrir muitos erros cedo. Beans marcados como lazy, outros escopos e objetos criados sob demanda são exceções; “BeanFactory é sempre lazy e ApplicationContext sempre eager” é uma simplificação.
@Service public class ServicoA { public ServicoA(ServicoB b) { ... } // A precisa de B } @Service public class ServicoB { public ServicoB(ServicoA a) { ... } // B precisa de A -- CICLO! } // erro na inicialização: "The dependencies of some of the beans in the // application context form a cycle" -- o Spring NÃO consegue decidir // qual instanciar primeiro, porque cada um exige que o outro já exista
ServicoA e ServicoB deveriam ter uma responsabilidade em comum extraída para um terceiro serviço, do qual ambos dependeriam unidirecionalmente — a mesma lição de SRP do capítulo 16. Embora exista uma forma técnica de contornar isso (injeção via campo, que o Spring resolve de forma "preguiçosa" através de um proxy intermediário), a solução correta na maioria absoluta dos casos reais é refatorar o design, não burlar a checagem.@PostConstruct tentando usar algo que ainda não existe.Crie um @Service chamado PoolDeConexoesServico que, no @PostConstruct, imprime "Pool inicializado com N conexões" (simule N=10), e no @PreDestroy, imprime "Pool encerrado, todas as conexões fechadas". Injete esse serviço em outro bean via construtor e explique, em texto, em que exato momento do ciclo de vida do Spring cada uma dessas mensagens vai aparecer no log da aplicação.
@Service public class PoolDeConexoesServico { private int conexoesAtivas; @PostConstruct public void inicializar() { conexoesAtivas = 10; System.out.println("Pool inicializado com " + conexoesAtivas + " conexões"); } @PreDestroy public void encerrar() { System.out.println("Pool encerrado, todas as conexões fechadas"); } }
"Pool inicializado..." aparece no refresh para esse singleton não lazy, depois da construção e injeção. "Pool encerrado..." aparece no fechamento gracioso do contexto, como em SIGTERM tratado dentro do prazo. Crash abrupto, SIGKILL ou falha do host não garantem callbacks de destruição.
O capítulo 00 explicou JDK/JRE/JVM em nível de superfície, e o capítulo 01 introduziu stack e heap de passagem. Este capítulo volta a esses dois tópicos e vai fundo de verdade — porque toda decisão de performance séria que você vai tomar como desenvolvedor sênior depende de entender exatamente o que acontece entre o momento em que você escreve Livro l = new Livro(); e o momento em que esse objeto deixa de existir.
Nada disso é mágico — é uma sequência de transformações mecânicas e determinísticas. Vamos percorrer cada uma delas sem pular nenhuma etapa.
PASSO 1 — Código-fonte (Livro.java)
┌─────────────────────────────────────┐
│ public class Livro { │
│ private String titulo; │
│ public String getTitulo() { │
│ return titulo; │
│ } │
│ } │
└─────────────────────────────────────┘
│
│ javac Livro.java
▼
PASSO 2 — Bytecode (Livro.class) -- NÃO é código de máquina x86/ARM,
é um formato intermediário, independente de sistema operacional
┌─────────────────────────────────────┐
│ CAFEBABE 0000 0041 001D... │ ← cabeçalho mágico + versão
│ Constant Pool: #1 = Class #2 │ ← tabela de símbolos (nomes, tipos)
│ Methods: │
│ getTitulo: │
│ aload_0 // empilha "this" │
│ getfield #3 // busca "titulo"│
│ areturn // retorna │
└─────────────────────────────────────┘
│
│ java Livro (a JVM CARREGA o .class)
▼
PASSO 3 — Class Loading: a JVM lê o .class, verifica que o bytecode é
válido (Bytecode Verifier -- proteção de segurança), e registra
a classe Livro na memória da aplicação
│
▼
PASSO 4 — Execução: a JVM interpreta o bytecode instrução por instrução
(lento) OU, para métodos executados MUITAS vezes, o JIT
(Just-In-Time compiler) o compila para código de máquina NATIVO
real, otimizado para o processador específico da máquina
javac é o compositor traduzindo uma ideia musical (seu código Java) para essa notação universal; a JVM é o maestro que interpreta a partitura e faz a orquestra (o processador da sua máquina) realmente tocar o som.Quando sua aplicação Spring Boot inicia, os primeiros métodos chamados rodam interpretados — a JVM lê cada instrução de bytecode e a executa uma a uma, sem otimização. A JVM mantém contadores de quantas vezes cada método é chamado (hot methods); quando um método ultrapassa um limiar, o compilador JIT (C1 para compilação rápida inicial, C2 para otimização agressiva depois) o traduz para código de máquina nativo, e as chamadas seguintes usam essa versão compilada, muito mais rápida.
┌─────────────────────────────────────────────────────────────┐ │ MEMÓRIA DA JVM │ │ │ │ ┌───────────────┐ ┌───────────────┐ ┌────────────────────┐│ │ │ STACK │ │ STACK │ │ METASPACE ││ │ │ (Thread A) │ │ (Thread B) │ │ (fora do heap) ││ │ │ │ │ │ │ guarda METADADOS ││ │ │ frame: main() │ │ frame: run() │ │ das CLASSES: ││ │ │ int x = 5 │ │ Livro l │ │ - bytecode dos ││ │ │ frame: foo() │ │ │ │ métodos ││ │ │ String s │ │ │ │ - Constant Pool ││ │ └───────────────┘ └───────────────┘ └────────────────────┘│ │ │ referências apontam para │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ HEAP │ │ │ │ ┌──────────────────┐ ┌───────────────────────────────┐│ │ │ │ │ Young Generation │ │ Old Generation ││ │ │ │ │ ┌─────┐┌───┐┌───┐│ │ (objetos que sobreviveram ││ │ │ │ │ │Eden ││S0 ││S1 ││ │ várias coletas de lixo na ││ │ │ │ │ └─────┘└───┘└───┘│ │ Young Generation) ││ │ │ │ │ objetos NOVOS, │ │ ex: cache de longa duração, ││ │ │ │ │ nascem aqui │ │ singleton beans do Spring ││ │ │ │ └──────────────────┘ └───────────────────────────────┘│ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
Cada Thread tem sua própria Stack — é por isso que variáveis locais nunca precisam de sincronização (capítulo 14): cada thread tem uma cópia física separada de sua própria pilha de execução. O Heap é compartilhado por todas as threads da aplicação — é onde vive todo objeto criado com new, e é exatamente por isso que objetos compartilhados entre threads precisam de synchronized ou classes Atomic* (capítulo 14): múltiplas threads podem acessar a mesma região de memória simultaneamente.
A observação empírica que fundamenta esse design, confirmada repetidamente em décadas de análise de aplicações reais, é chamada de hipótese geracional: a maioria dos objetos morre jovem. Uma variável local de método, um DTO temporário criado para uma única requisição HTTP (capítulo 44) — a esmagadora maioria dos objetos vive apenas milissegundos e é descartada logo depois.
Diferente de C/C++, você nunca chama free() em Java. O GC identifica objetos inalcançáveis — nenhuma referência viva (a partir de nenhuma Stack, de nenhum campo static) aponta mais para eles — e libera essa memória automaticamente.
Livro l1 = new Livro(); // objeto A criado no heap, "l1" (na stack) aponta para ele l1 = new Livro(); // objeto B criado, "l1" agora aponta para B // objeto A ficou SEM NENHUMA referência -- // agora é candidato a coleta de lixo void metodo() { Livro local = new Livro(); // objeto C, referenciado só pela variável local } // método termina -- o frame da stack é destruído -- // "local" deixa de existir -- objeto C também vira lixo
static Map ou List que só cresce e nunca remove itens (um cache mal desenhado, por exemplo), esses objetos permanecem "alcançáveis" para sempre, mesmo que ninguém mais os use de verdade. Isso é a causa mais comum de OutOfMemoryError em aplicações Java de longa duração, como um Spring Boot rodando em produção por semanas.O algoritmo fundamental por trás da maioria dos coletores modernos tem duas fases:
FASE 1 — MARK (marcar)
A JVM parte das "raízes" (variáveis em cada Stack de cada Thread,
campos static) e percorre TODAS as referências alcançáveis a partir
delas, marcando cada objeto encontrado como "vivo".
[Stack Thread A] ──┐
├──► Objeto X (marcado: VIVO)
[campo static] ───┘ │
└──► Objeto Y (marcado: VIVO,
referenciado por X)
Objeto Z (não alcançado por ninguém) → permanece NÃO marcado
FASE 2 — SWEEP (varrer)
A JVM percorre o heap inteiro; todo objeto NÃO marcado é considerado
lixo e sua memória é liberada/reciclada.
| Coletor | Estratégia | Quando usar |
|---|---|---|
| Serial GC | Uma única thread faz toda a coleta, pausando a aplicação inteira | Aplicações pequenas, single-core |
| Parallel GC | Várias threads coletam em paralelo, ainda pausando a aplicação | Maximizar throughput, pausas aceitáveis |
| G1 (Garbage-First) | Divide o heap em regiões pequenas, coleta incrementalmente as regiões com mais lixo primeiro | Padrão desde Java 9 — bom equilíbrio entre pausas curtas e throughput, a escolha certa para a maioria das aplicações Spring Boot |
| ZGC / Shenandoah | Coleta quase inteiramente concorrente, pausas sub-milissegundo mesmo em heaps enormes (terabytes) | Aplicações que não toleram NENHUMA pausa perceptível — sistemas de latência ultra-baixa |
-Xmx, -Xms na JVM) é uma decisão de produção real: um heap grande demais para o volume de tráfego pode causar pausas de GC mais longas e perceptíveis; um heap pequeno demais causa coletas frequentes demais, também prejudicando throughput. Ferramentas de observabilidade (o Actuator do capítulo 56, ou ferramentas dedicadas como o VisualVM) expõem métricas de GC exatamente para você acompanhar isso em produção.Uma otimização avançada do JIT: se o compilador consegue provar que um objeto nunca "escapa" do método (nenhuma referência a ele sai do escopo local), ele pode alocar esse objeto diretamente na Stack em vez do Heap — ou até eliminar a alocação completamente, mantendo os campos como variáveis locais soltas. Isso evita completamente o custo de GC para esses objetos.
Para o código abaixo, escreva um passo a passo detalhado de: (1) onde cada objeto é alocado (Stack ou Heap), (2) em que momento cada objeto se torna elegível para coleta de lixo, explicando o motivo exato em cada caso.
public class Main { static Livro livroFavorito; public static void main(String[] args) { Livro l1 = new Livro("1984"); livroFavorito = l1; Livro l2 = new Livro("Duna"); l1 = null; processarLivro(); } static void processarLivro() { Livro temp = new Livro("Temporário"); } }
Modelo conceitual: as variáveis locais l1, l2 e temp pertencem aos frames de seus métodos; os objetos Livro criados com new são normalmente alocados no heap, embora otimizações possam eliminar alocações. livroFavorito é um campo estático associado à classe e funciona como raiz de alcançabilidade enquanto a classe estiver carregada. Não afirme que o valor do campo fica no Metaspace: detalhes físicos dependem da implementação da JVM.
Passo a passo: (1) l1 aponta para o objeto "1984" no Heap. (2) livroFavorito = l1 — agora duas referências apontam para o mesmo objeto "1984": a variável local l1 e o campo estático livroFavorito. (3) l2 aponta para "Duna" no Heap. (4) l1 = null — a referência local deixa de apontar para "1984", mas o objeto ainda é alcançável através de livroFavorito (que é static, então "raiz" viva para sempre enquanto a classe existir) — não é coletado. (5) Dentro de processarLivro(), temp aponta para "Temporário". (6) Quando processarLivro() termina, o frame daquele método é destruído da Stack, temp deixa de existir, e o objeto "Temporário" fica sem nenhuma referência — torna-se elegível para coleta de lixo. (7) Quando main() termina, l2 também deixa de existir e "Duna" se torna elegível — mas "1984" continua vivo, porque livroFavorito (static) continua referenciando-o mesmo depois que main() encerra.
Analise a classe abaixo e explique por que ela causa um vazamento de memória em uma aplicação de longa duração, mesmo em uma linguagem com Garbage Collector automático. Proponha uma correção.
public class CacheDeUsuarios { private static final Map<Long, Usuario> cache = new HashMap<>(); public static void adicionar(Long id, Usuario usuario) { cache.put(id, usuario); // nunca remove nada! } }
Como cache é static, ele é uma "raiz" que vive durante toda a execução da aplicação — todo Usuario adicionado permanece alcançável para sempre através dele, mesmo que nenhuma outra parte do sistema jamais volte a usar aquele usuário específico. O GC nunca coleta esses objetos, porque tecnicamente eles nunca ficam inalcançáveis — o programa simplesmente acumula memória indefinidamente até esgotar o heap e lançar OutOfMemoryError. A correção clássica é usar uma estrutura com política de expiração ou tamanho máximo: um LinkedHashMap configurado como LRU (remove o item menos recentemente usado ao ultrapassar um limite), ou — a solução real de produção — delegar esse cache para o Redis (capítulo 38) com TTL configurado, tirando essa responsabilidade da memória da própria aplicação Java.
O capítulo 79 ensinou "dry run" — rastrear código manualmente no papel. Um debugger é a versão automatizada e interativa dessa mesma habilidade, integrada à sua IDE, permitindo pausar a execução e inspecionar o estado exato do programa em qualquer ponto.
Um breakpoint de linha é o mais básico: clique na margem esquerda do editor (IntelliJ IDEA ou VS Code) ao lado do número da linha, e a execução pausa antes daquela linha rodar, toda vez que o fluxo do programa chegar ali.
public LivroDTO buscarPorId(Long id) { Optional<Livro> livro = repository.findById(id); ← clique aqui na margem para breakpoint de linha return livro.map(LivroDTO::from) .orElseThrow(() -> new LivroNaoEncontradoException(id)); }
Ao rodar em modo Debug (não "Run" normal — na IntelliJ, o ícone de inseto verde; no VS Code, F5 com a configuração de debug ativa) e a execução chegar naquela linha, tudo congela: você pode inspecionar o valor de id, de qualquer variável em escopo, o estado exato da Stack (capítulo 85) naquele momento.
// clique com o botão direito no breakpoint já criado, e defina uma condição: livro.getId() == 42L // ou, para um loop: for (Livro livro : todosOsLivros) { processar(livro); ← breakpoint condicional: "livro.getPaginas() > 900" -- só pausa quando encontrar um livro específico com mais de 900 páginas, ignorando todos os outros }
Às vezes você não sabe onde um campo está sendo alterado incorretamente — só sabe que, em algum ponto do fluxo, ele muda para um valor errado. Um watchpoint de campo (na IntelliJ: clique com botão direito no nome do campo na declaração, "Add Field Watchpoint") pausa a execução toda vez que aquele campo específico é lido ou escrito, em qualquer lugar do código, sem você precisar adivinhar onde colocar um breakpoint de linha tradicional.
┌─────────────────────────────────────────────────────────────┐ │ Você está PARADO nesta linha, prestes a executá-la: │ │ │ │ LivroDTO dto = converterParaDTO(livro); │ │ │ │ STEP OVER (F8 na IntelliJ) │ │ Executa a linha INTEIRA de uma vez, incluindo qualquer │ │ chamada de método dentro dela, sem "entrar" no método -- │ │ você para na PRÓXIMA linha do método atual │ │ │ │ STEP INTO (F7 na IntelliJ) │ │ "Entra" dentro do método converterParaDTO(), pausando na │ │ PRIMEIRA linha DENTRO dele -- útil quando o bug pode estar │ │ dentro dessa chamada específica │ │ │ │ STEP OUT (Shift+F8 na IntelliJ) │ │ Se você já entrou em um método com Step Into e quer voltar │ │ para quem o chamou, Step Out executa o RESTO do método │ │ atual de uma vez e pausa de volta no método CHAMADOR │ └─────────────────────────────────────────────────────────────┘
Com a execução pausada, a maioria das IDEs mostra um painel Variables, listando todas as variáveis em escopo naquele ponto exato — incluindo os campos de this, permitindo expandir objetos aninhados e navegar sua estrutura completa em memória, exatamente como o dry run manual do capítulo 79, mas sem erro humano de cálculo.
Painel "Variables" durante a pausa: ┌─────────────────────────────────┐ │ this = Biblioteca@7a3f2c1 │ │ ├─ acervo = ArrayList size=3 │ │ │ ├─ [0] = Livro@1b2c3d │ │ │ │ ├─ titulo = "1984" │ │ │ │ └─ codigo = "L001" │ │ │ ├─ [1] = Livro@4e5f6g │ │ │ └─ [2] = Livro@7h8i9j │ │ └─ notificador = EmailServico │ │ │ │ livro = Livro@1b2c3d (parâmetro │ │ local do método atual) │
Um recurso ainda mais poderoso: Evaluate Expression (Alt+F8 na IntelliJ) permite executar código Java arbitrário no contexto exato da execução pausada — testar uma expressão, chamar um método, verificar uma condição — sem precisar modificar o código-fonte e reiniciar tudo.
Usando o método segundoMaior do exercício 79.1, insira um bug deliberado (troque n > maior por n >= maior) e escreva um teste com um array de 20 números aleatórios que expõe o bug apenas em alguns casos específicos. Configure um breakpoint condicional na linha do bug, ativo apenas quando n == maior (o caso-limite exato que expõe o defeito), e descreva o que você observaria no painel de variáveis nesse ponto exato de pausa.
Com o breakpoint condicional n == maior ativo, a execução só pausaria no exato momento em que o array contivesse um valor duplicado igual ao maior valor já encontrado até aquele ponto. No painel de variáveis, seria possível observar maior e segundoMaior com o mesmo valor imediatamente após essa iteração — evidenciando o bug: com n >= maior em vez de n > maior, um valor igual ao maior também dispara a atualização de segundoMaior para o mesmo valor de maior, quebrando a lógica de "segundo maior distinto" pretendida pelo exercício original. Esse é exatamente o tipo de bug que um breakpoint condicional revela rapidamente, mas que seria tedioso encontrar pausando em toda iteração de um array de 20 elementos.
O capítulo 14 mostrou a race condition de "valor++" e a solução com synchronized. Este capítulo aprofunda por que isso acontece no nível de hardware/memória, formaliza os problemas clássicos de concorrência além de race conditions, e introduz programação assíncrona moderna.
O código Java "valor++" na verdade vira TRÊS instruções de bytecode:
GETFIELD valor // 1. LER o valor atual da memória principal
ICONST_1
IADD // 2. SOMAR 1 (em um registrador da CPU)
PUTFIELD valor // 3. GRAVAR o resultado de volta na memória
Duas threads executando "valor++" SIMULTANEAMENTE, quando valor=5:
TEMPO →
Thread A: LER(5) ────────────── SOMAR(6) ── GRAVAR(6)
Thread B: LER(5) ── SOMAR(6) ── GRAVAR(6)
↑
Thread B leu o valor ANTES de A gravar --
as duas leram "5", as duas calcularam "6",
as duas gravaram "6" -- o incremento de uma
das duas threads foi COMPLETAMENTE PERDIDO.
Resultado esperado: valor = 7 (dois incrementos)
Resultado real: valor = 6 (só um incremento "sobreviveu")
Existe um segundo problema, frequentemente confundido com race condition, mas conceitualmente distinto: visibilidade. Mesmo sem duas threads escrevendo ao mesmo tempo, uma thread pode nunca "enxergar" a escrita feita por outra, por causa de otimizações de cache de CPU e reordenação de instruções pelo compilador/processador.
public class Flag { private boolean pronto = false; // SEM volatile void marcarPronto() { pronto = true; } // Thread A chama isso void esperar() { while (!pronto) { } // Thread B fica presa AQUI PARA SEMPRE, // mesmo depois de A marcar pronto=true -- // porque B pode estar lendo um valor // "cacheado" antigo, nunca atualizado // da memória principal } }
A palavra-chave volatile resolve exatamente este problema — não o de atomicidade (para isso, ainda é preciso synchronized ou Atomic*):
private volatile boolean pronto = false; // uma escrita volatile acontece-before de uma leitura posterior // da mesma variável: isso fornece visibilidade e restrições de ordenação. // Não significa "ignorar cache" literalmente e não torna valor++ atômico.
volatile substitui synchronized. volatile garante que a leitura mais recente sempre será vista — não garante que uma operação composta (ler + calcular + gravar, como valor++) seja atômica. Para uma flag booleana simples (como no exemplo acima), volatile sozinho é suficiente. Para um contador que precisa incrementar corretamente sob concorrência, você ainda precisa de synchronized ou AtomicInteger (capítulo 14).Object recursoA = new Object(); Object recursoB = new Object(); // Thread 1: synchronized (recursoA) { // ... faz algo ... synchronized (recursoB) { // espera conseguir travar B // ... } } // Thread 2 (rodando AO MESMO TEMPO): synchronized (recursoB) { // ... faz algo ... synchronized (recursoA) { // espera conseguir travar A // ... } } // se Thread 1 pegar recursoA e Thread 2 pegar recursoB QUASE ao mesmo // tempo, Thread 1 fica esperando recursoB (que Thread 2 tem), e // Thread 2 fica esperando recursoA (que Thread 1 tem) -- DEADLOCK, // as duas ficam bloqueadas PARA SEMPRE
recursoA antes de recursoB, o cenário acima é estruturalmente impossível.tryLock(tempo, unidade) de java.util.concurrent.locks.Lock) em vez de esperar indefinidamente.| Problema | O que acontece |
|---|---|
| Deadlock | Threads travadas, nenhuma progride, para sempre |
| Livelock | Threads continuam "trabalhando" (mudando de estado), mas nunca progridem de verdade — como duas pessoas se desviando repetidamente para o mesmo lado tentando passar uma pela outra em um corredor |
| Starvation | Uma thread nunca consegue acesso a um recurso porque outras threads (com prioridade mais alta, ou mais "espertas" na disputa) sempre chegam primeiro |
Threads (capítulo 14) e ExecutorService resolvem "executar código em paralelo". CompletableFuture resolve um problema diferente e complementar: compor operações assíncronas — encadear "quando isso terminar, faça aquilo", sem bloquear a thread principal esperando.
CompletableFuture<Livro> futuro = CompletableFuture .supplyAsync(() -> buscarLivroNoBanco(id)) // roda em outra thread, não bloqueia aqui .thenApply(livro -> enriquecerComDadosExternos(livro)) // encadeia -- roda QUANDO o anterior terminar .thenApply(LivroDTO::from); // mais um passo encadeado futuro.thenAccept(dto -> System.out.println("Pronto: " + dto)); // o código AQUI continua rodando IMEDIATAMENTE, sem esperar // o "futuro" terminar -- typical de programação assíncrona/reativa // combinando DUAS operações assíncronas independentes: CompletableFuture<Livro> buscaLivro = CompletableFuture.supplyAsync(() -> buscarLivro(id)); CompletableFuture<Autor> buscaAutor = CompletableFuture.supplyAsync(() -> buscarAutor(autorId)); CompletableFuture<String> combinado = buscaLivro.thenCombine(buscaAutor, (livro, autor) -> livro.getTitulo() + " por " + autor.getNome()); // as duas buscas rodam EM PARALELO, e "combinado" só resolve quando // AMBAS terminarem -- muito mais rápido que buscar uma, esperar, depois // buscar a outra sequencialmente
WebClient reativo do capítulo 72 — Mono<T> do Project Reactor é, conceitualmente, muito próximo de CompletableFuture<T>: ambos representam "um valor que ainda não existe, mas vai existir". A diferença prática é que o ecossistema reativo completo (Reactor, RxJava) oferece muito mais operadores de composição, backpressure (controle de quando o produtor está gerando dados mais rápido do que o consumidor processa) e integração nativa com I/O não-bloqueante — CompletableFuture é a porta de entrada da própria linguagem Java para esse mesmo paradigma de pensamento.Implemente o cenário de deadlock mostrado no exemplo acima com duas threads e dois objetos de lock. Rode e confirme que a aplicação trava (pode ser necessário usar um Thread.sleep() pequeno entre pegar o primeiro lock e tentar o segundo, para aumentar a chance da condição de corrida acontecer). Depois, corrija reordenando a aquisição de locks para que ambas as threads sempre travem na mesma ordem, e confirme que o deadlock não ocorre mais.
// versão CORRIGIDA -- ambas as threads travam recursoA ANTES de recursoB, sempre: Object recursoA = new Object(); Object recursoB = new Object(); Runnable tarefa1 = () -> { synchronized (recursoA) { synchronized (recursoB) { System.out.println("Tarefa 1 concluída"); } } }; Runnable tarefa2 = () -> { synchronized (recursoA) { // mesma ORDEM que tarefa1 -- nunca inverte! synchronized (recursoB) { System.out.println("Tarefa 2 concluída"); } } }; new Thread(tarefa1).start(); new Thread(tarefa2).start(); // como as DUAS threads sempre pedem recursoA primeiro, uma delas // sempre consegue pegar os dois locks em sequência antes da outra // sequer começar -- o cenário circular de espera se torna impossível
Enquanto Clean Code organiza a árvore, arquitetura decide o desenho da floresta inteira — como os grandes blocos do sistema se relacionam, comunicam e evoluem independentemente.
| Monolito | Microsserviços | |
|---|---|---|
| Deploy | Uma unidade, tudo junto | Cada serviço deployado independentemente |
| Comunicação interna | Chamadas de método diretas (rápido, simples) | Rede — HTTP/mensageria (capítulos 26, 40) — mais lento, mais falhas possíveis |
| Escalar | A aplicação inteira escala junto | Só o serviço sob carga escala, independente dos outros |
| Complexidade operacional | Baixa — um único artefato para monitorar | Alta — muitos serviços, cada um com seus próprios logs, deploy, falhas |
| Bom para | Times pequenos, produto em validação, a maioria dos projetos reais | Times grandes com fronteiras de domínio claras, escala genuína de produto maduro |
Controller (capítulo 44) → recebe requisição HTTP
↓
Service (capítulo 43) → regra de negócio
↓
Repository (capítulo 45) → acesso a dados
↓
Banco de dados (capítulo 34)
Essa é a arquitetura que você já pratica desde o capítulo 43 — cada camada só conhece a camada logo abaixo, nunca pula etapas (um controller nunca deveria chamar o repository diretamente, por exemplo).
// núcleo de domínio -- NÃO sabe que existe JPA, HTTP, ou Kafka: public interface LivroRepositorioPort { // a "porta" void salvar(Livro livro); } // adaptador concreto -- É aqui que JPA aparece, isolado do núcleo: @Repository public class LivroRepositorioJpaAdapter implements LivroRepositorioPort { private final LivroJpaRepository jpaRepository; // Spring Data, capítulo 45 @Override public void salvar(Livro livro) { jpaRepository.save(toEntity(livro)); } }
Para cada cenário, decida se monolito ou microsserviços encaixa melhor, justificando: (a) uma startup de 3 desenvolvedores validando um produto novo; (b) uma empresa com 200 desenvolvedores organizados em 15 times de produto diferentes; (c) um sistema de biblioteca de uma faculdade, uso interno, baixo tráfego.
(a) Monolito — 3 pessoas não têm o problema de coordenação entre times que microsserviços resolvem; velocidade de iteração importa mais que escala nesse estágio. (b) Microsserviços (ou pelo menos um monolito modular bem preparado para split) — 15 times fazendo deploy do mesmo artefato monolítico geraria gargalos de coordenação constantes. (c) Monolito, sem dúvida — baixo tráfego e escopo bem definido não justificam a complexidade operacional extra de serviços distribuídos.
DDD é uma abordagem para modelar sistemas complexos a partir da linguagem do próprio negócio, não a partir da estrutura conveniente de tabelas de banco de dados. É a resposta para quando "CRUD simples" já não descreve mais a complexidade real do domínio.
Usuario com um campo tipo="premium", toda conversa entre eles exige tradução mental constante — e traduções acumulam mal-entendidos. DDD prega que o código deveria usar exatamente os mesmos termos que as pessoas de negócio usam em conversa: se o negócio fala em "Sócio", o código deveria ter uma classe Socio, não um Usuario genérico disfarçado.| Entity (no sentido DDD) | Value Object | |
|---|---|---|
| Identidade | Tem um ID único que persiste ao longo do tempo | Não tem identidade — dois com os mesmos valores são iguais |
| Mutabilidade | Pode mudar de estado ao longo da vida | Idealmente imutável (capítulo 04!) |
| Exemplo | Pedido (mesmo pedido, mesmo se todos os itens mudarem) | Dinheiro, Endereco, Cpf (exercício 5.2!) — comparados por valor |
Dinheiro, imutável) e no exercício 5.2 (Cpf, com equals/hashCode baseados em conteúdo) — sem saber que estava praticando DDD. A diferença entre "aprender DDD" e "já ter praticado os padrões que DDD formaliza" é justamente ter um nome e uma justificativa teórica para uma prática que já fazia sentido intuitivamente.Um Aggregate é um grupo de objetos relacionados tratados como uma única unidade para fins de consistência — só o "objeto raiz" (Aggregate Root) pode ser referenciado de fora, e toda mudança passa por ele.
public class Pedido { // Aggregate Root private Long id; private List<ItemPedido> itens; // parte do agregado, NUNCA acessado diretamente de fora private StatusPedido status; // toda mudança passa por um método do Pedido -- nunca "pedido.getItens().add(...)" direto de fora public void adicionarItem(Produto produto, int qtd) { if (status != StatusPedido.ABERTO) { throw new PedidoFechadoException(); // a regra de negócio VIVE aqui, não espalhada } itens.add(new ItemPedido(produto, qtd)); } }
Em domínios grandes, a mesma palavra pode significar coisas diferentes em partes diferentes do sistema — um "Cliente" no contexto de Vendas tem atributos diferentes de um "Cliente" no contexto de Suporte. Um Bounded Context é a fronteira explícita onde um modelo e sua linguagem fazem sentido — frequentemente, cada Bounded Context vira candidato natural a um microsserviço separado (capítulo 75).
Para o sistema de biblioteca, classifique cada conceito como Entity ou Value Object, justificando: Livro, Isbn, Emprestimo, PeriodoEmprestimo (data início + data fim).
Livro: Entity — tem identidade própria que persiste (o mesmo exemplar físico, mesmo que o título mude numa correção de cadastro). Isbn: Value Object — dois ISBNs com o mesmo número são o mesmo ISBN, sem identidade própria além do valor. Emprestimo: Entity — cada empréstimo é um evento distinto, com identidade própria ao longo do tempo (mesmo que o livro seja devolvido e emprestado de novo, é um novo empréstimo). PeriodoEmprestimo: Value Object — dois períodos com as mesmas datas são iguais, sem identidade própria, e deveria ser imutável (capítulo 04).
Este é o capítulo que justifica a existência do nível "Além do Impossível". Não existe resposta certa aqui — só trade-offs, e reconhecer quais trade-offs um sistema real está fazendo é o tipo de julgamento que distingue um arquiteto sênior experiente.
| Letra | Significa |
|---|---|
| C — Consistency | Toda leitura recebe a escrita mais recente (ou um erro), nunca dado desatualizado |
| A — Availability | Todo pedido recebe uma resposta (não uma garantia de que seja o dado mais recente) |
| P — Partition Tolerance | O sistema continua funcionando mesmo que a rede entre nós falhe parcialmente |
Como falhas de rede (P) são uma realidade inevitável em qualquer sistema distribuído de verdade, a escolha prática do dia a dia é entre CP (prefere recusar responder a responder com dado desatualizado) e AP (prefere responder sempre, mesmo que com dado potencialmente desatualizado).
Na prática, a maioria dos sistemas reais não escolhe CP ou AP de forma absoluta e única — escolhe por parte do sistema. Pagamento e estoque (capítulo 33, transações) tendem a exigir consistência forte. Contadores de curtidas, notificações, feeds de atividade toleram consistência eventual — o sistema garante que, dado tempo suficiente sem novas escritas, todas as réplicas convergem para o mesmo estado, mas não instantaneamente.
O capítulo 33 mostrou uma transação SQL garantindo atomicidade dentro de um único banco. Mas e quando uma operação de negócio precisa coordenar múltiplos microsserviços (capítulo 75), cada um com seu próprio banco? Não existe BEGIN`/`COMMIT distribuído simples e eficiente em escala — o padrão Saga resolve isso com uma sequência de transações locais, cada uma publicando um evento (capítulo 40) que dispara a próxima, com compensações explícitas para desfazer passos já feitos se algo falhar no meio do caminho.
1. Serviço de Pedido: cria pedido (status PENDENTE) -- publica "PedidoCriado"
2. Serviço de Estoque: reserva o item -- publica "EstoqueReservado"
(se falhar: publica "EstoqueIndisponivel" -> Pedido é CANCELADO, sem próximo passo)
3. Serviço de Pagamento: cobra o cliente -- publica "PagamentoAprovado"
(se falhar: publica "PagamentoRecusado" -> COMPENSAÇÃO: Estoque é liberado de volta)
4. Serviço de Pedido: marca pedido como CONFIRMADO
ROLLBACK.Para cada cenário, primeiro proponha uma arquitetura replicada e uma partição de rede concreta. Então discuta o comportamento desejado: (a) confirmar a disponibilidade antes do empréstimo; (b) exibir contador histórico; (c) entregar notificação de reserva. Separe CAP de frescor do cache e semântica de entrega.
(a) em um registro de empréstimos com líderes separados por partição, prefira impedir confirmações conflitantes no lado sem autoridade; isso favorece consistência durante a partição. (b) uma réplica pode continuar respondendo com um contador antigo se o contrato aceitar eventual consistency; isso favorece disponibilidade. (c) a fila pode persistir localmente e reconciliar depois, mas at-least-once e atraso devem ser analisados separadamente de CAP. Em todos os casos, declare o que é resposta válida e onde estão as réplicas.
Os capítulos 40-42 ensinaram a usar Kafka. Este capítulo abre a "caixa preta" — como tópicos e partições realmente funcionam por dentro, e o que significa desenhar um sistema inteiro em torno de eventos, não só usar uma fila pontualmente.
Em uma arquitetura tradicional request-response (capítulo 44), um serviço pergunta ativamente a outro por informação, e espera a resposta. Em uma arquitetura orientada a eventos, um serviço anuncia que algo aconteceu ("PedidoCriado", "PagamentoAprovado"), sem saber (nem se importar) quem está ouvindo — quem precisar reagir, reage, de forma completamente desacoplada.
MODELO REQUEST-RESPONSE (acoplamento direto):
┌───────────┐ "processe este pagamento" ┌───────────────┐
│ Serviço │ ──────────────────────────► │ Serviço de │
│ Pedido │ ◄────────────────────────── │ Pagamento │
└───────────┘ resposta direta └───────────────┘
Serviço Pedido PRECISA SABER que o Serviço de Pagamento existe,
seu endereço, e espera SÍNCRONA e ATIVAMENTE pela resposta
MODELO EVENT-DRIVEN (desacoplamento via eventos):
┌───────────┐ publica "PedidoCriado" ┌───────────────┐
│ Serviço │ ──────────────────────────► │ TÓPICO │
│ Pedido │ (não sabe quem ouve) │ "pedidos" │
└───────────┘ └───────┬───────┘
│
┌──────────────────────────┼──────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Serviço de │ │ Serviço de │ │ Serviço de │
│ Pagamento │ │ Estoque │ │ Notificação │
└───────────────┘ └───────────────┘ └───────────────┘
Serviço Pedido NÃO SABE que esses três serviços existem --
cada um decide, de forma independente, se e como reagir ao evento
O capítulo 41 já introduziu tópicos e partições superficialmente. Vamos abrir isso completamente.
TÓPICO: "pedidos-finalizados" (3 partições) ┌─────────────────────────────────────────────────────────────┐ │ PARTIÇÃO 0 │ │ offset: 0 1 2 3 4 5 │ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ │msg│ │msg│ │msg│ │msg│ │msg│ │msg│ ← APPEND-ONLY -- │ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ NUNCA editada, │ │ ▲ só cresce │ │ próximo offset a escrever │ ├─────────────────────────────────────────────────────────────┤ │ PARTIÇÃO 1 │ │ offset: 0 1 2 3 │ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ │msg│ │msg│ │msg│ │msg│ │ │ └───┘ └───┘ └───┘ └───┘ │ ├─────────────────────────────────────────────────────────────┤ │ PARTIÇÃO 2 │ │ offset: 0 1 2 3 4 │ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ │msg│ │msg│ │msg│ │msg│ │msg│ │ │ └───┘ └───┘ └───┘ └───┘ └───┘ │ └─────────────────────────────────────────────────────────────┘
Cada partição é, estruturalmente, um log append-only — uma estrutura de dados extremamente simples (e por isso extremamente rápida): novas mensagens só são adicionadas ao final, nunca inseridas no meio, nunca editadas. Cada mensagem recebe um offset sequencial, único dentro daquela partição — como o número de uma página em um livro que só cresce, nunca é reordenado.
producer.send(new ProducerRecord<>("pedidos-finalizados", "cliente-42", dadosPedido)); ↑ chave Por baixo dos panos: partição = hash(chave) % numeroDePartições "cliente-42" sempre produz o MESMO hash, então TODAS as mensagens com essa chave SEMPRE vão para a MESMA partição -- garantindo que, para aquele cliente específico, a ORDEM de processamento é preservada (mensagens dentro de uma mesma partição são sempre processadas em ordem de offset). Mensagens de clientes DIFERENTES podem ir para partições diferentes, processadas EM PARALELO por consumidores diferentes -- é assim que Kafka escala: paralelismo entre partições, ordem garantida dentro de cada uma.
PARTIÇÃO 0, com replication-factor=3 (replicada em 3 brokers): Broker 1 (LÍDER) Broker 2 (réplica) Broker 3 (réplica) ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ [0][1][2][3] │ ──copia──► [0][1][2][3] │ ──copia─► [0][1][2][3] │ └─────────────┘ └─────────────┘ └─────────────┘ TODA escrita e réplica PASSIVA réplica PASSIVA leitura passa por (só copia do líder) (só copia do líder) este broker Se o Broker 1 (líder) CAIR: Kafka elege AUTOMATICAMENTE um novo líder entre as réplicas restantes (ex: Broker 2 vira o novo líder) -- a aplicação cliente nem percebe, além de uma pequena pausa durante a reeleição
Consumer Group "servico-notificacao" lendo a PARTIÇÃO 0:
offset: 0 1 2 3 4 5
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ ✓ │ │ ✓ │ │ ✓ │ │ ✓ │ │ → │ │ │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
▲
"committed offset" = 3
(o consumer já confirmou ter processado
até a mensagem de offset 3 -- se ele cair
e voltar, retoma exatamente do offset 4,
NUNCA reprocessa o que já foi confirmado
... exceto se o commit em si falhar, daí
entramos de novo em "at-least-once",
capítulo 40!)
O offset commitado é armazenado pelo próprio Kafka (em um tópico interno especial). Isso é o que permite a um consumer group retomar exatamente de onde parou depois de uma queda — e é também a raiz técnica exata da garantia "at-least-once" discutida no capítulo 40: se o consumer processar a mensagem mas cair antes de confirmar o commit do offset, ao voltar ele vai reprocessar aquela mesma mensagem — daí a necessidade de idempotência no lado do consumidor.
Para um tópico Kafka "eventos-conta-bancaria", publicando eventos de depósito e saque, explique qual deveria ser a chave de particionamento correta e por quê, considerando que a ordem de processamento entre depósitos e saques de uma mesma conta é crítica (processar um saque antes de um depósito anterior daria saldo incorreto), mas a ordem entre contas diferentes é irrelevante.
A chave deveria ser o ID da conta bancária (ex: numeroConta), nunca um ID de transação individual ou uma chave nula/aleatória. Como Kafka garante ordem dentro de uma partição, usar o ID da conta como chave garante que todos os eventos daquela conta específica sempre caiam na mesma partição e sejam processados estritamente na ordem em que foram publicados — resolvendo exatamente o requisito de negócio. Ao mesmo tempo, contas diferentes (chaves diferentes) podem ser distribuídas entre partições diferentes e processadas em paralelo por múltiplos consumidores do mesmo consumer group, aproveitando o paralelismo sem sacrificar a correção onde ela realmente importa.
O capítulo 54 já mostrou Testcontainers com Postgres. Este capítulo completa o quadro: as diferentes "fatias" de teste que o Spring Boot oferece, cada uma carregando só a parte do contexto necessária — e testes de integração reais com Kafka rodando em container, não simulado.
┌─────────────────────────────────────────────────────────┐
│ @SpringBootTest │
│ Carrega o CONTEXTO INTEIRO da aplicação (todos os beans) │
│ -- mais lento, mas testa a integração completa │
├─────────────────────────────────────────────────────────┤
│ @WebMvcTest(LivroController.class) │
│ Carrega SÓ a camada web -- controllers, sem banco real │
│ (services/repositories geralmente mockados com @MockBean) │
├─────────────────────────────────────────────────────────┤
│ @DataJpaTest │
│ Carrega SÓ a camada de persistência -- repositories, │
│ configura um banco em memória (H2) por padrão, OU pode ser │
│ combinado com Testcontainers para um banco REAL │
├─────────────────────────────────────────────────────────┤
│ Teste unitário puro (@ExtendWith(MockitoExtension.class)) │
│ NENHUM contexto Spring carregado -- o MAIS rápido de todos │
│ (capítulo 90) -- ideal para testar lógica de negócio isolada │
└─────────────────────────────────────────────────────────┘
↑ mais lento, mais completo mais rápido, mais isolado ↓
@DataJpaTest @Testcontainers @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // NÃO usa H2 -- usa o Testcontainers real class LivroRepositoryDataJpaTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16"); @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired private LivroRepository livroRepository; @Autowired private TestEntityManager entityManager; // ferramenta específica de @DataJpaTest, // para preparar dados de teste diretamente @Test void deveEncontrarLivrosPorAutor() { Autor autor = entityManager.persist(new Autor("Orwell")); entityManager.persist(new Livro("1984", autor)); entityManager.flush(); // força a escrita real no banco ANTES do teste continuar List<Livro> resultado = livroRepository.findByAutorNome("Orwell"); assertEquals(1, resultado.size()); } }
@DataJpaTest em relação a @SpringBootTest completo do capítulo 54: aqui, só os beans relacionados à camada JPA são carregados (repositories, EntityManager) — nenhum @Controller, nenhum @Service. Isso torna a suíte de testes significativamente mais rápida quando você só precisa validar comportamento de persistência, sem pagar o custo de subir a aplicação inteira para cada teste.@SpringBootTest @Testcontainers class PedidoEventPublisherTest { @Container static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0")); @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); } @Autowired private PedidoEventPublisher publisher; // capítulo 42 @Test void devePublicarEventoQueUmConsumerConsegueLer() throws Exception { CountDownLatch latch = new CountDownLatch(1); // sincroniza a espera pela mensagem assíncrona List<String> mensagensRecebidas = new ArrayList<>(); // cria um CONSUMER REAL, conectado ao mesmo Kafka em container: Properties consumerProps = new Properties(); consumerProps.put("bootstrap.servers", kafka.getBootstrapServers()); consumerProps.put("group.id", "teste"); consumerProps.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); consumerProps.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); consumerProps.put("auto.offset.reset", "earliest"); try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) { consumer.subscribe(List.of("pedidos-finalizados")); publisher.publicar("pedido-99"); // AÇÃO: publica de verdade var registros = consumer.poll(Duration.ofSeconds(5)); // espera de verdade pela mensagem assertEquals(1, registros.count()); assertTrue(registros.iterator().next().value().contains("pedido-99")); } } }
@DataJpaTest. Se é o comportamento HTTP de um controller (status codes, serialização), @WebMvcTest. Só recorra a @SpringBootTest completo (o mais lento) quando genuinamente precisar validar a integração de múltiplas camadas juntas.Escreva um teste @DataJpaTest com Testcontainers Postgres para o LivroRepository, usando TestEntityManager para persistir um autor e dois livros, e confirmando que findByAutorNome retorna exatamente os dois livros esperados, não mais nem menos.
@Test void deveEncontrarTodosOsLivrosDoAutor() { Autor orwell = entityManager.persist(new Autor("Orwell")); entityManager.persist(new Livro("1984", orwell)); entityManager.persist(new Livro("A Revolução dos Bichos", orwell)); Autor outroAutor = entityManager.persist(new Autor("Machado de Assis")); entityManager.persist(new Livro("Dom Casmurro", outroAutor)); entityManager.flush(); List<Livro> resultado = livroRepository.findByAutorNome("Orwell"); assertEquals(2, resultado.size()); }
Separe pedidos, estoque e notificações. A criação de pedido publica evento; estoque reserva de forma idempotente; falhas vão para estratégia de retentativa e dead-letter.
Você domina List, Set e Map desde o capítulo 11. Este capítulo cobre as estruturas por trás delas e outras que aparecem constantemente em entrevistas técnicas e em problemas reais que ArrayList/HashMap não resolvem elegantemente.
Deque<String> pilha = new ArrayDeque<>(); // Java não tem uma classe "Stack" idiomática -- usa-se Deque pilha.push("a"); pilha.push("b"); pilha.pop(); // remove "b" -- o último que entrou // uso real: a própria stack de chamadas de função (capítulo 01, 81) é uma pilha! // e é assim que o método "undo" de um editor de texto costuma funcionar
Fila<T>) e viu na prática no Kafka (capítulo 41, onde consumer groups processam mensagens em ordem de chegada).Queue<String> fila = new LinkedList<>(); fila.offer("a"); fila.offer("b"); fila.poll(); // remove "a" -- o primeiro que entrou
class No { int valor; No esquerda, direita; No(int valor) { this.valor = valor; } } // árvore de busca: esquerda menor, direita maior. // Busca é O(log n) quando a altura é logarítmica; sem balanceamento, // inserções ordenadas podem formar uma lista e degradar para O(n). boolean buscar(No raiz, int alvo) { if (raiz == null) return false; // caso base da recursão if (raiz.valor == alvo) return true; return alvo < raiz.valor ? buscar(raiz.esquerda, alvo) // descarta a metade direita inteira : buscar(raiz.direita, alvo); // descarta a metade esquerda inteira }
EXPLAIN ANALYZE mostre Index Scan em vez de Seq Scan.// representação simples: lista de adjacência Map<String, List<String>> grafo = new HashMap<>(); grafo.put("A", List.of("B", "C")); grafo.put("B", List.of("D")); grafo.put("C", List.of("D")); grafo.put("D", List.of()); // busca em largura (BFS) -- percorre "camada por camada", usando uma Fila: void bfs(String inicio) { Queue<String> fila = new LinkedList<>(); Set<String> visitados = new HashSet<>(); fila.offer(inicio); visitados.add(inicio); while (!fila.isEmpty()) { String atual = fila.poll(); System.out.println(atual); for (String vizinho : grafo.get(atual)) { if (!visitados.contains(vizinho)) { visitados.add(vizinho); fila.offer(vizinho); } } } }
Usando uma Deque como pilha, escreva um método boolean balanceado(String expressao) que verifica se todos os parênteses/colchetes/chaves de uma expressão estão corretamente balanceados (ex: "(a[b]{c})" → true, "(a[b)]" → false).
boolean balanceado(String expressao) { Deque<Character> pilha = new ArrayDeque<>(); Map<Character, Character> pares = Map.of(')', '(', ']', '[', '}', '{'); for (char c : expressao.toCharArray()) { if (c == '(' || c == '[' || c == '{') { pilha.push(c); } else if (pares.containsKey(c)) { if (pilha.isEmpty() || pilha.pop() != pares.get(c)) return false; } } return pilha.isEmpty(); // tudo que abriu precisa ter fechado }
Escrever código bom sozinho é só metade da engenharia de software profissional — a outra metade é comunicar decisões técnicas de forma que outras pessoas (incluindo você mesmo, seis meses depois) entendam o porquê, não só o o quê.
| Nível | O que revisar |
|---|---|
| Corretude | O código faz o que deveria? Cobre os casos-limite discutidos no capítulo 15? |
| Design | Segue SOLID (capítulo 16)? Está no lugar certo da arquitetura (capítulo 75)? |
| Legibilidade | Nomes claros (capítulo 74)? Um novo desenvolvedor entenderia sem perguntar? |
| Segurança | Alguma validação faltando (capítulo 48)? Secret vazando (capítulo 57)? |
Meses depois de uma decisão técnica importante ("por que escolhemos MongoDB em vez de Postgres para esse módulo?"), ninguém lembra o raciocínio — só o resultado. Um ADR é um documento curto, versionado junto com o código, registrando por que uma decisão arquitetural foi tomada.
# docs/adr/0003-usar-mongodb-para-catalogo.md
## Status
Aceito
## Contexto
O catálogo de produtos tem atributos que variam muito entre categorias
(capítulo 36) -- roupas têm tamanho/cor, eletrônicos têm voltagem/garantia.
Modelar isso em tabelas relacionais exigiria uma estrutura de atributos
genéricos difícil de consultar e manter.
## Decisão
Usar MongoDB para o serviço de catálogo, mantendo Postgres para pedidos
e pagamentos (que exigem transações fortes, capítulo 33).
## Consequências
+ Esquema flexível sem migrations a cada nova categoria de produto
- Introduz um segundo banco no stack, aumentando complexidade operacional
- Equipe precisa de familiaridade com consultas MongoDB (capítulo 37)
Escreva um ADR documentando a decisão de usar JWT em vez de sessão tradicional (capítulo 51) para autenticação da API da biblioteca, incluindo contexto, decisão e pelo menos duas consequências (uma positiva, uma negativa).
# ADR 0001: Usar JWT em vez de sessão tradicional ## Status Aceito ## Contexto A API da biblioteca é consumida por um front-end React separado (SPA) e, potencialmente, um app mobile no futuro -- múltiplos clientes independentes do back-end. ## Decisão Usar autenticação stateless via JWT (capítulo 51), em vez de sessão tradicional com cookie. ## Consequências + Não exige compartilhar estado de sessão entre múltiplas instâncias do back-end (capítulo 43, escopo Singleton) -- escala horizontalmente sem configuração extra de sessão distribuída. - Revogar um token antes da expiração é mais complexo que simplesmente apagar uma sessão no servidor -- exige lista de revogação se necessário.
Todo o resto deste curso ensinou como escrever software. Metodologias ágeis organizam como um time decide o que construir, em que ordem, e como acompanha o progresso — o contexto de processo em que praticamente todo trabalho profissional acontece.
| Papel/Cerimônia | Função |
|---|---|
| Product Owner | Decide o quê e em que ordem de prioridade construir |
| Scrum Master | Remove obstáculos do time, garante que o processo funcione |
| Sprint | Ciclo fixo de trabalho (geralmente 1-2 semanas) com um objetivo definido |
| Daily Standup | Reunião curta e diária: o que fiz, o que farei, o que está bloqueando |
| Sprint Review | Demonstração do que foi entregue ao final do sprint |
| Retrospectiva | O que funcionou bem no processo, o que precisa melhorar no próximo ciclo |
A FAZER → EM PROGRESSO → EM REVISÃO → CONCLUÍDO [tarefa A] [tarefa B] [tarefa C] [tarefa D] [tarefa E] [tarefa F]
Diferente do Scrum, Kanban não organiza trabalho em ciclos de tempo fixo — o foco é limitar o trabalho em progresso (WIP — Work In Progress) simultâneo, para reduzir contexto trocado e acelerar o fluxo de entrega item por item.
| Scrum | Kanban | |
|---|---|---|
| Estrutura de tempo | Sprints fixos | Fluxo contínuo, sem ciclos |
| Papéis definidos | Sim (PO, Scrum Master) | Não formalmente exigidos |
| Bom para | Times com escopo previsível, produto em evolução planejada | Times de suporte/manutenção, fluxo de trabalho mais imprevisível |
Para a funcionalidade "sistema de reserva de livros" (usuário reserva um livro emprestado, é notificado quando ele for devolvido), quebre em pelo menos 4 itens menores, entregáveis independentemente, que caberiam em um board Kanban ou backlog de sprint.
1. Modelar e migrar a tabela "reservas" (capítulo 35)
2. Endpoint para criar uma reserva (POST /livros/{id}/reserva)
3. Lógica de negócio: impedir reserva duplicada do mesmo usuário
4. Evento Kafka disparado na devolução, verificando fila de reservas (capítulo 41)
5. Notificação ao usuário quando o livro reservado ficar disponível (capítulo 64, WebSocket)
6. Endpoint para o usuário cancelar sua própria reserva
O momento em que todo o curso converge: o front-end fazendo requisições reais para a API Spring Boot que você construiu, com CORS já configurado (capítulo 50) para permitir a conversa entre os dois.
Antes mesmo do front-end tocar na API, é assim que qualquer desenvolvedor back-end valida seus endpoints — e é exatamente por isso que "funciona no Postman mas não no front" quase sempre aponta para CORS (capítulo 50), não para um bug na lógica da API.
1. Cria uma nova requisição: POST http://localhost:8080/auth/login
2. Aba "Body" → JSON: {"email": "teste@teste.com", "senha": "123456"}
3. Envia e confere a resposta: {"token": "eyJhbGc..."}
4. Copia o token, usa em requisições seguintes na aba "Authorization" → Bearer Token
// buscando a lista de livros: async function buscarLivros() { const resposta = await fetch(`${import.meta.env.VITE_API_URL}/livros`); if (!resposta.ok) { throw new Error(`Erro ${resposta.status}`); // resposta.ok é false para 4xx/5xx, capítulo 26 } return resposta.json(); // desserializa o JSON, capítulo 25 } // enviando dados com autenticação: async function criarLivro(dados, token) { const resposta = await fetch(`${import.meta.env.VITE_API_URL}/livros`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` // o JWT do capítulo 52 }, body: JSON.stringify(dados) }); return resposta.json(); }
fetch é o garçom fazendo esse mesmo pedido em nome do cliente — só que de forma automatizada, integrada à interface visual que o usuário realmente vê e interage.import axios from 'axios'; const api = axios.create({ baseURL: import.meta.env.VITE_API_URL, }); // interceptor: adiciona o token em TODA requisição automaticamente, // sem precisar repetir o header manualmente em cada chamada api.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); const { data } = await api.get('/livros'); // já vem parseado, sem precisar de .json()
@ControllerAdvice (capítulo 48) e do dynamic proxy (capítulo 20), só que do lado do cliente: um ponto único que intercepta toda requisição para aplicar uma regra transversal (adicionar token de autenticação), em vez de repetir esse código em cada chamada individual à API.Usando axios, crie um cliente configurado com baseURL apontando para a API da biblioteca, com um interceptor que adiciona o token JWT (guardado em algum lugar — veremos onde no próximo capítulo) em toda requisição. Escreva funções listarLivros(), emprestarLivro(codigo) e login(email, senha).
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL }); api.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); export async function listarLivros() { const { data } = await api.get('/livros'); return data; } export async function emprestarLivro(codigo) { const { data } = await api.post(`/livros/${codigo}/emprestimo`); return data; } export async function login(email, senha) { const { data } = await api.post('/auth/login', { email, senha }); localStorage.setItem('token', data.token); return data; }
Onde guardar o JWT (capítulo 51/52) no navegador é uma decisão de segurança real, não um detalhe de implementação — errar aqui abre a porta para roubo de sessão via XSS.
localStorage | Cookie httpOnly | |
|---|---|---|
| Acessível via JavaScript | Sim — localStorage.getItem(...) | Não — invisível para JS, só o navegador o envia |
| Risco de XSS | Alto — script malicioso injetado pode roubar o token diretamente | Baixo — mesmo com XSS, o script não consegue ler o cookie |
| Precisa enviar manualmente | Sim, no header Authorization (capítulo 62) | Não — o navegador envia automaticamente em toda requisição para o domínio |
| Risco de CSRF | Baixo (não é enviado automaticamente) | Precisa de proteção CSRF adicional |
localStorage é como deixar a chave de casa embaixo do tapete — qualquer um que descubra onde procurar (nesse caso, qualquer script rodando na sua página, inclusive um malicioso injetado via XSS) consegue pegar. Um cookie httpOnly é como um chaveiro eletrônico que só a fechadura em si consegue ler — mesmo que alguém entre na sala, não consegue copiar a chave manualmente.localStorage foi simplificado para fins didáticos. Em uma aplicação de produção real, especialmente uma que lida com dados sensíveis, cookie httpOnly + Secure + SameSite=Strict é a escolha mais segura — exige que o back-end (Spring Security, capítulo 52) defina o cookie na resposta de login, em vez do front-end guardar o token manualmente.localStorage é aceitável — é mais simples de implementar e entender o fluxo completo primeiro. Migrar para cookie httpOnly é o passo natural assim que o projeto sair do estágio de portfólio pessoal para algo que lida com dados reais de usuários reais.JavaScript puro deixa passar erros de contrato que só aparecem em runtime — exatamente o problema que a tipagem estática do Java (capítulo 01) já te ensinou a evitar.
function exibirLivro(livro) { return `${livro.titulo} (${livro.paginas} páginas)`; // se a API mudar "paginas" para "totalPaginas" sem avisar, // isso só quebra em RUNTIME, silenciosamente, como "undefined" na tela }
interface Livro { titulo: string; paginas: number; } function exibirLivro(livro: Livro): string { return `${livro.titulo} (${livro.paginas} páginas)`; // se a API mudar o campo, o editor avisa AGORA, em tempo de // desenvolvimento -- exatamente como o compilador Java (capítulo 00) faz }
interface Livro do TypeScript deveria, idealmente, espelhar exatamente o LivroDTO (capítulo 47) do back-end — na prática, times maduros geram esses tipos automaticamente a partir do contrato OpenAPI/Swagger (capítulo 49), garantindo que front e back nunca fiquem dessincronizados quanto ao formato dos dados trocados.Reescreva as funções listarLivros() e login(email, senha) do exercício 62.1 em TypeScript, definindo interfaces Livro e RespostaLogin correspondentes ao formato retornado pela API.
interface Livro { id: number; titulo: string; paginas: number; nomeAutor: string; } interface RespostaLogin { token: string; } export async function listarLivros(): Promise<Livro[]> { const { data } = await api.get<Livro[]>('/livros'); return data; } export async function login(email: string, senha: string): Promise<RespostaLogin> { const { data } = await api.post<RespostaLogin>('/auth/login', { email, senha }); return data; }
Todo o curso até aqui usou o modelo requisição/resposta: o cliente pergunta, o servidor responde, a conexão fecha. Isso não serve para "o servidor avisar o cliente sozinho" — um chat, uma notificação ao vivo, um dashboard atualizando sem o usuário recarregar a página.
| Cenário | Ferramenta |
|---|---|
| Listar livros, criar pedido, qualquer CRUD comum | REST tradicional (capítulo 26) — mais simples, cacheável, já dominado |
| Chat em tempo real | WebSocket |
| Notificação instantânea ("seu empréstimo está atrasado") | WebSocket (ou Server-Sent Events, mais simples para via única servidor→cliente) |
| Dashboard com métricas atualizando sozinho | WebSocket |
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins("http://localhost:5173"); // CORS, capítulo 50, aplicado aqui também } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topico"); // canais que o servidor publica para registry.setApplicationDestinationPrefixes("/app"); // prefixo para mensagens do cliente } } @Controller public class NotificacaoController { @MessageMapping("/emprestimo") // cliente manda para /app/emprestimo @SendTo("/topico/notificacoes") // servidor retransmite para todos inscritos public Notificacao notificar(PedidoEmprestimo pedido) { return new Notificacao("Livro emprestado: " + pedido.getTitulo()); } }
// front-end (usando a biblioteca STOMP sobre SockJS): const socket = new SockJS(`${import.meta.env.VITE_API_URL}/ws`); const stompClient = Stomp.over(socket); stompClient.connect({}, () => { stompClient.subscribe('/topico/notificacoes', (mensagem) => { const notificacao = JSON.parse(mensagem.body); exibirNotificacao(notificacao.texto); // atualiza a UI sem recarregar a página }); });
Configure um endpoint WebSocket /ws no back-end da biblioteca. Ao chamar o endpoint REST de empréstimo (capítulo 44/52), publique também uma mensagem no tópico /topico/notificacoes informando qual livro foi emprestado — de forma que qualquer cliente conectado via WebSocket receba a notificação instantaneamente, sem precisar dar refresh na página.
@Service public class Biblioteca { private final SimpMessagingTemplate mensageria; // injeção, capítulo 22 public Biblioteca(SimpMessagingTemplate mensageria) { this.mensageria = mensageria; } public void emprestar(String codigo) throws ItemIndisponivelException { // ... lógica de empréstimo já existente (capítulo 17/22) ... mensageria.convertAndSend("/topico/notificacoes", new Notificacao("Livro emprestado: " + codigo)); } }
O front-end (React, Vue, ou qualquer SPA) tem um perfil de deploy bem diferente do back-end: no final, é só HTML/CSS/JS estático — não precisa de um servidor Java rodando o tempo todo.
# conecta o repositório, e a cada push:
# 1. detecta o framework (Next.js, Vite, etc)
# 2. roda o build (npm run build)
# 3. publica os arquivos estáticos em uma CDN global automaticamente
# .env.production do front-end -- aponta para a API real, não localhost:
VITE_API_URL=https://api.biblioteca.com
npm run build) faz algo parecido com o multi-stage build do capítulo 31: pega código-fonte (JSX, TypeScript, CSS) e produz um punhado de arquivos estáticos otimizados (minificados, com hash no nome para cache eficiente do navegador) — o "estágio final enxuto" equivalente ao jre-alpine do back-end, só que para o mundo do front-end.| Monorepo | Poli-repo | |
|---|---|---|
| Estrutura | Front e back no mesmo repositório Git, em pastas separadas | Dois repositórios completamente independentes |
| Vantagem | Mudanças relacionadas (API muda + front se adapta) num único commit/PR | Pipelines de deploy independentes, times podem trabalhar sem interferir um no outro |
| Desvantagem | CI/CD precisa ser configurado para rodar só o que mudou | Sincronizar mudanças relacionadas exige coordenar dois PRs em dois lugares |
| Bom para | Time pequeno, projeto pessoal, forte acoplamento entre front e back | Times maiores, ciclos de release independentes, ownership claro |
# estrutura típica de monorepo: projeto-biblioteca/ ├── backend/ # projeto Spring Boot (capítulos 43-59) │ ├── pom.xml │ └── src/ ├── frontend/ # projeto React/Vite │ ├── package.json │ └── src/ └── docker-compose.yml # sobe os dois juntos, capítulo 32
git clone, um único docker compose up sobe tudo. Só considere poli-repo quando times diferentes realmente precisarem de ciclos de deploy totalmente independentes.Desenhe a estrutura de pastas de um monorepo para o projeto da biblioteca, com back-end Spring Boot e um front-end React, incluindo onde ficaria o docker-compose.yml que sobe os dois juntos com o banco Postgres (capítulo 32).
biblioteca/ ├── backend/ │ ├── Dockerfile │ ├── pom.xml │ └── src/ ├── frontend/ │ ├── Dockerfile │ ├── package.json │ └── src/ ├── docker-compose.yml # define os 3 serviços: api, front, db └── .env # local, no .gitignore
Antes do projeto final, um passo atrás para ver a floresta inteira, não só as árvores individuais que você plantou nos últimos capítulos.
1. Usuário clica "Emprestar" no FRONT-END (React/TS, capítulos 62-63)
2. axios envia POST /livros/{codigo}/emprestimo com JWT no header (capítulo 52)
3. Requisição atravessa HTTPS (capítulo 53) até o back-end
4. CORS (capítulo 50) já validou que essa origem tem permissão
5. Spring Security Filter Chain (capítulo 52) valida o JWT ANTES do controller
6. LivroController (capítulo 44) recebe a requisição, delega ao Service
7. Biblioteca (Service, capítulo 43) aplica a regra de negócio
8. Se o livro já está emprestado -> ItemIndisponivelException
-> capturada pelo @ControllerAdvice (capítulo 48) -> 409 Conflict
9. Se disponível: LivroRepository (Spring Data JPA, capítulo 45) faz UPDATE no Postgres
10. Cache no Redis (capítulo 38) é invalidado, se existir
11. Um evento é publicado no Kafka (capítulos 40-42): "livro emprestado"
12. Um consumidor assíncrono processa esse evento (ex: enviar notificação)
13. Uma notificação WebSocket (capítulo 64) é enviada em tempo real ao front
14. O Service devolve um DTO (capítulo 47), nunca a Entity crua
15. O Controller retorna 200 OK com o corpo em JSON (Jackson, capítulo 25)
16. Actuator (capítulo 56) registrou métricas dessa requisição o tempo todo
17. Se algo der muito errado, logs estruturados (capítulo 27) registram o ocorrido
| Termo | Definição resumida | Capítulo |
|---|---|---|
| Bean | Objeto gerenciado pelo container Spring (IoC) | 43 |
| DTO | Objeto usado só para transporte de dados entre camadas/API | 47 |
| N+1 | Bug de performance: 1 query + N queries extras evitáveis | 46 |
| Idempotência | Repetir a operação tem o mesmo efeito de fazê-la uma vez | 26, 40 |
| Circuit Breaker | Interrompe chamadas a um serviço que está falhando repetidamente | 55 |
| JWT | Token de autenticação assinado, carregando sua própria informação | 51 |
| Migration | Mudança versionada e ordenada no schema do banco | 35 |
| Consumer Group | Conjunto de consumidores Kafka dividindo o processamento de um tópico | 41 |
O projeto que reúne, literalmente, tudo — do Olá, POO! do capítulo 00 até WebSockets em tempo real. Um sistema de biblioteca completo, pronto para produção real.
Livro, Autor, Usuario com relacionamentos JPA corretos (capítulo 45), schema versionado via Flyway (capítulo 35).Vinte perguntas cobrindo do básico ao avançado. Clique em uma alternativa para ver o feedback na hora.
1. Qual a diferença fundamental entre sobrecarga (overload) e sobrescrita (override)?
2. Ao atribuir um objeto a outra variável (b = a), o que é copiado?
public.3. Se você sobrescreve equals(), o que é obrigatório fazer junto?
equals() funciona sozinho.hashCode() de forma coerente.final.4. Atributos (campos) de instância têm ligação dinâmica (polimorfismo)?
protected.5. Qual a diferença principal entre uma exceção checked e uma unchecked?
catch.6. Em generics, o que significa "type erasure"?
7. Por que um Stream só pode ser consumido (operação terminal) uma única vez?
8. Por que "valor++" não é seguro em múltiplas threads sem sincronização?
++ não existe de fato em Java.double.9. Qual estrutura de teste representa o padrão AAA?
10. O que o princípio de Liskov (o "L" de SOLID) afirma?
static não podem ser sobrescritos.11. Por que FetchType.LAZY sozinho não resolve o problema N+1?
12. Um JWT decodificado revela o conteúdo do payload sem nenhuma chave secreta. Isso significa que:
13. Qual a principal diferença entre Kafka e uma fila tradicional como RabbitMQ?
14. Por que consumidores de mensageria deveriam ser idempotentes?
15. Qual a diferença entre guardar um JWT em localStorage vs em um cookie httpOnly?
16. Por que Testcontainers é preferível a mocks para testar um Repository JPA?
17. O que um Circuit Breaker faz quando um serviço externo falha repetidamente?
18. Por que nunca usar ddl-auto=update em produção?
19. Por que uma imagem Docker de aplicação Java deveria usar multi-stage build?
20. Por que expor uma @Entity JPA diretamente em um endpoint REST é uma má prática?