Excepciones personalizadas en Java
Define tus propias clases de excepción en Java extendiendo Exception o RuntimeException para errores específicos del dominio.
Las excepciones integradas cubren la mayoría de los fallos generales, pero no saben nada sobre tu dominio. Cuando algo falla de una manera específica a tu código — "usuario no encontrado", "cupón inválido", "configuración desincronizada" — la mejor opción suele ser definir tu propio tipo de excepción. Las excepciones personalizadas cuestan poco de escribir, hacen que las trazas de pila sean autoexplicativas y permiten a los llamadores gestionar exactamente el fallo que les interesa.
La forma mínima
Una excepción personalizada es una clase que extiende Exception (o una de sus subclases). La versión más corta y útil:
public class UserNotFoundException extends Exception {
public UserNotFoundException(String message) {
super(message);
}
}Eso es una excepción personalizada comprobada y completa. Puedes usar throw new UserNotFoundException("id=42") desde cualquier lugar, y los llamadores pueden hacer catch (UserNotFoundException e).
¿Comprobada o no comprobada?
La decisión más importante al definir una clase de excepción: ¿qué extiendes?
extends Exception→ comprobada. El compilador obliga a los llamadores a gestionarla o declararla.extends RuntimeException→ no comprobada. Los llamadores pueden gestionarla pero no están obligados.
La misma lógica de excepciones comprobadas vs. no comprobadas aplica: extiende Exception cuando los llamadores pueden recuperarse de forma realista y quieres forzarlos a pensarlo; extiende RuntimeException cuando el fallo representa un error de programación o una condición de la que ningún llamador puede recuperarse de forma sensata.
Para excepciones de dominio en código Java moderno, RuntimeException es la opción más común — en parte porque las excepciones comprobadas no se combinan bien con streams y lambdas, y en parte porque la mayoría de los fallos de dominio ascienden hasta un único manejador de nivel superior de todas formas. Comienza con RuntimeException a menos que tengas una razón específica para forzar el manejo.
Los cuatro constructores
Por convención, una clase de excepción proporciona los mismos cuatro constructores que las integradas:
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 qué los cuatro:
- Sin argumentos — para herramientas y frameworks que usan reflexión sobre la clase.
- Solo mensaje — el caso más común en tu propio código.
- Mensaje + causa — para envolver una excepción de nivel inferior. El más importante de incluir.
- Solo causa — para cuando el mensaje de la causa ya es descriptivo.
No necesitas escribir los cuatro cada vez — los IDEs los generan con una sola pulsación de tecla — pero omitir las formas que incluyen la causa es una pérdida real. Sin ellas no puedes preservar la excepción subyacente cuando la envuelves.
Llevar estado útil
Los strings están bien, pero los campos personalizados son mejores. Si el llamador puede querer saber qué usuario no se encontró, expónlo:
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; }
}Ahora un bloque catch puede hacer algo con el fallo en lugar de simplemente analizar el mensaje:
catch (UserNotFoundException e) {
metrics.recordMissingUser(e.getUserId());
return Response.notFound();
}Mantén los campos inmutables (final) y el constructor mínimo. Las excepciones se construyen en la ruta de fallo — deben ser rápidas y no lanzar excepciones ellas mismas.
Envolver con una causa
La técnica más útil con excepciones personalizadas es traducir una excepción de bajo nivel en una de dominio, preservando la 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);
}
}El llamador ve un único tipo de excepción que coincide con el vocabulario de su capa. El fallo original no se pierde — está colgado de getCause() y aparece en printStackTrace() bajo una línea Caused by:.
Así es como mantienes las capas separadas. La API de Config no filtra IOException ni ParseException; ambas se traducen en algo que significa "falló la carga de la configuración".
Una pequeña jerarquía
Cuando tienes una familia de fallos relacionados, dales un padre común:
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); }
}Los llamadores pueden ser específicos (catch (CardDeclinedException)) o generales (catch (PaymentException)) según sea necesario. Un padre compartido también te da una única importación para incluir en una cláusula throws cuando el método podría lanzar cualquiera de ellas.
Qué evitar
- No extiendas
ThrowableniErrordirectamente. Siempre pasa porExceptionoRuntimeException. - No sobreescribas
getMessage()para calcular strings en cada llamada. Construye el mensaje en el constructor y deja que la clase padre lo almacene. - No pongas lógica en la excepción. Existe para transportar información. La recuperación pertenece al catch.
- No proliferes. Cada nuevo tipo de excepción es un pequeño contrato que los llamadores pueden querer gestionar. Si dos fallos realmente quieren un manejo idéntico, probablemente quieran ser el mismo tipo.
Un ejemplo completo
Un pequeño módulo de procesamiento de pedidos con su propia familia de excepciones. La clase base envuelve fallos de nivel inferior; las subclases llevan detalles del dominio; el controlador las captura a distintos niveles de especificidad para mostrar cómo la jerarquía permite elegir.
El controlador captura EmptyOrderException primero (el caso específico que quiere gestionar de forma diferente), luego OrderException como captura global para la familia. Cuando falla la validación, la cadena de causas enlaza de vuelta a la IllegalStateException original, por lo que no se pierde información al traducir al tipo de dominio.
Qué sigue
Ahora tienes toda la mecánica. El capítulo final es el lado del juicio — cuándo lanzar, cuándo capturar, qué registrar y los patrones que distinguen el código de excepciones maduro del ruido defensivo. Continúa con mejores prácticas de manejo de excepciones en Java.