Por qué excepciones personalizadas
Las excepciones estándar de Java (IllegalArgumentException, IOException, NullPointerException...) son útiles pero genéricas. En un dominio real conviene tener excepciones específicas que:
- Comuniquen claramente el tipo de error (
SaldoInsuficienteExceptionvsRuntimeException). - Permitan captura selectiva (
catch (PagoExpiradoException e)). - Lleven contexto estructurado (id de usuario, importe, código de error).
- Organicen errores en jerarquías lógicas.
Checked vs Unchecked
Java distingue dos tipos de excepciones por su comportamiento con el compilador:
Checked (extends Exception pero no RuntimeException): obligan al llamante a capturarlas o declararlas con throws.
public class ArchivoNoEncontradoException extends Exception {
public ArchivoNoEncontradoException(String mensaje) {
super(mensaje);
}
}
Unchecked (extends RuntimeException): no obligan nada; el compilador no exige tratarlas.
public class CarritoVacioException extends RuntimeException {
public CarritoVacioException(String mensaje) {
super(mensaje);
}
}
Cuándo usar cada uno
| Tipo | Usar para | No usar para | |------|-----------|--------------| | Checked | Errores recuperables donde el llamante puede actuar (archivo no encontrado, conexión perdida) | Errores de programación o condiciones que el llamante no puede resolver | | Unchecked | Errores de programación (null inesperado, índice fuera de rango) o fallos de lógica de negocio que rara vez se recuperan | Errores genuinamente recuperables que el llamante debería manejar obligatoriamente |
La tendencia moderna es preferir unchecked en aplicaciones (Spring, frameworks modernos las usan mayoritariamente) y checked con más moderación, porque la obligación de capturar suele dar lugar a catch vacíos.
Plantilla básica de excepción personalizada
public class SaldoInsuficienteException extends RuntimeException {
private final String cuentaId;
private final BigDecimal saldo;
private final BigDecimal solicitado;
public SaldoInsuficienteException(String cuentaId, BigDecimal saldo, BigDecimal solicitado) {
super("Saldo insuficiente en cuenta %s: disponible %s, solicitado %s"
.formatted(cuentaId, saldo, solicitado));
this.cuentaId = cuentaId;
this.saldo = saldo;
this.solicitado = solicitado;
}
public String getCuentaId() { return cuentaId; }
public BigDecimal getSaldo() { return saldo; }
public BigDecimal getSolicitado() { return solicitado; }
}
Ventajas de incluir campos:
- El handler puede decidir la respuesta con información estructurada.
- Los logs contienen contexto sin tener que parsear el mensaje.
- Permite testear específicamente los campos, no solo la clase.
Jerarquías de excepciones
Organiza excepciones por dominio con una raíz común:
// Raíz del dominio de pagos
public class PagoException extends RuntimeException {
public PagoException(String mensaje) { super(mensaje); }
public PagoException(String mensaje, Throwable causa) { super(mensaje, causa); }
}
// Subtipos específicos
public class TarjetaRechazadaException extends PagoException {
private final String codigoRechazo;
public TarjetaRechazadaException(String codigoRechazo, String razon) {
super("Tarjeta rechazada [" + codigoRechazo + "]: " + razon);
this.codigoRechazo = codigoRechazo;
}
public String getCodigoRechazo() { return codigoRechazo; }
}
public class FondosInsuficientesException extends PagoException {
public FondosInsuficientesException(BigDecimal saldo, BigDecimal solicitado) {
super("Fondos insuficientes: " + saldo + " < " + solicitado);
}
}
public class PagoExpiradoException extends PagoException {
public PagoExpiradoException(LocalDateTime expirado) {
super("El pago expiró el " + expirado);
}
}
Esto permite capturas tanto específicas como genéricas:
try {
pago.procesar();
} catch (TarjetaRechazadaException e) {
// manejar específicamente
} catch (PagoException e) {
// cualquier otro error del dominio de pagos
} catch (Exception e) {
// todo lo demás
}
Chaining (causa subyacente)
Si tu excepción envuelve otra (por ejemplo, una SQLException mientras cargas un dato), pasa la causa en el constructor:
public Producto cargar(long id) {
try {
return repositorio.findById(id);
} catch (SQLException e) {
throw new CargaProductoException("No se pudo cargar producto " + id, e);
}
}
El método getCause() expone la causa original. El printStackTrace() muestra ambas con Caused by:. Nunca pierdas la causa original: facilita muchísimo el diagnóstico.
public class CargaProductoException extends RuntimeException {
public CargaProductoException(String msg, Throwable cause) {
super(msg, cause);
}
}
try-with-resources
Para recursos que deben cerrarse (conexiones, streams, lectores), usa try-with-resources. Cualquier clase que implemente AutoCloseable o Closeable es compatible:
public String leerArchivo(Path ruta) throws IOException {
try (BufferedReader br = Files.newBufferedReader(ruta)) {
return br.lines().collect(Collectors.joining("\n"));
}
// br.close() se llama automáticamente, incluso si se lanzó excepción
}
Puedes declarar múltiples recursos separados por ;:
try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement("SELECT ...");
var rs = stmt.executeQuery()) {
// ...
}
Los recursos se cierran en orden inverso al de declaración, y se cierran siempre.
Crear tu propia clase cerrable
Implementar AutoCloseable es trivial:
public class ConexionArchivo implements AutoCloseable {
private final FileChannel canal;
public ConexionArchivo(Path ruta) throws IOException {
this.canal = FileChannel.open(ruta);
}
public void leer(ByteBuffer buf) throws IOException {
canal.read(buf);
}
@Override
public void close() throws IOException {
canal.close();
}
}
// Uso
try (ConexionArchivo ca = new ConexionArchivo(Path.of("datos.bin"))) {
// ca.leer(...)
}
Multi-catch
Desde Java 7, puedes capturar varias excepciones disjuntas en un solo bloque:
try {
hacerAlgo();
} catch (ArchivoNoEncontradoException | FormatoInvalidoException e) {
log.warn("Error conocido: {}", e.getMessage());
return Optional.empty();
}
La variable e es del tipo común más específico (normalmente Exception).
Rethrow tipado (Java 7+)
Cuando recibes Exception general pero el compilador puede inferir que solo lanzas subtipos específicos, el flujo funciona:
public void procesar() throws IOException, SQLException {
try {
intentar();
} catch (Exception e) {
log.error("Error al procesar", e);
throw e; // el compilador sabe que solo puede ser IOException o SQLException
}
}
Buenas prácticas
- Mensajes específicos con contexto: "no se pudo cargar producto 1234" en lugar de "error".
- Una clase por tipo de error: no reutilices la misma excepción para casos distintos.
- Preserva la causa: siempre pasa
causecuando envuelvas. - Valida pronto, falla rápido:
Objects.requireNonNull,if (x < 0) throw ...al principio del método. - No captures solo para relanzar idéntico: el compilador ya propaga por defecto.
- Evita
catch (Exception e)silencioso: al menos loguea o enriquece el mensaje. - No uses excepciones para flujo de control: lanzar es caro. Devuelve
OptionaloResultpara casos esperados.
Errores comunes
// MAL: catch y ignore
catch (IOException e) {
// nada
}
// MAL: perder la causa
catch (IOException e) {
throw new RuntimeException(e.getMessage()); // causa perdida
}
// BIEN: preservar causa
catch (IOException e) {
throw new CargaException("No se pudo cargar", e);
}
Una jerarquía de excepciones bien diseñada es una señal clara de un equipo experto en Java: comunica intención, facilita logs y permite manejos elegantes.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Java es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Java
Explora más contenido relacionado con Java y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Decidir cuándo crear excepciones personalizadas. Elegir entre checked (Exception) y unchecked (RuntimeException). Diseñar jerarquías coherentes. Enriquecer excepciones con campos contextuales y cause (chaining). Usar try-with-resources con clases AutoCloseable. Aplicar multi-catch y rethrow tipado.