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.

Jerarquía de Throwable con Error, Exception checked y RuntimeException unchecked Throwable Error OutOfMemoryError, StackOverflowError. Fallas de la JVM. NO se capturan. Exception IOException, SQLException. CHECKED: el compilador te obliga. RuntimeException NullPointerException, ArithmeticException, UNCHECKED: el compilador no dice nada. ¿Por qué no capturar un Error? Porque no hay nada que puedas hacer. Si la JVM se quedó sin memoria, tu catch tampoco va a poder ejecutarse. Todo lo que cuelga de RuntimeException es unchecked. El resto de Exception es checked. Esa línea divide el mundo en dos.
La jerarquía define quién te obliga a hacerte cargo. Es la decisión de diseño más importante al crear una excepción propia.

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
}
Qué bloques se ejecutan con y sin excepción en un try-catch-finally Sin excepción Con excepción try { ... } se ejecuta entero, hasta la última línea catch (...) { ... } SE SALTEA por completo finally { ... } se ejecuta el código que sigue el programa continúa normalmente try { ... } se CORTA en la línea que falla catch (...) { ... } se ejecuta, si el tipo coincide finally { ... } se ejecuta igual el código que sigue el programa continúa normalmente finally se ejecuta SIEMPRE: con excepción, sin excepción, e incluso si hay un return adentro del try. Lo único que se saltea es el catch, cuando no hubo nada que atrapar. Todo lo demás corre igual.
Las líneas del 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) y throws (declarar, en la firma) son cosas distintas y se escriben casi igual. Es una fuente clásica de confusión: throw es una acción, throws es 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.

Propagación de una excepción hacia abajo en la pila de llamadas hasta encontrar un catch La excepción sube por la pila hasta que alguien la atrapa Integer.parseInt("veintidós") throw new NumberFormatException(...) acá NACE la excepción leerLinea() no tiene try/catch abandona y sigue bajando procesarArchivo() tampoco tiene try/catch abandona y sigue bajando main() catch (NumberFormatException e) { ... } acá SE DETIENE Si nadie la atrapa, la JVM imprime el stack trace y termina el hilo. Ese stack trace es exactamente este recorrido.
Cada método que no atrapa la excepción es abandonado inmediatamente: su código posterior a la llamada no se ejecuta.

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
Las tres salidas posibles de un bloque try-with-resources pasan siempre por el cierre automático try (var conexion = new ConexionSimulada(...)) { el recurso queda declarado en el paréntesis Termina bien llegó a la última línea Lanza una excepción se corta a la mitad Hace un return sale antes de tiempo conexion.close() — automático antes de que se ejecute el catch o el finally Funciona con cualquier clase que implemente AutoCloseable. Podés declarar varios recursos separados por punto y coma.
Los tres caminos de salida convergen en el mismo punto. No hay forma de olvidarse el 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

ErrorQué pasaCómo se arregla
catch vacíoEl 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 catchAtrapa 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íficoError 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íneasImposible 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 manoAnidamiento, null checks y un close() que también puede fallar.try-with-resources.
Usar excepciones para casos normalesCó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

  1. Creá una excepción EdadInvalidaException que extienda RuntimeException, guarde la edad rechazada y arme un mensaje descriptivo.
  2. Creá una clase RegistroDePersonas con un método registrar(String nombre, int edad) que la lance si la edad no está entre 0 y 120.
  3. Agregá un método registrarDesdeTexto(String nombre, String edadTexto) que convierta el texto y traduzca el NumberFormatException a tu propia excepción, conservando la causa.
  4. 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.
  5. Usá un finally para 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 try se corta en la línea que falla; el finally se ejecuta siempre, incluso con return de por medio.
  • La excepción sube por la pila hasta encontrar un catch. Capturá donde puedas hacer algo, no donde ocurre.
  • Los catch van de lo más específico a lo más general, y el multi-catch evita duplicar bloques.
  • try-with-resources para 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 catch vacío es peor que no capturar nada.