Boas práticas em Java: imutabilidade, SOLID e testes que realmente protegem seu código
- #Java
Como aplicar as boas práticas de imutabilidade, SOLID e testes em Java
Este artigo direto ao ponto mostra como aplicar imutabilidade, princípios SOLID e testes eficazes para escrever código Java mais legível, seguro e fácil de evoluir. Inclui exemplos práticos com record
, Streams, Optional, JUnit 5 e AssertJ, além de um checklist final para revisão técnica.
1) Por que falar de boas práticas em “Fundamentos de Java”?
Fundamentos não são só sintaxe. São hábitos que reduzem bug, melhoram a legibilidade e aceleram a evolução do sistema. Três pilares práticos que elevam o nível do seu Java:
- Imutabilidade: evita estados inesperados e facilita testes.
- SOLID: organiza responsabilidades e reduz acoplamento.
- Testes eficientes: validam regra de negócio e previnem regressões.
2) Imutabilidade: previsibilidade que paga dividendos
Quando aplicar: DTOs, Value Objects (moeda, CPF/CNPJ, e-mail), configurações, respostas de API.
2.1 Exemplo com record
(Java 16+)
public record Dinheiro(long centavos, String moeda) {
public Dinheiro {
if (centavos < 0) throw new IllegalArgumentException("Valor negativo");
if (moeda == null || moeda.isBlank()) throw new IllegalArgumentException("Moeda inválida");
}
public Dinheiro somar(Dinheiro outro) {
if (!this.moeda.equals(outro.moeda)) {
throw new IllegalArgumentException("Moedas diferentes");
}
return new Dinheiro(this.centavos + outro.centavos, this.moeda);
}
}
2.2 Se você está no Java 8
- Campos
private final
, sem setters. - Validação no construtor.
- Implemente
equals
/hashCode
/toString
. - Ao expor coleções, use cópias imutáveis:
public List<Item> itens() {
return Collections.unmodifiableList(this.itens);
}
Benefícios práticos: menos bugs de concorrência, objetos fáceis de testar, APIs mais previsíveis.
3) SOLID sem dogma (com exemplos mínimos e úteis)
S — Single Responsibility (uma razão para mudar)
Separar persistência, regra de negócio e integração reduz acoplamento.
class FaturaService {
private final FaturaRepository repo;
private final PoliticaCobranca politica;
public FaturaService(FaturaRepository repo, PoliticaCobranca politica) {
this.repo = repo;
this.politica = politica;
}
public void processar(Fatura f) {
politica.aplicar(f);
repo.salvar(f);
}
}
O — Open/Closed (aberto para extensão, fechado para modificação)
Novas regras sem tocar no núcleo.
interface RegraDesconto { BigDecimal aplicar(BigDecimal valor); }
class DescontoBlackFriday implements RegraDesconto { /* ... */ }
class DescontoAniversario implements RegraDesconto { /* ... */ }
class CalculadoraDescontos {
private final List<RegraDesconto> regras;
public CalculadoraDescontos(List<RegraDesconto> regras) {
this.regras = regras;
}
public BigDecimal calcular(BigDecimal valor) {
for (var r : regras) valor = r.aplicar(valor);
return valor;
}
}
L — Liskov Substitution (herdar sem quebrar contrato)
Se a subclasse precisa “desligar” comportamentos da superclasse, prefira composição.
I — Interface Segregation (interfaces pequenas)
Evite interfaces com métodos que clientes não usam. Crie interfaces coesas.
D — Dependency Inversion (dependa de abstrações)
Injete interfaces, não implementações. Isso reduz acoplamento e viabiliza testes.
4) Coleções, Streams e Optional sem armadilhas
4.1 Streams: expressividade com clareza
- Pipelines curtos e puros (sem efeitos colaterais).
collect(toList())
> construir manualmente comforEach
.- Evite
parallelStream()
sem medir ganho real.
List<Pedido> topAprovados = pedidos.stream()
.filter(Pedido::aprovado)
.sorted(Comparator.comparing(Pedido::data).reversed())
.limit(10)
.toList();
groupingBy
e partitioningBy
:
Map<Status, List<Pedido>> porStatus = pedidos.stream()
.collect(Collectors.groupingBy(Pedido::status));
Map<Boolean, List<Pedido>> atrasados = pedidos.stream()
.collect(Collectors.partitioningBy(Pedido::estaAtrasado));
4.2 Optional: sem null
surpresa
- Use como retorno; evite em campos.
- Prefira
orElse
,orElseGet
,orElseThrow
aget()
.
Cliente cliente = repo.buscarPorId(id)
.orElseThrow(() -> new NaoEncontradoException("Cliente " + id));
5) Exceções e logs que ajudam a resolver o problema
- Checked: chamador pode se recuperar (I/O, rede).
- Unchecked: erro de programação/validação (ex.:
IllegalArgumentException
). - Não engula exceções. Logue contexto e decida (propagar, retry, fallback).
public class SaldoInsuficienteException extends RuntimeException {
public SaldoInsuficienteException(String conta, BigDecimal tentativa, BigDecimal saldo) {
super("Saldo insuficiente na conta " + conta +
" (tentativa=" + tentativa + ", saldo=" + saldo + ")");
}
}
try {
pagamentoService.executar(pedido);
} catch (GatewayIndisponivelException e) {
log.warn("Gateway indisponível no pagamento do pedido {}. Tentando fallback.", pedido.getId(), e);
// fallback...
}
Boas práticas de log: níveis corretos (info/warn/error
), sem dados sensíveis, mensagens acionáveis.
6) Testes que realmente protegem (JUnit 5 + AssertJ)
Pirâmide saudável: muitos testes de unidade, menos de integração, poucos E2E.
6.1 Exemplo de teste de unidade (regra de negócio)
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class DinheiroTest {
@Test
void soma_mesma_moeda() {
Dinheiro a = new Dinheiro(100, "BRL");
Dinheiro b = new Dinheiro(50, "BRL");
Dinheiro r = a.somar(b);
assertThat(r.centavos()).isEqualTo(150);
assertThat(r.moeda()).isEqualTo("BRL");
}
@Test
void soma_moedas_diferentes_dispara_erro() {
var a = new Dinheiro(100, "BRL");
var b = new Dinheiro(50, "USD");
assertThatThrownBy(() -> a.somar(b))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Moedas diferentes");
}
}
6.2 Dicas de produtividade
- Nomes descritivos:
deve_calcular_desconto_progressivo
. - Test Doubles (mocks/fakes) só quando há dependências externas.
- Cobertura é métrica, não objetivo: foque ramos críticos do domínio.
- Corrigiu bug? Escreva um teste que o reproduza.
7) Qualidade contínua no dia a dia
- Formatação e lint (Spotless/Checkstyle) no build.
- Qualidade em PR (Sonar, regras simples e claras).
- Métricas úteis: complexidade ciclomática, duplicação, acoplamento.
- Exceções às regras só com justificativa explícita (comentário técnico).
8) Erros comuns (e como evitar)
- Objetos mutáveis “escapando”: expondo listas/arrays mutáveis → retorne cópias imutáveis.
- Streams gigantes: quebre em métodos nomeados para manter legibilidade.
- Optional mal usado: chamar
get()
sem checar presença → useorElseThrow
. parallelStream()
por “achismo”: meça; cuidado com thread pools disputadas.- Exceções genéricas: mensagens fracas e sem contexto atrapalham diagnóstico.
- Testes frágeis: acoplados a detalhes de implementação, nomes ou ordenações não determinísticas.
9) Checklist de revisão (cole no fim do seu PR)
- Classes com uma responsabilidade clara
- Dependências via interfaces + injeção
- Objetos de valor imutáveis; coleções expostas de forma segura
- Exceções informativas (com contexto) e logs nos níveis corretos
- Streams curtas, puras e legíveis (sem efeitos colaterais)
- Optional apenas como retorno; sem
null
oculto - Testes de unidade cobrindo regras críticas e cenários de erro
- Ferramentas de qualidade no pipeline (formatador, linter, análise estática)
- Nomes de classes/métodos/variáveis expressivos; comentários só onde agregam
Conclusão
Boas práticas não são burocracia: são atalhos para código confiável. Comece pequeno hoje:
- torne um Value Object imutável;
- extraia uma regra para uma interface (SOLID);
- escreva um teste que impediria um bug real que você já viu.
Repita esse ciclo nos próximos PRs. Seu futuro “eu” — e seu time — vão agradecer.