Exceções Personalizadas em Java
Defina suas próprias classes de exceção em Java estendendo Exception ou RuntimeException para erros específicos do domínio.
As exceções integradas cobrem a maioria das falhas gerais, mas não sabem nada sobre o seu domínio. Quando algo falha de uma forma específica ao seu código — "usuário não encontrado", "cupom inválido", "configuração fora de sincronia" — a decisão certa geralmente é definir seu próprio tipo de exceção. Exceções personalizadas custam pouco para escrever, tornam os stack traces autoexplicativos e permitem que quem chama trate exatamente a falha que lhe interessa.
A estrutura mínima
Uma exceção personalizada é uma classe que estende Exception (ou uma de suas subclasses). A versão mais curta e útil:
public class UserNotFoundException extends Exception {
public UserNotFoundException(String message) {
super(message);
}
}Isso é uma exceção personalizada checked completa. Você pode usar throw new UserNotFoundException("id=42") de qualquer lugar, e quem chama pode usar catch (UserNotFoundException e).
Checked ou unchecked?
A decisão mais importante ao definir uma classe de exceção: o que você estende?
extends Exception→ checked. O compilador obriga quem chama a tratar ou declarar a exceção.extends RuntimeException→ unchecked. Quem chama pode tratá-la, mas não é obrigado.
A mesma lógica de exceções checked vs. unchecked se aplica: estenda Exception quando quem chama pode realisticamente se recuperar e você quer forçá-lo a pensar sobre isso; estenda RuntimeException quando a falha representa um bug ou uma condição da qual ninguém pode se recuperar de forma sensata.
Para exceções de domínio em código Java moderno, RuntimeException é a escolha mais comum — em parte porque exceções checked não se compõem bem com streams e lambdas, e em parte porque a maioria das falhas de domínio sobe até um único handler de nível superior. Comece com RuntimeException a menos que tenha uma razão específica para forçar o tratamento.
Os quatro construtores
Por convenção, uma classe de exceção fornece os mesmos quatro construtores que os tipos integrados:
public class ConfigLoadException extends RuntimeException {
public ConfigLoadException() {
super();
}
public ConfigLoadException(String message) {
super(message);
}
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
public ConfigLoadException(Throwable cause) {
super(cause);
}
}Por que todos os quatro:
- Sem argumentos — para ferramentas e frameworks que usam reflexão na classe.
- Somente mensagem — o caso comum no seu próprio código.
- Mensagem + causa — para encapsular uma exceção de nível inferior. O mais importante a incluir.
- Somente causa — para quando a mensagem da causa já é descritiva.
Você não precisa digitar todos os quatro toda vez — IDEs os geram com um atalho de teclado — mas omitir as formas com causa é uma perda real. Sem elas, você não consegue preservar a exceção subjacente ao encapsular.
Carregando estado útil
Strings são aceitáveis, mas campos personalizados são melhores. Se quem chama pode querer saber qual usuário não foi encontrado, exponha isso:
public class UserNotFoundException extends RuntimeException {
private final String userId;
public UserNotFoundException(String userId) {
super("user not found: " + userId);
this.userId = userId;
}
public String getUserId() { return userId; }
}Agora um bloco catch pode fazer algo com a falha em vez de apenas analisar a mensagem:
catch (UserNotFoundException e) {
metrics.recordMissingUser(e.getUserId());
return Response.notFound();
}Mantenha os campos imutáveis (final) e o construtor mínimo. Exceções são construídas no caminho de falha — devem ser rápidas e nunca lançar exceções elas mesmas.
Encapsulando com uma causa
A técnica mais útil com exceções personalizadas é traduzir uma exceção de baixo nível em uma do domínio, preservando a original:
public Config load(Path p) {
try {
return parser.parse(Files.readString(p));
} catch (IOException e) {
throw new ConfigLoadException("could not read " + p, e);
} catch (ParseException e) {
throw new ConfigLoadException("invalid config in " + p, e);
}
}Quem chama vê um único tipo de exceção que corresponde ao vocabulário da sua camada. A falha original não é perdida — ela está pendurada em getCause() e aparece no printStackTrace() sob uma linha Caused by:.
É assim que você mantém as camadas separadas. A API Config não vaza IOException nem ParseException; ambas são traduzidas em algo que significa "falha ao carregar a configuração."
Uma pequena hierarquia
Quando você tem uma família de falhas relacionadas, dê a elas um pai compartilhado:
public class PaymentException extends RuntimeException {
public PaymentException(String message) { super(message); }
public PaymentException(String message, Throwable c) { super(message, c); }
}
public class CardDeclinedException extends PaymentException {
public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
public FraudCheckFailedException(String message) { super(message); }
}Quem chama pode ser específico (catch (CardDeclinedException)) ou abrangente (catch (PaymentException)) conforme necessário. Um pai compartilhado também oferece uma única importação para usar em uma cláusula throws quando o método pode lançar qualquer uma delas.
O que evitar
- Não estenda
ThrowableouErrordiretamente. Sempre passe porExceptionouRuntimeException. - Não sobrescreva
getMessage()para calcular strings a cada chamada. Construa a mensagem no construtor e deixe a classe pai armazená-la. - Não coloque lógica na exceção. Ela existe para carregar informações. A recuperação pertence ao catch.
- Não prolifere. Cada novo tipo de exceção é um pequeno contrato que quem chama pode querer tratar. Se duas falhas realmente querem tratamento idêntico, provavelmente deveriam ser o mesmo tipo.
Um exemplo completo
Um pequeno módulo de processamento de pedidos com sua própria família de exceções. A classe base encapsula falhas de nível inferior; as subclasses carregam detalhes do domínio; o driver captura-as em diferentes níveis de especificidade para mostrar como a hierarquia permite que você escolha.
O driver captura EmptyOrderException primeiro (o caso específico que quer tratar de forma diferente), depois OrderException como catch-all para a família. Quando a validação falha, a cadeia de causas remete à IllegalStateException original, para que você não perca informações ao traduzir para o tipo de domínio.
O que vem a seguir
Agora você tem toda a mecânica. O capítulo final aborda o lado do julgamento — quando lançar, quando capturar, o que registrar e os padrões que distinguem um código de tratamento de exceções maduro do ruído defensivo. Continue para boas práticas de tratamento de exceções em Java.