Tratamento de erros em Java OOP: exceções, invariantes e mensagens úteis

Capítulo 13

Tempo estimado de leitura: 11 minutos

+ Exercício

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çãoPreferênciaMotivo
Violação de regra de domínio (ex.: limite excedido)UncheckedContrato do domínio; tratar na borda
Argumento inválido (pré-condição)UncheckedErro de uso da API
Falha técnica recuperável (ex.: arquivo temporariamente indisponível)CheckedChamador pode reagir
Falha técnica não recuperável no contextoUnchecked (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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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. 1) Validar dados básicos na borda (ex.: null, vazio, valores negativos).

  2. 2) No núcleo, validar regras de domínio (ex.: saldo, conta ativa).

  3. 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 Optional quando “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:
    • ProdutoNaoEncontradoException quando o SKU não existe.
    • PrecoInvalidoException quando 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:

  • FalhaDePersistenciaException com cause
  • 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 orElseThrow quando endereço for obrigatório e orElse quando houver fallback.

Agora responda o exercício sobre o conteúdo:

Ao projetar uma API orientada a objetos em Java, qual abordagem melhor mantém o objeto em estado válido e comunica a falha com precisão quando uma operação não pode ser realizada por regra de domínio (ex.: saldo insuficiente)?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Regras de domínio devem proteger invariantes no núcleo: valida-se antes de mutar e, se falhar, lança-se exceção específica (unchecked) com dados (ids/valores). Isso evita estados inválidos e melhora diagnóstico, ao contrário de boolean/null sem contexto.

Próximo capitúlo

Padrão Factory em Java OOP: criação de objetos e centralização de variações

Arrow Right Icon
Capa do Ebook gratuito Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos
76%

Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos

Novo curso

17 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.