Excepciones personalizadas y jerarquías

Avanzado
Java
Java
Actualizado: 18/04/2026

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 (SaldoInsuficienteException vs RuntimeException).
  • 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 cause cuando 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 Optional o Result para 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 - Autor del tutorial

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.