Erros como parte do design orientado a objetos
Em um modelo orientado a objetos, erros não são “casos especiais” tratados no fim do método: eles fazem parte do contrato da API. Um bom tratamento de erros preserva três objetivos: (1) manter o objeto em estado válido, (2) comunicar a falha com precisão e (3) permitir recuperação quando fizer sentido.
Em Java, isso normalmente se materializa em: exceções (para falhas), validações (para impedir estados inválidos), mensagens orientadas a diagnóstico (para facilitar suporte e observabilidade) e alternativas a null (para ausência de valor sem ambiguidade).
Exceções em Java: checked vs unchecked (e quando usar)
Unchecked (RuntimeException): violações de regra, pré-condição e bugs de uso
Use exceções unchecked quando a falha indica que o código chamador usou a API de forma incorreta ou quando se trata de uma regra de domínio que não é razoável obrigar o chamador a tratar em todos os pontos (mas ele pode tratar em uma borda, como um controller).
- Exemplos típicos: argumentos inválidos, estado inválido, regra de negócio violada (saldo insuficiente), operação não permitida.
- Vantagem: não “polui” assinaturas com throws; incentiva tratamento em camadas de borda.
Checked (Exception): falhas recuperáveis e esperadas do ambiente
Use exceções checked quando a falha é esperada e o chamador tem uma ação clara de recuperação, como: tentar novamente, escolher outro recurso, informar ao usuário para corrigir algo externo.
- Exemplos típicos: I/O, rede, acesso a arquivo, integrações onde o chamador pode escolher fallback.
- Cuidado: checked em excesso pode gerar cascata de throws e try/catch sem valor; prefira encapsular detalhes técnicos e expor um erro de aplicação mais significativo.
Regra prática de decisão
| Situação | Preferência | Motivo |
|---|---|---|
| Violação de regra de domínio (ex.: limite excedido) | Unchecked | Contrato do domínio; tratar na borda |
| Argumento inválido (pré-condição) | Unchecked | Erro de uso da API |
| Falha técnica recuperável (ex.: arquivo temporariamente indisponível) | Checked | Chamador pode reagir |
| Falha técnica não recuperável no contexto | Unchecked (encapsulada) | Evita obrigar tratamento inútil |
Exceções específicas do domínio: comunicar o “porquê”
Exceções genéricas (IllegalArgumentException, RuntimeException) comunicam pouco. Exceções do domínio tornam o erro autoexplicativo e permitem tratamento seletivo (por tipo) sem depender de mensagem.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Estrutura recomendada
- Crie uma hierarquia pequena: uma base do domínio e especializações.
- Inclua dados úteis (ex.: id, limite, valor) como campos, não só na mensagem.
- Evite expor detalhes internos sensíveis; foque em diagnóstico e correção.
public class DomainException extends RuntimeException { public DomainException(String message) { super(message); } public DomainException(String message, Throwable cause) { super(message, cause); }}public class SaldoInsuficienteException extends DomainException { private final String contaId; private final long saldoAtualCentavos; private final long valorSolicitadoCentavos; public SaldoInsuficienteException(String contaId, long saldoAtualCentavos, long valorSolicitadoCentavos) { super("Saldo insuficiente para débito."); this.contaId = contaId; this.saldoAtualCentavos = saldoAtualCentavos; this.valorSolicitadoCentavos = valorSolicitadoCentavos; } public String getContaId() { return contaId; } public long getSaldoAtualCentavos() { return saldoAtualCentavos; } public long getValorSolicitadoCentavos() { return valorSolicitadoCentavos; }}Com isso, a camada de apresentação pode mapear o erro para uma resposta adequada (ex.: HTTP 409) e logs podem incluir os campos para diagnóstico.
Validação de entrada: onde validar e como evitar duplicação
Validação tem dois alvos diferentes:
- Validação de entrada (boundary): dados vindos de fora (UI, API, arquivo). Objetivo: rejeitar cedo e retornar mensagens amigáveis.
- Validação de regra/invariante (core): regras que sempre precisam valer dentro do domínio. Objetivo: impedir estados inválidos mesmo se a boundary falhar.
Uma prática comum é validar formato e obrigatoriedade na borda, e manter validações essenciais no núcleo (para garantir consistência).
Passo a passo: validar um comando e proteger o domínio
Exemplo: caso de uso “transferir”.
1) Validar dados básicos na borda (ex.: null, vazio, valores negativos).
2) No núcleo, validar regras de domínio (ex.: saldo, conta ativa).
3) Lançar exceções específicas para cada falha relevante.
public record TransferenciaCommand(String origemId, String destinoId, long valorCentavos) {}public class TransferenciaService { private final ContaRepository repo; public TransferenciaService(ContaRepository repo) { this.repo = repo; } public void transferir(TransferenciaCommand cmd) { if (cmd == null) throw new IllegalArgumentException("cmd não pode ser null"); if (cmd.origemId() == null || cmd.origemId().isBlank()) throw new IllegalArgumentException("origemId é obrigatório"); if (cmd.destinoId() == null || cmd.destinoId().isBlank()) throw new IllegalArgumentException("destinoId é obrigatório"); if (cmd.valorCentavos() <= 0) throw new IllegalArgumentException("valorCentavos deve ser > 0"); Conta origem = repo.getById(cmd.origemId()) .orElseThrow(() -> new ContaNaoEncontradaException(cmd.origemId())); Conta destino = repo.getById(cmd.destinoId()) .orElseThrow(() -> new ContaNaoEncontradaException(cmd.destinoId())); origem.debitar(cmd.valorCentavos()); destino.creditar(cmd.valorCentavos()); repo.save(origem); repo.save(destino); }}public class ContaNaoEncontradaException extends DomainException { private final String contaId; public ContaNaoEncontradaException(String contaId) { super("Conta não encontrada."); this.contaId = contaId; } public String getContaId() { return contaId; }}Mensagens orientadas a diagnóstico (sem virar “texto aleatório”)
Uma boa mensagem de erro deve ajudar alguém a responder: o que falhou, por que falhou e o que fazer agora (quando aplicável). Em sistemas distribuídos, também é útil incluir um identificador de correlação (gerado na borda) nos logs, não necessariamente na exceção.
Boas práticas
- Mensagem curta e estável na exceção (ex.: “Saldo insuficiente para débito.”).
- Contexto em campos (ids, valores, limites) para logs/telemetria.
- Não dependa de parsing de mensagem para lógica; use o tipo da exceção e campos.
- Evite vazar segredos (tokens, dados pessoais) em mensagens e logs.
- Cause encadeada: ao traduzir exceções técnicas, preserve
cause.
Traduzindo exceções técnicas para exceções de aplicação
Ao integrar com infraestrutura (DB, HTTP, filesystem), capture exceções técnicas e lance uma exceção mais significativa para a camada acima, mantendo a causa.
public class FalhaDePersistenciaException extends RuntimeException { public FalhaDePersistenciaException(String message, Throwable cause) { super(message, cause); }}public class ContaRepositorySql implements ContaRepository { public Optional<Conta> getById(String id) { try { // consulta SQL... return Optional.empty(); } catch (Exception e) { throw new FalhaDePersistenciaException("Falha ao consultar conta.", e); } }}Evitar retornos nulos: Optional, coleções vazias e alternativas
null cria ambiguidade: “não existe”, “não carregado”, “erro”, “não aplicável”. Em APIs orientadas a objetos, prefira tipos que expressem intenção.
Quando usar Optional
- Bom uso: retorno de consulta que pode não encontrar resultado.
- Evite: usar Optional em campos de entidade (pode complicar serialização e invariantes), ou como parâmetro em excesso.
public interface ContaRepository { Optional<Conta> getById(String id);}Conta conta = repo.getById(id) .orElseThrow(() -> new ContaNaoEncontradaException(id));Preferir coleção vazia a null
public List<Transacao> listarTransacoes(String contaId) { List<Transacao> txs = /* consulta */ List.of(); return txs; // nunca null}Null Object (quando a ausência tem comportamento)
Quando “ausência” precisa responder a métodos sem explodir, um objeto nulo explícito pode ser melhor que null. Ex.: um Desconto que sempre retorna 0.
public interface PoliticaDeDesconto { long calcularDescontoCentavos(long subtotalCentavos);}public class SemDesconto implements PoliticaDeDesconto { public long calcularDescontoCentavos(long subtotalCentavos) { return 0; }}Invariantes e consistência: falhar rápido e não corromper estado
Ao ocorrer um erro, o objeto deve permanecer consistente. Isso implica duas regras práticas:
- Falhar antes de mutar: valide tudo que puder antes de alterar campos.
- Operações atômicas: se uma operação envolve múltiplas mudanças, garanta que ou todas acontecem ou nenhuma (no mínimo, no nível do objeto; em persistência, via transação).
Exemplo: débito com validação antes da alteração
public class Conta { private final String id; private long saldoCentavos; private boolean ativa; public Conta(String id, long saldoCentavos, boolean ativa) { this.id = id; this.saldoCentavos = saldoCentavos; this.ativa = ativa; } public void debitar(long valorCentavos) { if (!ativa) throw new OperacaoNaoPermitidaException(id, "Conta inativa"); if (valorCentavos <= 0) throw new IllegalArgumentException("valorCentavos deve ser > 0"); if (saldoCentavos < valorCentavos) { throw new SaldoInsuficienteException(id, saldoCentavos, valorCentavos); } // só muta depois de validar saldoCentavos -= valorCentavos; } public void creditar(long valorCentavos) { if (!ativa) throw new OperacaoNaoPermitidaException(id, "Conta inativa"); if (valorCentavos <= 0) throw new IllegalArgumentException("valorCentavos deve ser > 0"); saldoCentavos += valorCentavos; }}public class OperacaoNaoPermitidaException extends DomainException { private final String contaId; private final String motivo; public OperacaoNaoPermitidaException(String contaId, String motivo) { super("Operação não permitida."); this.contaId = contaId; this.motivo = motivo; } public String getContaId() { return contaId; } public String getMotivo() { return motivo; }}Assertivas e validações internas
Use assert com cuidado: é útil para invariantes internas durante desenvolvimento, mas pode estar desabilitado em produção. Para invariantes essenciais, prefira validações normais com exceções.
private void validarEstadoInterno() { if (saldoCentavos < 0) { throw new IllegalStateException("Invariante violada: saldo negativo"); }}Design de APIs que comunicam erros claramente
1) Prefira métodos que deixam claro quando podem falhar
- Consulta: retorne
Optionalquando “não encontrado” é normal. - Comando: lance exceções de domínio quando a operação não pode ser realizada.
// Consulta: ausência é esperadapublic Optional<Conta> buscarConta(String id); // Comando: falha é exceção de domíniopublic void encerrarConta(String id);2) Não use boolean para erro sem contexto
Evite APIs como boolean debitar(...) que retornam false sem explicar o motivo. Se você precisa de múltiplos motivos, use exceções específicas ou um resultado tipado.
3) Quando resultado tipado fizer sentido (sem exceção)
Em alguns casos, falha faz parte do fluxo normal e você quer evitar exceções (por exemplo, validação de formulário em lote). Um padrão é retornar um objeto de resultado com erros.
public record ErroDeValidacao(String campo, String mensagem) {}public final class Resultado<T> { private final T valor; private final List<ErroDeValidacao> erros; private Resultado(T valor, List<ErroDeValidacao> erros) { this.valor = valor; this.erros = erros; } public static <T> Resultado<T> ok(T valor) { return new Resultado<>(valor, List.of()); } public static <T> Resultado<T> falha(List<ErroDeValidacao> erros) { return new Resultado<>(null, List.copyOf(erros)); } public boolean sucesso() { return erros.isEmpty(); } public T valor() { return valor; } public List<ErroDeValidacao> erros() { return erros; }}Esse estilo é útil quando você quer acumular múltiplos erros e apresentar todos de uma vez, sem lançar exceção na primeira falha.
Passo a passo prático: refatorando uma API “frágil” para uma API robusta
Cenário inicial (problemático)
Problemas comuns: retorno null, boolean sem contexto, exceções genéricas.
// API frágilpublic Conta getConta(String id) { // retorna null se não achar}public boolean sacar(String id, long valor) { // retorna false se não deu, sem dizer por quê}Passo 1: tornar ausência explícita
public Optional<Conta> getConta(String id) { // Optional.empty() se não achar}Passo 2: substituir boolean por exceções de domínio
public void sacar(String id, long valorCentavos) { Conta conta = getConta(id).orElseThrow(() -> new ContaNaoEncontradaException(id)); conta.debitar(valorCentavos);}Passo 3: enriquecer diagnóstico
Inclua campos na exceção (id, valores) e preserve causa ao traduzir falhas técnicas.
Exercícios propostos (cenários de falha e design de API)
Exercício 1: Catálogo de produtos com busca e validação
Você tem um ProdutoRepository com método findBySku. Implemente:
Optional<Produto> findBySku(String sku)(nunca retorne null).- Um serviço
alterarPreco(sku, novoPrecoCentavos)que lança: ProdutoNaoEncontradoExceptionquando o SKU não existe.PrecoInvalidoExceptionquando o preço é <= 0.
Teste mental: quais mensagens e campos você colocaria em cada exceção para facilitar diagnóstico?
Exercício 2: Matrícula em curso com múltiplas regras
Modele uma operação matricular(alunoId, cursoId) com falhas possíveis:
- Aluno não encontrado
- Curso não encontrado
- Curso lotado
- Aluno já matriculado
Tarefa: crie exceções específicas para cada falha e defina quais dados devem estar disponíveis nelas (ids, capacidade, vagas, etc.).
Exercício 3: Validação em lote sem exceções
Você recebe uma lista de comandos de cadastro. Requisito: retornar todos os erros de validação de uma vez (campo + mensagem), sem lançar exceção na primeira falha.
- Implemente um
Resultado<Cliente>(ou similar) que acumule erros. - Defina quando usar esse resultado em vez de exceções.
Exercício 4: Tradução de exceções técnicas
Simule um repositório que pode lançar uma exceção técnica (ex.: SQLException ou uma RuntimeException genérica). Crie:
FalhaDePersistenciaExceptioncomcause- Uma camada de serviço que captura a exceção técnica e lança a exceção de aplicação
Pergunta: quais informações entram na mensagem e quais ficam apenas em logs?
Exercício 5: API que evita null e comunica ausência
Você tem um método Endereco enderecoPrincipal(Cliente c) que hoje retorna null quando não existe endereço.
- Refatore para retornar
Optional<Endereco>. - Atualize o código chamador para usar
orElseThrowquando endereço for obrigatório eorElsequando houver fallback.