Manejo de Excepciones y Robustez
En la lección Constructores, Modificadores de Acceso y Getters/Setters escribiste esto:
public boolean setPrecio(double precio) {
if (precio < 0) {
return false;
}
this.precio = precio;
return true;
}
Funciona, pero tiene una grieta: setPrecio devuelve boolean para avisar si aceptó o rechazó el valor, pero nada obliga a quien lo llama a revisar ese resultado.
producto.setPrecio(-500); // compila, corre, y el rechazo pasa completamente desapercibido
El precio queda como estaba, no hay ningún aviso, y el programa sigue corriendo como si nada. Esta lección resuelve exactamente esa grieta: una forma de fallar que no se pueda ignorar por accidente.
Un programa real falla todo el tiempo, y no por culpa tuya: el archivo no está, la red se cae, el usuario escribe "veintidós" donde iba un número, la base de datos rechaza la conexión. La pregunta no es si va a fallar, sino qué hace tu código cuando falla.
Java tiene una respuesta muy concreta: cuando algo sale mal, se lanza un objeto que describe el problema, y la ejecución normal se interrumpe hasta que alguien lo atrape y decida qué hacer. A diferencia de un boolean que se puede dejar pasar sin mirar, una excepción que nadie atrapa detiene el programa: no hay forma de fingir que no pasó nada.
1. Una excepción es un objeto
Esto es lo primero que hay que sacarse de encima: una excepción no es un código de error ni un estado mágico. Es una instancia de una clase, con su jerarquía de herencia, sus atributos y sus métodos, exactamente como todo lo que venís viendo.
Como cualquier objeto, una excepción trae información útil:
catch (ArithmeticException e) {
e.getMessage(); // "/ by zero" — la descripción
e.getCause(); // la excepción original, si esta la envuelve
e.getStackTrace(); // el recorrido completo de llamadas
e.printStackTrace(); // lo imprime en la salida de error
}
2. try, catch, finally y el orden en que corren
try {
// el código que puede fallar
} catch (TipoDeExcepcion e) {
// qué hacer si falla de esa manera
} finally {
// lo que hay que hacer sí o sí, pase lo que pase
}
try posteriores a la falla nunca se ejecutan. Es el error más común al leer un try largo.Ese detalle del try que se corta es más importante de lo que parece:
try {
System.out.println("A");
int x = 10 / 0; // ← acá se lanza
System.out.println("B"); // ← NUNCA se ejecuta
} catch (ArithmeticException e) {
System.out.println("C");
}
System.out.println("D");
// Salida: A, C, D
Por eso conviene que los bloques try sean cortos. Un try de cuarenta líneas es un bloque donde no sabés en qué punto quedó todo cuando saltó la excepción.
3. Checked vs unchecked: quién te obliga
Esta distinción es exclusiva de Java y define cómo se escribe todo el resto.
Unchecked (RuntimeException y sus hijas): representan errores de programación. Un NullPointerException no se maneja, se evita. El compilador no te dice nada porque la solución no es un catch, es arreglar el código.
String s = null;
s.length(); // NullPointerException — el bug es el null, no la excepción
int[] a = new int[3];
a[5] = 1; // ArrayIndexOutOfBoundsException — el bug es el 5
Integer.parseInt("hola"); // NumberFormatException — validá la entrada antes
Checked (Exception sin ser RuntimeException): representan condiciones esperables del entorno que tu código no controla. Un servicio externo puede no responder; eso no es un bug tuyo, es la realidad. El compilador te obliga a decidir qué hacés.
Imaginá un método obtenerConfiguracion() que consulta un servicio externo, y que por eso declara throws ConfiguracionNoDisponibleException —una excepción checked, porque el servicio puede no estar disponible y quien lo llama tiene que decidir qué hacer—.
Y solo hay dos opciones. Manejarla:
public void leerConfiguracion() {
try {
String valor = obtenerConfiguracion();
System.out.println(valor);
} catch (ConfiguracionNoDisponibleException e) {
System.out.println("No se pudo leer la configuración, uso los valores por defecto.");
}
}
O declarar que no te hacés cargo, y que se ocupe quien te llamó:
public String leerConfiguracion() throws ConfiguracionNoDisponibleException {
return obtenerConfiguracion(); // que decida el de arriba
}
throw(lanzar, dentro del método) ythrows(declarar, en la firma) son cosas distintas y se escriben casi igual. Es una fuente clásica de confusión:throwes una acción,throwses una advertencia.
4. La propagación: cómo sube una excepción
Cuando se lanza una excepción y el método actual no la atrapa, no se pierde: la JVM abandona ese método y la ofrece al que lo llamó, y así sucesivamente hacia abajo en la pila.
Esto tiene una consecuencia de diseño enorme: no tenés que capturar donde ocurre el error, sino donde podés hacer algo con él. Un método que lee un archivo casi nunca sabe qué hacer si no existe; el que sí sabe es el que lo mandó a leer.
Un catch que no puede tomar ninguna decisión útil es un catch que sobra.
5. Varios catch, y el orden importa
try {
procesar(datos);
} catch (NumberFormatException e) { // más específica primero
System.out.println("El dato no es un número válido.");
} catch (IllegalArgumentException e) { // NumberFormatException hereda de esta
System.out.println("Argumento inválido.");
} catch (Exception e) { // la más general, al final
System.out.println("Error inesperado.");
}
Java prueba los catch en orden y ejecuta el primero cuyo tipo coincida. Por eso van de lo más específico a lo más general. Si invertís el orden, el compilador te frena directamente: los catch posteriores serían inalcanzables.
Cuando dos tipos distintos se manejan igual, no dupliques el bloque: usá multi-catch.
try {
conectarYGuardar();
} catch (IOException | SQLException e) {
logger.error("Falló la persistencia: " + e.getMessage());
}
6. try-with-resources: el cierre que no te podés olvidar
Cuando abrís un archivo, una conexión o un socket, tenés que cerrarlo. Siempre. Incluso —sobre todo— si algo falla en el medio.
Los archivos llegan más adelante, en la lección Archivos, Serialización y Empaquetado JAR. Para ver el mecanismo de cierre sin depender todavía de ellos, vamos a simular un recurso con una clase propia que implementa la interfaz AutoCloseable —la misma noción de interfaz que viste en Clases Abstractas, Interfaces y Organización del Código—: imprime un mensaje cuando se abre y otro cuando se cierra, así se ve el orden exacto en que ocurre todo.
public class ConexionSimulada implements AutoCloseable {
private final String servidor;
public ConexionSimulada(String servidor) {
if (servidor == null || servidor.isBlank()) {
throw new IllegalArgumentException("El servidor no puede estar vacío");
}
this.servidor = servidor;
System.out.println("Conectando a " + servidor + "...");
}
public void enviar(String comando) {
System.out.println("Enviando: " + comando);
}
@Override
public void close() {
System.out.println("Conexión a " + servidor + " cerrada.");
}
}
Hacerlo a mano se ve así:
ConexionSimulada conexion = null;
try {
conexion = new ConexionSimulada("servidor-datos");
conexion.enviar("SELECT * FROM productos");
} catch (IllegalArgumentException e) {
System.out.println("No se pudo conectar.");
} finally {
if (conexion != null) { // ¿y si falló al construirla?
try {
conexion.close(); // cerrar también puede lanzar excepción
} catch (Exception e) {
// y acá casi nadie sabe qué poner
}
}
}
Nueve líneas de ceremonia, dos casos borde que la mayoría olvida, y todavía no hicimos nada útil. Por eso existe try-with-resources:
try (ConexionSimulada conexion = new ConexionSimulada("servidor-datos")) {
conexion.enviar("SELECT * FROM productos");
} catch (IllegalArgumentException e) {
System.out.println("No se pudo conectar.");
}
// conexion ya está cerrada, pase lo que pase
close() porque no lo escribís vos.7. Excepciones personalizadas
Cuando el problema es de tu dominio, las excepciones estándar no lo describen bien. IllegalStateException es correcta pero muda; SaldoInsuficienteException te dice qué pasó desde el nombre.
// Unchecked: el llamador puede prevenirlo consultando el saldo antes
public class SaldoInsuficienteException extends RuntimeException {
private final double faltante;
public SaldoInsuficienteException(double solicitado, double disponible) {
super(String.format("Faltan $%.2f: se pidieron $%.2f y hay $%.2f",
solicitado - disponible, solicitado, disponible));
this.faltante = solicitado - disponible;
}
public double getFaltante() { return faltante; }
}
Fijate que la excepción lleva datos, no solo un texto. El catch puede usarlos:
catch (SaldoInsuficienteException e) {
System.out.printf("Te faltan $%.2f. ¿Querés cargar saldo?%n", e.getFaltante());
}
¿Checked o unchecked?
La pregunta que decide: ¿quien llama a este método puede hacer algo razonable para recuperarse?
- Sí, y es una condición esperable del entorno →
extends Exception(checked). Ejemplo:ArchivoDeConfiguracionNoEncontrado. - No, o es un error de uso de la API →
extends RuntimeException(unchecked). Ejemplo:EdadInvalidaException, porque el llamador debería haber validado antes.
En la práctica, la mayoría del código moderno se inclina por unchecked, porque las checked obligan a propagar throws por toda la cadena de llamadas y eso termina ensuciando firmas de métodos que no tienen nada que ver con el problema.
Encadenar causas
Cuando traducís una excepción de bajo nivel a una de tu dominio, nunca pierdas la original:
try {
return repositorio.buscarPorId(id);
} catch (SQLException e) {
// El segundo argumento es la causa: conserva el stack trace completo
throw new RepositorioNoDisponibleException("No se pudo consultar el cliente " + id, e);
}
Sin ese e, el stack trace se corta justo donde estaba la información que necesitabas para depurar. Es uno de los errores que más horas cuesta.
8. Los cuatro antipatrones
1. El catch vacío. El peor de todos, sin competencia:
try {
guardarPedido(pedido);
} catch (Exception e) {
// TODO: ver esto después
}
El pedido no se guardó, el usuario ve “listo”, y no hay ni un rastro en ningún log. Un error tragado es infinitamente peor que un error visible.
2. Capturar Exception de entrada. Atrapa todo, incluidos los bugs de programación que querías que explotaran fuerte y temprano. Capturá el tipo más específico que sepas manejar.
3. Excepciones para control de flujo normal. Que un usuario no exista no es excepcional, es martes:
// Mal: usa una excepción para algo que pasa todos los días
try {
Usuario u = buscarUsuario(email);
mostrar(u);
} catch (UsuarioNoEncontradoException e) {
mostrarFormularioDeRegistro();
}
// Bien: null expresa "puede no haber" sin ninguna excepción
Usuario u = buscarUsuario(email);
if (u != null) {
mostrar(u);
} else {
mostrarFormularioDeRegistro();
}
Además de confundir al que lee, lanzar excepciones es caro: construirlas implica capturar el stack trace completo.
4. return dentro de finally. Descarta silenciosamente la excepción que estaba viajando:
try {
throw new IllegalStateException("algo grave");
} finally {
return 0; // la excepción DESAPARECE. Nadie se entera nunca.
}
9. Errores frecuentes
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
catch vacío | El fallo desaparece sin rastro y el bug aparece mucho después, irreconocible. | Como mínimo, registrarlo. Si de verdad se ignora a propósito, dejarlo escrito en un comentario. |
catch (Exception e) como primer catch | Atrapa también los bugs de programación que deberían explotar. | Capturar el tipo más específico que sepas manejar. |
Poner el catch general antes que el específico | Error de compilación: el segundo catch es inalcanzable. | Ordenar de lo más específico a lo más general. |
Relanzar sin la causa: throw new MiException(e.getMessage()) | Se pierde el stack trace original y con él la línea que falló de verdad. | throw new MiException("contexto", e). |
Bloque try de cincuenta líneas | Imposible saber en qué punto quedó el estado cuando saltó la excepción. | try cortos, alrededor de la operación que puede fallar. |
Cerrar recursos en finally a mano | Anidamiento, null checks y un close() que también puede fallar. | try-with-resources. |
| Usar excepciones para casos normales | Código confuso y lento: cada excepción captura el stack trace completo. | Un valor de retorno comprobable (null, un boolean) o validación previa. |
10. Ejercicio práctico guiado
Desafío: validación de edad
- Creá una excepción
EdadInvalidaExceptionque extiendaRuntimeException, guarde la edad rechazada y arme un mensaje descriptivo. - Creá una clase
RegistroDePersonascon un métodoregistrar(String nombre, int edad)que la lance si la edad no está entre 0 y 120. - Agregá un método
registrarDesdeTexto(String nombre, String edadTexto)que convierta el texto y traduzca elNumberFormatExceptiona tu propia excepción, conservando la causa. - En el
main, probá un caso válido, una edad fuera de rango y un texto que no es número. Capturá cada uno y mostrá un mensaje útil. - Usá un
finallypara dejar constancia de que el intento de registro terminó, haya salido bien o mal.
Ver solución sugerida
public class EdadInvalidaException extends RuntimeException {
private final int edadRechazada;
public EdadInvalidaException(int edadRechazada) {
super("Edad inválida: " + edadRechazada + ". Debe estar entre 0 y 120.");
this.edadRechazada = edadRechazada;
}
// Constructor con causa: para envolver otra excepción sin perderla
public EdadInvalidaException(String mensaje, Throwable causa) {
super(mensaje, causa);
this.edadRechazada = -1;
}
public int getEdadRechazada() { return edadRechazada; }
}
public class RegistroDePersonas {
private static final int EDAD_MINIMA = 0;
private static final int EDAD_MAXIMA = 120;
public void registrar(String nombre, int edad) {
if (nombre == null || nombre.isBlank()) {
throw new IllegalArgumentException("El nombre es obligatorio");
}
if (edad < EDAD_MINIMA || edad > EDAD_MAXIMA) {
throw new EdadInvalidaException(edad);
}
System.out.println(" ✓ Registrado: " + nombre + ", " + edad + " años");
}
public void registrarDesdeTexto(String nombre, String edadTexto) {
int edad;
try {
edad = Integer.parseInt(edadTexto.trim());
} catch (NumberFormatException e) {
// Traducimos al lenguaje de NUESTRO dominio, sin perder la causa
throw new EdadInvalidaException(
"'" + edadTexto + "' no es un número válido para una edad", e);
}
registrar(nombre, edad); // la validación de rango vive en un solo lugar
}
}
public class MainRegistro {
public static void main(String[] args) {
RegistroDePersonas registro = new RegistroDePersonas();
String[][] intentos = {
{"Laura Giménez", "28"}, // válido
{"Carlos Ruiz", "150"}, // fuera de rango
{"Ana Torres", "treinta"} // no es un número
};
for (String[] intento : intentos) {
System.out.println("Intentando registrar a " + intento[0] + "...");
try {
registro.registrarDesdeTexto(intento[0], intento[1]);
} catch (EdadInvalidaException e) {
System.out.println(" ✗ " + e.getMessage());
if (e.getCause() != null) {
// La causa original sigue disponible para el log técnico
System.out.println(" causa técnica: " + e.getCause());
}
} catch (IllegalArgumentException e) {
System.out.println(" ✗ Dato inválido: " + e.getMessage());
} finally {
System.out.println(" — intento finalizado —\n");
}
}
}
}
Tres decisiones de diseño para mirar acá.
EdadInvalidaException es unchecked porque quien llama puede validar la edad antes: es un error de uso, no una condición del entorno.
registrarDesdeTexto traduce el NumberFormatException técnico a una excepción del dominio, pero pasa e como causa. El stack trace completo sigue disponible; solo cambia el idioma en que se cuenta el problema.
Y registrar es el único lugar donde vive la regla del rango. registrarDesdeTexto convierte y delega. Es el mismo principio del constructor canónico de la lección Constructores, Modificadores de Acceso y Getters/Setters.
Para llevarte
- Una excepción es un objeto con jerarquía, datos y stack trace. No es un código de error.
- Unchecked (
RuntimeException) = error de programación: se evita, no se maneja. Checked = condición del entorno: el compilador te obliga a decidir. - El
tryse corta en la línea que falla; elfinallyse ejecuta siempre, incluso conreturnde por medio. - La excepción sube por la pila hasta encontrar un
catch. Capturá donde puedas hacer algo, no donde ocurre. - Los
catchvan de lo más específico a lo más general, y el multi-catch evita duplicar bloques. try-with-resourcespara todo lo que se abre y se cierra. Sin excepciones.- Al relanzar, pasá siempre la causa: sin ella perdés la línea donde realmente falló.
- Un
catchvacío es peor que no capturar nada.