Hasta ahora has estado en el lado receptor: leyendo stack traces y capturando lo que otros lanzaban. En esta lección cambias de papel. throw es la palabra con la que tu código declara que no puede cumplir su contrato, y throws es la anotación con la que avisa de ello en su firma.

Este cambio de papel es el que resuelve, por fin, dos deudas que arrastras desde el módulo 3. La primera: los constructores de Libro, Empleado y Prestamo validan sus datos imprimiendo avisos por consola y sustituyendo los valores inválidos por defectos —"Sin titulo", anio = 0—, lo que produce objetos que existen pero están mal. La segunda: Catalogo.buscarPorReferencia devuelve null cuando no encuentra nada, y ese null viaja por el programa hasta explotar lejos de su origen. Ambas se prometieron para este módulo. Toca pagarlas.

Verás también la técnica que separa el código diagnosticable del que no lo es: el encadenamiento de excepciones. Envolver un fallo de bajo nivel en uno del vocabulario de tu capa está bien; hacerlo perdiendo la causa es tirar a la basura la única información que resolvía el caso. La diferencia entre ambas cosas es un argumento en un constructor, y verás exactamente qué desaparece del stack trace cuando se olvida.

Contenido

  1. throw: lanzar explícitamente
  2. Código inalcanzable tras un throw
  3. throws: declarar en la firma
  4. Qué obliga throws al llamador
  5. Propagación en cadena
  6. Declarar excepciones no comprobadas en throws
  7. Validación de argumentos: la caja de herramientas
  8. Objects.requireNonNull y sus variantes
  9. Precondiciones y fallar rápido
  10. Encadenamiento de excepciones: conservar la causa
  11. Qué se pierde exactamente si no conservas la causa
  12. Relanzar: throw e frente a envolver
  13. Traducción de excepciones entre capas
  14. El análisis preciso de relanzamiento de Java 7
  15. throws y sobrescritura de métodos
  16. BiblioTech: validaciones que lanzan y búsquedas que no devuelven null
  17. Errores Comunes y Consejos
  18. Ejercicios

  1. throw: lanzar explícitamente

La sintaxis es de una simplicidad engañosa:

throw expresionQueDaUnThrowable;

Tres reglas:

  1. La expresión debe evaluar a un objeto que descienda de Throwable. No puedes lanzar un String ni un int.
  2. Lo habitual es crear el objeto en el mismo throw: throw new IllegalArgumentException("...");. Pero también puedes lanzar una excepción que ya tengas en una variable.
  3. Si la expresión evalúa a null, se lanza un NullPointerException en su lugar. throw null; compila y produce un NullPointerException, no un fallo raro.

Un ejemplo de BiblioTech, ya con la mentalidad correcta:

package com.nexussoftware.bibliotech.dominio;

public class CalculadoraTarifa {

    private static final double TARIFA_DIARIA = 0.25;
    private static final double MULTA_MAXIMA  = 20.0;

    /**
     * Calcula la multa de un material devuelto con retraso.
     *
     * @param diasRetraso dias de retraso, cero o positivo
     * @return el importe, topado a MULTA_MAXIMA
     * @throws IllegalArgumentException si diasRetraso es negativo
     */
    public double calcular(int diasRetraso) {
        if (diasRetraso < 0) {
            throw new IllegalArgumentException(
                    "Los dias de retraso no pueden ser negativos: " + diasRetraso);
        }
        return Math.min(diasRetraso * TARIFA_DIARIA, MULTA_MAXIMA);
    }

    public static void main(String[] args) {
        CalculadoraTarifa calc = new CalculadoraTarifa();
        System.out.println(calc.calcular(4));      // 1.0
        System.out.println(calc.calcular(120));    // 20.0 (topado)
        System.out.println(calc.calcular(-3));     // <-- IllegalArgumentException
    }
}

Salida:

1.0
20.0
Exception in thread "main" java.lang.IllegalArgumentException: Los dias de retraso no pueden ser negativos: -3
	at com.nexussoftware.bibliotech.dominio.CalculadoraTarifa.calcular(CalculadoraTarifa.java:16)
	at com.nexussoftware.bibliotech.dominio.CalculadoraTarifa.main(CalculadoraTarifa.java:26)

Compara este comportamiento con la versión del módulo 3, que hacía esto:

// VERSION ANTIGUA (modulo 3): corrige el dato y sigue
public double calcular(int diasRetraso) {
    if (diasRetraso < 0) {
        System.out.println("AVISO: dias negativos, se usa 0");
        diasRetraso = 0;                            // "arregla" el dato
    }
    return Math.min(diasRetraso * TARIFA_DIARIA, MULTA_MAXIMA);
}

La diferencia es enorme. La versión antigua inventa un dato y devuelve 0.0 como si fuera un cálculo legítimo. Quien llama recibe un número perfectamente creíble que no corresponde a nada real, y el aviso se pierde en una consola que probablemente nadie está mirando. La versión nueva se niega a inventar: dice qué esperaba, qué recibió y en qué línea, y detiene el proceso antes de propagar un dato falso.

El principio general, que vale para todo el módulo:

Es mejor un fallo ruidoso ahora que un resultado incorrecto silencioso después.

  1. Código inalcanzable tras un throw

Un throw termina el método inmediatamente, igual que un return. Cualquier sentencia posterior en el mismo bloque es inalcanzable, y Java lo detecta en compilación:

public double calcular(int diasRetraso) {
    if (diasRetraso < 0) {
        throw new IllegalArgumentException("Negativo: " + diasRetraso);
        // System.out.println("nunca");   // error: unreachable statement
    }
    return diasRetraso * TARIFA_DIARIA;
}

Esa comprobación tiene una consecuencia práctica muy útil: un método cuyo else termina en throw no necesita un return en esa rama, porque el compilador sabe que ese camino no llega al final:

public Gravedad clasificar(int diasRetraso) {
    if (diasRetraso < 0) {
        throw new IllegalArgumentException("Negativo: " + diasRetraso);
    }
    if (diasRetraso == 0) { return Gravedad.SIN_RETRASO; }
    if (diasRetraso <= UMBRAL_LEVE) { return Gravedad.LEVE; }
    return Gravedad.GRAVE;
    // No hace falta un return tras el throw inicial: ese camino nunca llega aqui
}

Y un caso que confunde: un método declarado con tipo de retorno que solo lanza compila perfectamente, sin return:

public Material buscarObligatorio(String referencia) {
    throw new UnsupportedOperationException("Pendiente de implementar");
    // Compila: el metodo nunca retorna normalmente, asi que no falta ningun return
}

Es el idioma habitual para dejar un método sin implementar sin romper la compilación. Mucho mejor que return null;, que compilaría igual pero introduciría un null de contrabando.

  1. throws: declarar en la firma

throws va en la firma del método, tras la lista de parámetros, y anuncia qué excepciones puede propagar:

public void cargarCatalogo(String ruta) throws IOException {
    // ...
}

Dos palabras casi idénticas con papeles opuestos:

throw throws
Dónde Dentro del cuerpo del método En la firma
Qué hace Lanza una excepción ahora Declara que puede lanzarse
Cuántas Una, la del objeto Varias, separadas por comas
Sintaxis throw new X("..."); ... metodo() throws X, Y {
Obligatorio Nunca Sí, para comprobadas que se propaguen

Varias excepciones se separan por comas:

public void sincronizar(String ruta) throws IOException, InterruptedException {
    // ...
}

Y lo esencial: throws solo es obligatorio para las excepciones comprobadas. Si tu método lanza IllegalArgumentException (no comprobada), no tienes que declarar nada; el código compila igual.

// Compila sin throws: IllegalArgumentException es NO comprobada
public double calcular(int diasRetraso) {
    if (diasRetraso < 0) {
        throw new IllegalArgumentException("Negativo");
    }
    return diasRetraso * 0.25;
}

// NO compila sin throws: IOException es COMPROBADA
public String leerCatalogo(String ruta) {
    throw new java.io.IOException("No implementado");
    // error: unreported exception IOException; must be caught or declared to be thrown
}

  1. Qué obliga throws al llamador

Cuando llamas a un método que declara una excepción comprobada, el compilador te da exactamente dos opciones. Es la regla de capturar o declarar.

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;

public class OpcionesDelLlamador {

    /** Metodo que declara una excepcion comprobada. */
    static String leerCatalogo(String ruta) throws IOException {
        if (!ruta.endsWith(".txt")) {
            throw new IOException("Formato no soportado: " + ruta);
        }
        return "3 materiales";
    }

    // OPCION A: capturar. Este metodo se responsabiliza del fallo.
    static void opcionCapturar() {
        try {
            String contenido = leerCatalogo("catalogo.csv");
            System.out.println(contenido);
        } catch (IOException e) {
            System.out.println("No se pudo cargar el catalogo: " + e.getMessage());
            System.out.println("Se arranca con el catalogo vacio.");
        }
    }

    // OPCION B: declarar. Este metodo delega el problema a QUIEN LE LLAME.
    static void opcionDeclarar() throws IOException {
        String contenido = leerCatalogo("catalogo.csv");
        System.out.println(contenido);
    }

    // OPCION C (ilegal): ignorar. NO COMPILA.
    // static void opcionIgnorar() {
    //     leerCatalogo("catalogo.csv");
    //     // error: unreported exception IOException; must be caught or declared
    // }

    public static void main(String[] args) {
        opcionCapturar();

        // main tambien tiene que elegir: aqui captura
        try {
            opcionDeclarar();
        } catch (IOException e) {
            System.out.println("main: " + e.getMessage());
        }
    }
}

Cómo elegir entre A y B, que es la decisión de diseño real:

Elige... Cuando...
Capturar (A) Este método sabe qué hacer: hay valor por defecto, alternativa o política de degradación
Declarar (B) Este método no sabe qué hacer; la decisión corresponde a alguien con más contexto

La respuesta correcta suele ser B más veces de lo que la gente cree. Un método de servicio profundo casi nunca tiene la información necesaria para decidir si un fallo de lectura debe abortar la aplicación, mostrar un aviso o cargar valores por defecto. Esa decisión pertenece a una capa superior. Capturar por capturar, para "quitarse el error de encima", es el origen del catch vacío.

Un atajo que hay que conocer: main puede declarar throws.

public static void main(String[] args) throws Exception {
    // ...
}

Es perfectamente legal, y significa "si esto falla, que muera el programa y se imprima el trace". Para un ejemplo de aprendizaje o una herramienta pequeña es aceptable. Para una aplicación real es una renuncia: el usuario final ve un stack trace crudo. En 06-07 lo sustituirás por una frontera de errores decente.

  1. Propagación en cadena

Cuando cada método de la cadena elige declarar en lugar de capturar, la excepción sube nivel a nivel hasta el primero que decida hacerse cargo. La declaración throws sube con ella:

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;

public class CadenaDePropagacion {

    // Nivel 4 (el mas profundo): aqui se origina el fallo
    static String leerFichero(String ruta) throws IOException {
        System.out.println("  [4] leerFichero: intentando abrir " + ruta);
        throw new IOException("No existe el fichero: " + ruta);
    }

    // Nivel 3: no sabe que hacer, declara y deja pasar
    static String cargarLineas(String ruta) throws IOException {
        System.out.println(" [3] cargarLineas");
        return leerFichero(ruta);
    }

    // Nivel 2: tampoco sabe, declara y deja pasar
    static int cargarCatalogo(String ruta) throws IOException {
        System.out.println("[2] cargarCatalogo");
        String contenido = cargarLineas(ruta);
        return contenido.length();
    }

    // Nivel 1: AQUI si hay contexto para decidir. Se captura.
    public static void main(String[] args) {
        System.out.println("[1] main: arrancando BiblioTech");
        try {
            int materiales = cargarCatalogo("catalogo.txt");
            System.out.println("Catalogo cargado: " + materiales + " materiales");
        } catch (IOException e) {
            System.out.println("[1] main: no hay catalogo previo (" + e.getMessage() + ")");
            System.out.println("[1] main: se arranca con catalogo vacio. La aplicacion continua.");
        }
    }
}

Salida:

[1] main: arrancando BiblioTech
[2] cargarCatalogo
 [3] cargarLineas
  [4] leerFichero: intentando abrir catalogo.txt
[1] main: no hay catalogo previo (No existe el fichero: catalogo.txt)
[1] main: se arranca con catalogo vacio. La aplicacion continua.

El recorrido, dibujado:

flowchart TB
    M["main<br/>try-catch: DECIDE"]
    C2["cargarCatalogo<br/>throws IOException"]
    C3["cargarLineas<br/>throws IOException"]
    C4["leerFichero<br/>throw new IOException"]

    M -->|"llama"| C2
    C2 -->|"llama"| C3
    C3 -->|"llama"| C4

    C4 -.->|"la excepcion sube"| C3
    C3 -.->|"sube: solo declara"| C2
    C2 -.->|"sube: solo declara"| M
    M -.->|"CAPTURADA: degradacion elegante"| F["Arranca con catalogo vacio"]

Fíjate en el reparto de responsabilidades, que es el patrón que aplicarás en BiblioTech: los niveles 2, 3 y 4 detectan e informan; solo el nivel 1 decide. Y esa decisión —arrancar con catálogo vacío en lugar de morir— solo es posible en main, porque solo ahí se sabe que la aplicación puede funcionar sin catálogo previo. leerFichero no tenía forma de saberlo.

  1. Declarar excepciones no comprobadas en throws

Puedes poner una excepción no comprobada en throws. Es legal pero opcional, y el compilador la ignora por completo: no obliga a nada a quien llama.

// Legal. No obliga al llamador a nada, pero DOCUMENTA.
public double calcular(int diasRetraso) throws IllegalArgumentException {
    if (diasRetraso < 0) {
        throw new IllegalArgumentException("Negativo: " + diasRetraso);
    }
    return diasRetraso * TARIFA_DIARIA;
}

¿Merece la pena? Hay dos escuelas, y el consenso está bastante claro:

Postura Argumento
A favor La firma documenta el contrato completo; algunos IDE lo aprovechan para avisar
En contra (mayoritaria) Ensucia la firma sin aportar comprobación; da falsa sensación de exhaustividad; nadie declara NullPointerException y sin embargo casi todo método puede lanzarla

La práctica recomendada: documenta las no comprobadas con @throws en el Javadoc, no en la firma. El Javadoc es el sitio donde puedes explicar bajo qué condición se lanza, que es la información que realmente importa.

/**
 * Calcula la multa correspondiente a un retraso.
 *
 * @param diasRetraso dias de retraso; debe ser cero o positivo
 * @return el importe en euros, topado a {@value #MULTA_MAXIMA}
 * @throws IllegalArgumentException si {@code diasRetraso} es negativo
 */
public double calcular(int diasRetraso) {
    // ...
}

Esto sí es útil: aparece en la documentación generada, lo muestra el IDE al autocompletar y explica la condición, no solo el tipo. La firma queda limpia.

  1. Validación de argumentos: la caja de herramientas

Llegamos a la deuda del módulo 3. Java tiene un vocabulario estándar para rechazar entradas inválidas, y usar el término correcto importa: quien capture podrá distinguir un problema de otro.

Excepción Cuándo usarla Ejemplo en BiblioTech
NullPointerException Un argumento obligatorio es null new Prestamo(ref, null, empleado, 1)
IllegalArgumentException El argumento no es null pero su valor es inválido anioPublicacion = 1200; diasRetraso = -3
IndexOutOfBoundsException Un índice está fuera de rango Posición inválida en la cola de reservas
IllegalStateException Los argumentos son válidos, pero el objeto no está en un estado que permita la operación Devolver un préstamo ya devuelto
UnsupportedOperationException La operación no está soportada por esta implementación add sobre un catálogo de solo lectura
ArithmeticException Condición aritmética imposible La lanza la JVM en la división entera por cero

La distinción que más cuesta interiorizar es IllegalArgumentException frente a IllegalStateException. La regla:

  • IllegalArgumentException: el problema está en lo que me has pasado. Con otros argumentos, la llamada funcionaría.
  • IllegalStateException: el problema está en cuándo me lo has pedido. Con los mismos argumentos, en otro momento, funcionaría.

Aplicado a Prestamo:

package com.nexussoftware.bibliotech.dominio;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * Prestamo de un material a un empleado.
 *
 * A partir de este modulo, la clase RECHAZA los datos invalidos en lugar de
 * corregirlos con avisos por consola, como hacia en el modulo 3.
 */
public class Prestamo {

    public static final int DIAS_PRESTAMO = 15;

    private final String referencia;          // formato PR-NNNN
    private final Material material;
    private final Empleado titular;
    private final int diaInicio;

    private int diaDevolucion = -1;           // -1 = aun no devuelto
    private final List<Incidencia> incidencias = new ArrayList<>();

    public Prestamo(String referencia, Material material, Empleado titular, int diaInicio) {

        // 1. Nulos -> NullPointerException, con Objects.requireNonNull
        this.referencia = Objects.requireNonNull(referencia, "La referencia no puede ser nula");
        this.material   = Objects.requireNonNull(material,   "El material no puede ser nulo");
        this.titular    = Objects.requireNonNull(titular,    "El titular no puede ser nulo");

        // 2. Formato invalido -> IllegalArgumentException (el argumento esta mal)
        if (!referencia.matches("PR-\\d{4}")) {
            throw new IllegalArgumentException(
                    "La referencia debe tener el formato PR-NNNN, y era: '" + referencia + "'");
        }
        if (diaInicio < 1) {
            throw new IllegalArgumentException(
                    "El dia de inicio debe ser 1 o posterior, y era: " + diaInicio);
        }

        // 3. Estado incompatible -> IllegalStateException (el argumento esta bien,
        //    pero el objeto que me pasas no esta en condiciones)
        if (!material.estaDisponible()) {
            throw new IllegalStateException(
                    "El material " + material.getReferencia() + " no esta disponible");
        }
        if (!titular.puedeTomarPrestado()) {
            throw new IllegalStateException(
                    "El empleado " + titular.getIdentificador() + " ha alcanzado el limite de "
                            + Empleado.MAX_PRESTAMOS_SIMULTANEOS + " prestamos simultaneos");
        }

        this.diaInicio = diaInicio;
        material.prestar();
        titular.registrarPrestamo();
    }

    /**
     * Registra la devolucion.
     *
     * @throws IllegalArgumentException si el dia es anterior al de inicio
     * @throws IllegalStateException    si el prestamo ya estaba devuelto
     */
    public void registrarDevolucion(int dia) {
        // IllegalState: el problema es CUANDO se pide, no QUE se pide
        if (estaDevuelto()) {
            throw new IllegalStateException(
                    "El prestamo " + referencia + " ya se devolvio el dia " + diaDevolucion);
        }
        // IllegalArgument: el problema es el argumento en si
        if (dia < diaInicio) {
            throw new IllegalArgumentException(
                    "El dia de devolucion (" + dia + ") no puede ser anterior al de inicio ("
                            + diaInicio + ")");
        }

        this.diaDevolucion = dia;
        material.devolver();
    }

    public boolean estaDevuelto()      { return diaDevolucion >= 0; }
    public String  getReferencia()     { return referencia; }
    public Material getMaterial()      { return material; }
    public Empleado getTitular()       { return titular; }
    public int      getDiaInicio()     { return diaInicio; }

    public int calcularDiasRetraso(int diaActual) {
        int diaReferencia = estaDevuelto() ? diaDevolucion : diaActual;
        int exceso = diaReferencia - (diaInicio + DIAS_PRESTAMO);
        return Math.max(0, exceso);
    }

    /** Incidencia anotada durante el prestamo (clase anidada estatica, 04-03). */
    public static class Incidencia {
        private final int dia;
        private final String motivo;
        private final Gravedad gravedad;

        public Incidencia(int dia, String motivo, Gravedad gravedad) {
            if (dia < 1) {
                throw new IllegalArgumentException("Dia invalido: " + dia);
            }
            this.dia = dia;
            this.motivo = Objects.requireNonNull(motivo, "El motivo no puede ser nulo");
            this.gravedad = Objects.requireNonNull(gravedad, "La gravedad no puede ser nula");
        }

        @Override
        public String toString() {
            return "Dia " + dia + " [" + gravedad + "]: " + motivo;
        }
    }
}

Compara con la versión del módulo 3:

// VERSION ANTIGUA: crea objetos invalidos y avisa a nadie
public Prestamo(String referencia, Material material, Empleado titular, int diaInicio) {
    if (referencia == null || !referencia.matches("PR-\\d{4}")) {
        System.out.println("AVISO: referencia invalida, se usa PR-0000");
        referencia = "PR-0000";                        // ¡todos los prestamos malos comparten referencia!
    }
    if (diaInicio < 1) {
        System.out.println("AVISO: dia invalido, se usa 1");
        diaInicio = 1;
    }
    this.referencia = referencia;
    // ...
}

El daño de la versión antigua es peor de lo que parece a primera vista: todos los préstamos con referencia inválida acaban compartiendo PR-0000, así que el índice Map<String, Prestamo> del RegistroPrestamos los va sobrescribiendo unos a otros. Se pierden préstamos en silencio. Y el aviso se imprimió hace horas en una consola que nadie guardó.

  1. Objects.requireNonNull y sus variantes

java.util.Objects ofrece la forma canónica de rechazar nulos. Es una línea, devuelve el valor y lanza NullPointerException con tu mensaje si es null:

import java.util.Objects;

// Forma canonica: valida y asigna en la misma linea
this.titular = Objects.requireNonNull(titular, "El titular no puede ser nulo");

Es exactamente equivalente a esto, pero en una línea y sin ruido:

if (titular == null) {
    throw new NullPointerException("El titular no puede ser nulo");
}
this.titular = titular;

Sus variantes útiles:

Método Qué hace
requireNonNull(obj) Lanza NullPointerException sin mensaje
requireNonNull(obj, "mensaje") Con mensaje. Es el que debes usar
requireNonNull(obj, Supplier<String>) Mensaje perezoso: solo se construye si falla
requireNonNullElse(obj, defecto) Devuelve defecto si obj es null; no lanza
requireNonNullElseGet(obj, Supplier) Igual, con el defecto calculado perezosamente
Objects.isNull(obj) / nonNull(obj) Predicados; útiles como referencias a método (04-06)
Objects.equals(a, b) Comparación tolerante a nulos (ya lo viste en 03-09)
Objects.requireNonNullElse vs checkIndex Objects.checkIndex(i, longitud) valida índices y lanza IndexOutOfBoundsException

La variante con Supplier merece una nota, porque tiene una razón de ser concreta:

// Mal: el mensaje se construye SIEMPRE, incluso cuando no falla
Objects.requireNonNull(material, "No se encontro el material " + referencia
        + " en el catalogo de " + sede + " a fecha " + dia);

// Bien: la lambda solo se ejecuta si material es null
Objects.requireNonNull(material, () -> "No se encontro el material " + referencia
        + " en el catalogo de " + sede + " a fecha " + dia);

Con la primera forma, esa concatenación de cuatro trozos ocurre en todas las llamadas, incluso en el 99,99% que no fallan. Con la lambda, solo cuando hace falta. Es el mismo principio de evaluación perezosa que viste en 04-05 con las lambdas, y en un método muy llamado la diferencia es medible.

Un debate frecuente: ¿NullPointerException o IllegalArgumentException para un argumento nulo? Ambas son defendibles, pero la convención dominante en Java —y la que sigue el propio JDK y Objects.requireNonNull— es NullPointerException. La razón práctica: así, un null inesperado produce siempre la misma excepción, tanto si lo detectas tú al validar como si explota más adelante al usarlo. Sé consistente: elige NullPointerException para nulos e IllegalArgumentException para valores no nulos pero inválidos.

Un aviso sobre el orden de validación. Este código tiene un fallo sutil:

// MAL: se usa referencia ANTES de comprobar que no es nula
if (!referencia.matches("PR-\\d{4}")) {                    // NullPointerException si es null
    throw new IllegalArgumentException("Formato invalido");
}
this.referencia = Objects.requireNonNull(referencia, "...");   // nunca llega

Si referencia es null, salta un NullPointerException sin tu mensaje, desde matches. Valida siempre los nulos primero, y después el resto.

  1. Precondiciones y fallar rápido

Una precondición es lo que un método exige para poder cumplir su contrato. calcular(int diasRetraso) exige que diasRetraso >= 0. Prestamo(...) exige que ningún argumento sea nulo, que la referencia tenga formato PR-NNNN y que el material esté disponible.

Fallar rápido (fail-fast) es el principio de comprobar esas precondiciones al principio del método, antes de tocar nada, y rechazar de inmediato si no se cumplen.

flowchart TB
    A["Entra al metodo"] --> B["VALIDAR precondiciones"]
    B --> C{"¿Se cumplen?"}
    C -->|"no"| D["throw: fallo inmediato,<br/>con mensaje y dato culpable"]
    C -->|"si"| E["Ejecutar la logica<br/>sin volver a comprobar"]
    E --> F["Devolver resultado"]

    D --> G["El defecto se detecta<br/>DONDE y CUANDO se produce"]

Las tres ventajas, con nombre:

  1. El error aparece cerca de su causa. Sin validación, un null pasado en el constructor explota veinte minutos después al imprimir un recibo, con un stack trace que apunta a un sitio que no tiene la culpa. Es exactamente el caso que analizaste en el ejercicio 2 de 06-01.
  2. El resto del método puede confiar. Una vez validadas las precondiciones, la lógica se escribe sin defensas repetidas por todas partes.
  3. El objeto nunca existe en estado inválido. Si el constructor lanza, no hay objeto. No hay forma de que un Prestamo sin titular circule por el programa.

Ese tercer punto conecta directamente con las invariantes de 03-07. Una invariante es una condición que se cumple durante toda la vida del objeto. Sin validación en el constructor, la invariante "un préstamo siempre tiene titular" es una aspiración. Con validación, es un hecho garantizado por el lenguaje.

Un idioma cómodo cuando hay muchas precondiciones: métodos privados de validación con nombre descriptivo.

public Libro(String referencia, String titulo, String autor, int anioPublicacion, String isbn) {
    validarReferencia(referencia);
    validarTexto(titulo, "titulo");
    validarTexto(autor, "autor");
    validarAnio(anioPublicacion);
    validarIsbn(isbn);

    // A partir de aqui, todo es valido: la logica queda limpia
    this.referencia = referencia;
    this.titulo = titulo;
    this.autor = autor;
    this.anioPublicacion = anioPublicacion;
    this.isbn = isbn;
}

private static void validarTexto(String valor, String nombreCampo) {
    Objects.requireNonNull(valor, "El " + nombreCampo + " no puede ser nulo");
    if (valor.isBlank()) {
        throw new IllegalArgumentException("El " + nombreCampo + " no puede estar en blanco");
    }
}

private static void validarAnio(int anio) {
    if (anio < 1450 || anio > 2100) {
        throw new IllegalArgumentException(
                "El anio de publicacion debe estar entre 1450 y 2100, y era: " + anio);
    }
}

private static void validarIsbn(String isbn) {
    Objects.requireNonNull(isbn, "El ISBN no puede ser nulo");
    if (!isbn.matches("97[89]-\\d{10}")) {
        throw new IllegalArgumentException(
                "ISBN con formato invalido (se esperaba 978-NNNNNNNNNN): " + isbn);
    }
}

Una advertencia sobre el alcance de esta técnica: fallar rápido es para los errores de programación y para los datos que llegan de dentro del sistema. Para los datos que teclea un usuario, ya viste en 06-02 que la política correcta es distinta: validar y reintentar con un mensaje amable, no lanzar una excepción en su cara. La frontera entre ambas políticas es la capa de presentación: fuera de ella se lanza, dentro de ella se pregunta.

  1. Encadenamiento de excepciones: conservar la causa

Cuando envuelves una excepción en otra, tienes que pasar la original como causa. Hay dos formas, y una de ellas es claramente preferible.

Forma preferida: el constructor con causa.

try {
    int anio = Integer.parseInt(campos[2]);
} catch (NumberFormatException e) {
    throw new IllegalArgumentException("Linea de catalogo mal formada: " + linea, e);
    //                                                                          ^^^
    //                                                                       la causa
}

Throwable define cuatro constructores, y todas las excepciones estándar los ofrecen:

Constructor Uso
X() Sin mensaje ni causa
X(String mensaje) Con mensaje. El más frecuente al originar un fallo
X(String mensaje, Throwable causa) Al envolver. El que debes usar al traducir
X(Throwable causa) Mensaje derivado de causa.toString(). Poco recomendable: pierdes la oportunidad de explicar

Forma alternativa: initCause. Existe para las excepciones antiguas que no ofrecen el constructor con causa:

IllegalArgumentException fallo = new IllegalArgumentException("Linea mal formada: " + linea);
fallo.initCause(e);                 // solo se puede llamar UNA VEZ, y solo si no habia causa
throw fallo;

initCause tiene dos limitaciones que la hacen incómoda: solo puede llamarse una vez por objeto, y falla con IllegalStateException si la causa ya se estableció (incluso si se estableció a null mediante el constructor de dos argumentos). Úsala solo cuando no tengas alternativa.

Un ejemplo completo con las tres capas de BiblioTech:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;

/**
 * Demuestra el encadenamiento de excepciones a traves de tres capas.
 */
public class CargadorCatalogo {

    /** CAPA DE DATOS: convierte una linea de texto en un Material. */
    private Material parsearLinea(String linea) {
        String[] campos = linea.split(";");
        return new Libro(campos[0].trim(), campos[1].trim(), campos[2].trim(),
                Integer.parseInt(campos[3].trim()), campos[4].trim());
        // Puede lanzar: ArrayIndexOutOfBoundsException, NumberFormatException,
        //               IllegalArgumentException (validaciones de Libro)
    }

    /** CAPA DE SERVICIO: traduce al vocabulario del dominio, CONSERVANDO la causa. */
    public Material cargarMaterial(String linea, int numeroLinea) {
        try {
            return parsearLinea(linea);
        } catch (RuntimeException e) {
            throw new IllegalStateException(
                    "No se pudo cargar el material de la linea " + numeroLinea
                            + " del catalogo: '" + linea + "'", e);     // <-- CAUSA CONSERVADA
        }
    }

    public static void main(String[] args) {
        CargadorCatalogo cargador = new CargadorCatalogo();
        cargador.cargarMaterial("LIB-0001;Java Efectivo;Bloch;mil;978-0000000001", 7);
    }
}

El stack trace resultante:

Exception in thread "main" java.lang.IllegalStateException: No se pudo cargar el material de la linea 7 del catalogo: 'LIB-0001;Java Efectivo;Bloch;mil;978-0000000001'
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.cargarMaterial(CargadorCatalogo.java:24)
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.main(CargadorCatalogo.java:32)
Caused by: java.lang.NumberFormatException: For input string: "mil"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at java.base/java.lang.Integer.parseInt(Integer.java:781)
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.parsearLinea(CargadorCatalogo.java:16)
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.cargarMaterial(CargadorCatalogo.java:22)
	... 1 more

Este volcado contiene las dos historias completas: qué operación de negocio falló y con qué dato (bloque 1), y cuál fue el fallo técnico concreto y en qué línea del código (el Caused by:). Es exactamente lo que necesitas a las tres de la mañana.

  1. Qué se pierde exactamente si no conservas la causa

Vale la pena verlo con los dos volcados uno junto al otro, porque la diferencia se subestima hasta que te toca depurar sin ella.

// VERSION QUE PIERDE LA CAUSA
public Material cargarMaterialMal(String linea, int numeroLinea) {
    try {
        return parsearLinea(linea);
    } catch (RuntimeException e) {
        throw new IllegalStateException("No se pudo cargar el material de la linea " + numeroLinea);
        //                                                          ^ falta el ", e"
    }
}

Su stack trace:

Exception in thread "main" java.lang.IllegalStateException: No se pudo cargar el material de la linea 7
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.cargarMaterialMal(CargadorCatalogo.java:30)
	at com.nexussoftware.bibliotech.servicio.CargadorCatalogo.main(CargadorCatalogo.java:38)

Y esto es todo. Compara qué información ha desaparecido:

Información Con causa Sin causa
Qué operación de negocio falló
Qué fallo técnico ocurrió NumberFormatException Perdido
Qué dato concreto lo provocó "mil" Perdido
En qué línea del código ocurrió parsearLinea, línea 16 Perdido
Toda la pila del punto real del fallo Completa Perdida

Con el segundo volcado, un desarrollador tiene que adivinar. ¿Faltaba un campo? ¿El año no era numérico? ¿El ISBN tenía formato inválido? ¿El título estaba en blanco? Las cinco causas posibles producen exactamente el mismo mensaje. Y como el fichero de entrada probablemente ya no exista, ni siquiera puede reproducirlo.

Esa información no se recupera. Una vez que el objeto excepción original se descarta sin referenciarlo, el recolector de basura se lo lleva con toda su pila dentro.

Regla sin excepciones: si capturas para envolver, pasa siempre la causa. El coste es cinco caracteres.

Un caso especial en el que sí es correcto no pasar la causa: cuando la excepción original contiene información sensible que no debe salir de esa capa —una contraseña en el mensaje de un error de conexión, por ejemplo—. En ese caso, registra la original con todo su detalle en el log interno (06-07) y lanza una nueva sin causa. Es la única excepción legítima a la regla, y es deliberada, no un olvido.

  1. Relanzar: throw e frente a envolver

Dos operaciones distintas que a veces se confunden:

// A) RELANZAR: la misma excepcion, intacta
catch (RuntimeException e) {
    System.err.println("Contexto: procesando " + referencia);
    throw e;                   // mismo objeto, misma pila, mismo mensaje
}

// B) ENVOLVER: una excepcion NUEVA que lleva dentro la original
catch (RuntimeException e) {
    throw new IllegalStateException("Fallo al procesar " + referencia, e);
}
Relanzar (throw e) Envolver
Tipo que ve el llamador El original El nuevo
Stack trace Intacto: no se añaden marcos Nuevo trace, con Caused by:
Cuándo usarlo Solo querías añadir contexto o registrar Necesitas cambiar el vocabulario o el tipo
Riesgo Filtras el tipo de una capa inferior Ninguno, si conservas la causa

Un matiz técnico sobre throw e: no reinicia el stack trace. El trace se capturó al construir el objeto y no se toca al relanzar, así que la línea original del fallo se conserva. Si quisieras reiniciarlo —cosa que casi nunca conviene— tendrías que llamar explícitamente a fillInStackTrace().

Y una advertencia sobre relanzar por costumbre:

// Casi siempre innecesario
try {
    hacerAlgo();
} catch (RuntimeException e) {
    throw e;                   // no aporta NADA: sin el try, el resultado seria identico
}

Un catch que solo relanza sin añadir contexto, sin registrar y sin liberar nada es ruido puro. Bórralo.

  1. Traducción de excepciones entre capas

Este es el uso profesional del encadenamiento, y conecta directamente con la abstracción de 03-08.

El principio: una capa no debe filtrar las excepciones de su implementación interna. Si Catalogo guarda los materiales en un HashMap hoy y en una base de datos mañana, quien lo usa no debería enterarse. Pero si Catalogo deja escapar una SQLException, la capa superior queda acoplada a la base de datos: tendrá catch (SQLException e) por todas partes, y el día que cambies a fichero, ese código dejará de compilar.

flowchart TB
    subgraph P["Capa de PRESENTACION"]
        P1["MenuBiblioTech<br/>Ve: excepciones del dominio<br/>Muestra: mensajes al usuario"]
    end
    subgraph S["Capa de SERVICIO"]
        S1["GestorPrestamos, Catalogo<br/>Ve: excepciones de datos<br/>Traduce a excepciones del dominio"]
    end
    subgraph D["Capa de DATOS"]
        D1["Ficheros, base de datos<br/>Lanza: IOException, SQLException"]
    end

    D1 -->|"IOException"| S1
    S1 -->|"MaterialNoEncontradoException<br/>envolviendo la causa"| P1
    P1 -->|"'No se encontro el material LIB-0001'"| U["Usuario"]

En código:

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import com.nexussoftware.bibliotech.dominio.Material;

public class Catalogo {

    private final Map<String, Material> indicePorReferencia = new HashMap<>();

    /**
     * MAL: filtra el detalle de implementacion.
     * El dia que cambiemos de fichero a base de datos, todo el codigo
     * que llame a este metodo tendra que cambiar su catch.
     */
    public void cargarDesdeFicheroMal(String ruta) throws IOException {
        // ...
    }

    /**
     * BIEN: traduce al vocabulario del dominio, conservando la causa.
     * Quien llama solo necesita saber que "el catalogo no se pudo cargar";
     * el detalle tecnico sigue disponible en el Caused by: para diagnosticar.
     */
    public void cargarDesdeFichero(String ruta) {
        try {
            leerFisicamente(ruta);                       // detalle de implementacion
        } catch (IOException e) {
            throw new IllegalStateException(
                    "No se pudo cargar el catalogo desde '" + ruta + "'", e);
        }
    }

    private void leerFisicamente(String ruta) throws IOException {
        throw new IOException("Permiso denegado: " + ruta);   // simulado; el modulo 7 lo hara de verdad
    }
}

Y la regla que gobierna esta técnica:

Cada capa lanza excepciones del vocabulario de su propio nivel de abstracción, y conserva como causa las de los niveles inferiores.

Es el mismo principio que aplican los frameworks profesionales. Spring convierte cada SQLException en su jerarquía DataAccessException —con clases como DuplicateKeyException o DataIntegrityViolationException— precisamente para que tu código de negocio no dependa del motor de base de datos. En 06-04 construirás la jerarquía equivalente para BiblioTech.

  1. El análisis preciso de relanzamiento de Java 7

Una nota técnica breve pero útil. Antes de Java 7, este código no compilaba:

public void procesar() throws IOException, java.sql.SQLException {
    try {
        operacionQueLanzaAmbas();
    } catch (Exception e) {
        registrar(e);
        throw e;                 // Java 6: error, "unreported exception Exception"
    }
}

El compilador antiguo razonaba de forma tosca: el tipo declarado de e es Exception, luego throw e puede lanzar cualquier Exception, luego el método debe declarar throws Exception.

Desde Java 7, el compilador hace análisis preciso de relanzamiento (more precise rethrow): analiza qué excepciones puede lanzar realmente el try y deduce que e solo puede ser IOException o SQLException. El código anterior compila sin cambios.

La condición para que funcione: el parámetro del catch no debe reasignarse. Si lo reasignas, el compilador vuelve al análisis conservador. Por eso conviene declararlo final explícitamente cuando dependes de esta característica:

} catch (final Exception e) {     // el final documenta la intencion
    registrar(e);
    throw e;                      // el compilador deduce IOException | SQLException
}

Recuerda del apartado 5 de 06-02 que en un multi-catch el parámetro es final implícitamente, sin que tengas que escribirlo.

  1. throws y sobrescritura de métodos

Una regla de herencia que conecta directamente con el polimorfismo de 03-05 y 03-06.

Un método sobrescrito no puede declarar excepciones comprobadas más amplias que el método de la superclase.

La razón es el principio de sustitución: si tienes una referencia de tipo Material y llamas a prestar(), el compilador solo sabe manejar lo que Material.prestar() declara. Si una subclase pudiera lanzar algo más, se colaría una excepción comprobada sin que nadie la declarase, y el sistema de comprobación se rompería.

package com.nexussoftware.bibliotech.dominio;

import java.io.IOException;
import java.io.FileNotFoundException;

class ReglasDeSobrescritura {

    static class MaterialBase {
        /** Metodo base: declara IOException. */
        public void exportar(String ruta) throws IOException {
            System.out.println("Exportando " + ruta);
        }
    }

    // A) Declarar la MISMA excepcion: LEGAL
    static class LibroA extends MaterialBase {
        @Override
        public void exportar(String ruta) throws IOException { }
    }

    // B) Declarar una SUBCLASE: LEGAL (es mas restrictivo, mas seguro)
    static class LibroB extends MaterialBase {
        @Override
        public void exportar(String ruta) throws FileNotFoundException { }
    }

    // C) NO declarar ninguna: LEGAL (el maximo de restrictivo)
    static class LibroC extends MaterialBase {
        @Override
        public void exportar(String ruta) { }
    }

    // D) Declarar una SUPERCLASE: ILEGAL
    // static class LibroD extends MaterialBase {
    //     @Override
    //     public void exportar(String ruta) throws Exception { }
    //     // error: exportar(String) in LibroD cannot override exportar(String) in MaterialBase
    //     //        overridden method does not throw Exception
    // }

    // E) Declarar una excepcion NO RELACIONADA: ILEGAL si es comprobada
    // static class LibroE extends MaterialBase {
    //     @Override
    //     public void exportar(String ruta) throws java.sql.SQLException { }
    //     // error: overridden method does not throw SQLException
    // }

    // F) Anadir cualquier NO COMPROBADA: LEGAL siempre (el compilador no las controla)
    static class LibroF extends MaterialBase {
        @Override
        public void exportar(String ruta) throws IllegalStateException {
            throw new IllegalStateException("El material no tiene contenido exportable");
        }
    }
}

Resumen de la regla:

Caso ¿Legal? Motivo
Misma excepción Contrato idéntico
Subclase de la declarada Más restrictivo: nunca sorprende al llamador
Ninguna Lo máximo de restrictivo
Superclase de la declarada No Más amplio: el llamador no la esperaba
Comprobada no relacionada No Idem
Cualquier no comprobada El compilador no las controla en ningún caso

Por qué la sustitución se rompería con el caso D:

MaterialBase material = new LibroD();          // polimorfismo (03-06)
try {
    material.exportar("catalogo.txt");         // el compilador solo ve MaterialBase.exportar
} catch (IOException e) {
    // Aqui solo se puede capturar IOException, porque es lo unico declarado.
    // Si LibroD pudiera lanzar Exception, escaparia una comprobada sin capturar
    // ni declarar: el sistema de comprobadas quedaria roto.
}

Un corolario práctico: cuando diseñes una interfaz o una clase abstracta (04-01, 04-02), piensa bien qué throws pones, porque estás fijando el máximo para todas las implementaciones presentes y futuras. Es una de las razones de peso para preferir excepciones no comprobadas en las interfaces de dominio: no restringen a los implementadores. Es exactamente lo que hará Prestable en BiblioTech.

  1. BiblioTech: validaciones que lanzan y búsquedas que no devuelven null

Cerramos con las dos deudas saldadas. Primero, Catalogo, que deja de mentir con null y false:

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;

/**
 * Catalogo de BiblioTech, version del modulo 6.
 *
 * Cambios respecto al modulo 5:
 *  - registrar() ya no devuelve un boolean mudo: lanza indicando el MOTIVO.
 *  - buscarPorReferencia() ya no devuelve null: hay dos metodos con contratos
 *    explicitos, uno tolerante y otro exigente.
 *
 * Nota: aqui se usan todavia excepciones estandar. En 06-04 se sustituyen por
 * la jerarquia propia de BiblioTech, que ademas transportara datos del error.
 */
public class Catalogo {

    private final List<Material> materiales = new ArrayList<>();
    private final Map<String, Material> indicePorReferencia = new HashMap<>();
    private final Set<String> isbnRegistrados = new HashSet<>();

    /**
     * Registra un material en el catalogo.
     *
     * @param material material no nulo y con referencia no registrada
     * @throws NullPointerException     si el material es nulo
     * @throws IllegalArgumentException si la referencia o el ISBN ya existen
     */
    public void registrar(Material material) {
        Objects.requireNonNull(material, "El material a registrar no puede ser nulo");

        String referencia = material.getReferencia();
        if (indicePorReferencia.containsKey(referencia)) {
            throw new IllegalArgumentException(
                    "Ya existe un material con la referencia " + referencia + ": '"
                            + indicePorReferencia.get(referencia).getTitulo() + "'");
        }

        if (material instanceof Libro libro) {
            String isbn = libro.getIsbn();
            if (isbnRegistrados.contains(isbn)) {
                throw new IllegalArgumentException(
                        "El ISBN " + isbn + " ya esta catalogado (referencia "
                                + referencia + ")");
            }
            isbnRegistrados.add(isbn);
        }

        materiales.add(material);
        indicePorReferencia.put(referencia, material);
    }

    /**
     * Busca un material EXIGIENDO que exista.
     *
     * Es la version que se usa cuando la ausencia es un error: prestar,
     * devolver, consultar la ficha de algo que el usuario dice tener.
     *
     * @throws NoSuchElementException si no existe ningun material con esa referencia
     */
    public Material obtenerPorReferencia(String referencia) {
        Objects.requireNonNull(referencia, "La referencia no puede ser nula");

        Material encontrado = indicePorReferencia.get(referencia);
        if (encontrado == null) {
            throw new NoSuchElementException(
                    "No existe ningun material con la referencia '" + referencia
                            + "'. El catalogo tiene " + materiales.size() + " materiales.");
        }
        return encontrado;                       // NUNCA null: garantizado por el contrato
    }

    /**
     * Busca un material ADMITIENDO que no exista.
     *
     * Es la version para cuando la ausencia es un resultado normal: comprobar
     * si una referencia esta libre antes de darla de alta.
     *
     * Devuelve Optional en lugar de null: quien llame no puede ignorar el caso
     * "no encontrado" por descuido. Optional se desarrolla en 10-04.
     */
    public Optional<Material> buscarPorReferencia(String referencia) {
        if (referencia == null) {
            return Optional.empty();
        }
        return Optional.ofNullable(indicePorReferencia.get(referencia));
    }

    public boolean existe(String referencia) {
        return referencia != null && indicePorReferencia.containsKey(referencia);
    }

    public int tamano() {
        return materiales.size();
    }

    public List<Material> listar() {
        return List.copyOf(materiales);          // copia inmutable (03-07)
    }
}

La decisión clave está en tener dos métodos con dos contratos en lugar de uno ambiguo:

Método Contrato Cuándo usarlo
obtenerPorReferencia Nunca devuelve null; lanza si no existe La ausencia es un error: prestar, devolver
buscarPorReferencia Devuelve Optional, nunca null La ausencia es normal: comprobar disponibilidad

Es un patrón que verás por todo el JDK y por todos los frameworks: la pareja get/find, require/optional, obtener/buscar. Lo que desaparece para siempre es el null como valor de retorno.

Y ahora el uso conjunto, con Empleado validando también sus invariantes:

package com.nexussoftware.bibliotech.dominio;

import java.util.Objects;

/** Empleado de Nexus Software con derecho a prestamo. */
public class Empleado {

    public static final int MAX_PRESTAMOS_SIMULTANEOS = 3;

    private final String nombre;
    private final String identificador;          // formato EMP-NNN
    private int prestamosAcumulados;

    public Empleado(String nombre, String identificador) {
        this.nombre = Objects.requireNonNull(nombre, "El nombre no puede ser nulo");
        this.identificador = Objects.requireNonNull(identificador,
                "El identificador no puede ser nulo");

        if (nombre.isBlank()) {
            throw new IllegalArgumentException("El nombre no puede estar en blanco");
        }
        if (!identificador.matches("EMP-\\d{3}")) {
            throw new IllegalArgumentException(
                    "El identificador debe tener el formato EMP-NNN, y era: '"
                            + identificador + "'");
        }
        this.prestamosAcumulados = 0;
    }

    public boolean puedeTomarPrestado() {
        return prestamosAcumulados < MAX_PRESTAMOS_SIMULTANEOS;
    }

    /**
     * Anota un prestamo mas.
     *
     * @throws IllegalStateException si ya se alcanzo el limite. Es IllegalState
     *         y no IllegalArgument porque el problema no es lo que se pasa
     *         (no hay argumentos), sino el ESTADO del empleado.
     */
    public void registrarPrestamo() {
        if (!puedeTomarPrestado()) {
            throw new IllegalStateException(
                    "El empleado " + identificador + " (" + nombre + ") ya tiene "
                            + prestamosAcumulados + " prestamos, el maximo es "
                            + MAX_PRESTAMOS_SIMULTANEOS);
        }
        prestamosAcumulados++;
    }

    /**
     * Anota una devolucion.
     *
     * @throws IllegalStateException si no hay prestamos que devolver.
     *         Esta comprobacion protege la INVARIANTE "prestamosAcumulados >= 0",
     *         cuya violacion fue exactamente la causa raiz del stack trace
     *         que analizaste en el ejercicio 2 de 06-01.
     */
    public void registrarDevolucion() {
        if (prestamosAcumulados <= 0) {
            throw new IllegalStateException(
                    "El empleado " + identificador + " no tiene prestamos que devolver");
        }
        prestamosAcumulados--;
    }

    public String getNombre()          { return nombre; }
    public String getIdentificador()   { return identificador; }
    public int getPrestamosAcumulados(){ return prestamosAcumulados; }

    @Override
    public String toString() {
        return identificador + " (" + nombre + "): " + prestamosAcumulados + "/"
                + MAX_PRESTAMOS_SIMULTANEOS + " prestamos";
    }
}

Y una demostración de todo junto:

package com.nexussoftware.bibliotech.presentacion;

import java.util.NoSuchElementException;

import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.servicio.Catalogo;

public class DemoValidaciones {

    public static void main(String[] args) {
        Catalogo catalogo = new Catalogo();

        // --- Registro correcto ---
        catalogo.registrar(new Libro("LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001"));
        catalogo.registrar(new Libro("LIB-0002", "Patrones de Diseno", "GoF", 1994, "978-0000000002"));
        System.out.println("Catalogo: " + catalogo.tamano() + " materiales");

        // --- Referencia duplicada ---
        intentar("referencia duplicada", () ->
                catalogo.registrar(new Libro("LIB-0001", "Otro", "Otro", 2020, "978-0000000009")));

        // --- ISBN duplicado ---
        intentar("ISBN duplicado", () ->
                catalogo.registrar(new Libro("LIB-0003", "Copia", "Bloch", 2018, "978-0000000001")));

        // --- Material nulo ---
        intentar("material nulo", () -> catalogo.registrar(null));

        // --- Anio invalido en el constructor de Libro ---
        intentar("anio invalido", () ->
                new Libro("LIB-0004", "Manuscrito", "Anonimo", 1200, "978-0000000004"));

        // --- Busqueda exigente de algo que no existe ---
        intentar("referencia inexistente", () -> catalogo.obtenerPorReferencia("LIB-9999"));

        // --- Busqueda tolerante: sin excepcion, sin null ---
        System.out.println("\nBusqueda tolerante de LIB-9999: "
                + catalogo.buscarPorReferencia("LIB-9999").isPresent());
        System.out.println("Busqueda tolerante de LIB-0001: "
                + catalogo.buscarPorReferencia("LIB-0001")
                          .map(m -> m.getTitulo())
                          .orElse("(no encontrado)"));

        // --- Limite de prestamos del empleado ---
        Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
        for (int i = 1; i <= 3; i++) { marta.registrarPrestamo(); }
        System.out.println("\n" + marta);
        intentar("cuarto prestamo", marta::registrarPrestamo);

        // --- Identificador con formato invalido ---
        intentar("identificador invalido", () -> new Empleado("Diego Alonso", "diego"));
    }

    /** Ejecuta una accion y muestra el fallo de forma legible. */
    private static void intentar(String descripcion, Runnable accion) {
        try {
            accion.run();
            System.out.println("[" + descripcion + "] -> no fallo (inesperado)");
        } catch (NullPointerException | IllegalArgumentException
                 | IllegalStateException | NoSuchElementException e) {
            System.out.println("[" + descripcion + "] -> "
                    + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

Salida:

Catalogo: 2 materiales
[referencia duplicada] -> IllegalArgumentException: Ya existe un material con la referencia LIB-0001: 'Java Efectivo'
[ISBN duplicado] -> IllegalArgumentException: El ISBN 978-0000000001 ya esta catalogado (referencia LIB-0003)
[material nulo] -> NullPointerException: El material a registrar no puede ser nulo
[anio invalido] -> IllegalArgumentException: El anio de publicacion debe estar entre 1450 y 2100, y era: 1200
[referencia inexistente] -> NoSuchElementException: No existe ningun material con la referencia 'LIB-9999'. El catalogo tiene 2 materiales.

Busqueda tolerante de LIB-9999: false
Busqueda tolerante de LIB-0001: Java Efectivo

EMP-001 (Marta Ruiz): 3/3 prestamos
[cuarto prestamo] -> IllegalStateException: El empleado EMP-001 (Marta Ruiz) ya tiene 3 prestamos, el maximo es 3
[identificador invalido] -> IllegalArgumentException: El identificador debe tener el formato EMP-NNN, y era: 'diego'

Compara con lo que producía el módulo 5: siete false y siete null indistinguibles. Ahora cada fallo tiene nombre, motivo y el dato culpable.

Queda una molestia visible en la demostración: ese catch con cuatro tipos estándar unidos por |. Es feo, y es un síntoma. IllegalArgumentException significa cosas muy distintas según el caso —referencia duplicada, ISBN duplicado, año fuera de rango— y quien captura no puede distinguirlas sin leer el mensaje, que es texto libre y frágil. Además, ninguna de estas excepciones transporta datos: para saber qué referencia estaba duplicada hay que parsear una cadena.

La respuesta es la lección siguiente.

Errores Comunes y Consejos

Confundir throw con throws. throw lanza, en el cuerpo; throws declara, en la firma. Una letra de diferencia, papeles opuestos.

Corregir en silencio en lugar de rechazar. Sustituir un año inválido por 0 o una referencia inválida por PR-0000 produce objetos que existen y están mal, y en BiblioTech provocaba que los préstamos se sobrescribieran unos a otros en el índice. Rechaza.

Validar el formato antes de comprobar el null. referencia.matches(...) con referencia a null lanza un NullPointerException sin tu mensaje. Los nulos, primero.

Olvidar la causa al envolver. Es el error más caro de este módulo. Pierdes el tipo del fallo real, el dato culpable, la línea exacta y toda la pila. Y no se recupera. El coste de evitarlo son cinco caracteres: , e.

Usar IllegalArgumentException para todo. Distingue: nulo → NullPointerException; valor inválido → IllegalArgumentException; momento inadecuado → IllegalStateException. Quien capture podrá reaccionar de forma distinta.

Declarar throws Exception en una interfaz. Fija el máximo para todas las implementaciones y obliga a los llamadores a capturar el tipo más ancho posible. Declara el tipo más específico, o ninguno.

Poner throws de no comprobadas en la firma. Legal, pero no aporta comprobación y ensucia. Documenta con @throws en el Javadoc, donde además puedes explicar la condición.

main(String[] args) throws Exception en una aplicación real. El usuario final recibe un stack trace crudo. Aceptable en un ejemplo o una herramienta interna; en producción necesitas una frontera de errores (06-07).

Relanzar sin añadir nada. Un catch que solo hace throw e; es ruido: sin el try, el resultado sería idéntico. Bórralo.

Devolver null cuando algo no se encuentra. Retrasa el fallo hasta un punto lejano del programa y lo hace irreconocible. Ofrece dos métodos: uno que lanza y otro que devuelve Optional.

Consejo: escribe el mensaje pensando en quien lo leerá dentro de seis meses a las tres de la mañana. Qué pasó, con qué dato y qué se esperaba. "Anio fuera de rango [1450..2100]: 1200" cuesta lo mismo que "Anio invalido".

Consejo: no incluyas datos sensibles en los mensajes. Contraseñas, tokens, DNI. El mensaje acabará en un log, en un correo automático y quizá en la pantalla de alguien. En 06-07 hay una advertencia específica sobre esto.

Consejo: extrae las validaciones largas a métodos privados con nombre. validarIsbn(isbn) documenta mejor que ocho líneas de if dentro del constructor, y se reutiliza en los setter.

Consejo: Objects.requireNonNull con el mensaje siempre. La versión sin mensaje lanza un NullPointerException que no dice qué era nulo, justo la información que necesitas.

Ejercicios

Ejercicio 1: Libro con validación completa

Reescribe la clase Libro de BiblioTech eliminando por completo los avisos por consola del módulo 3 y sustituyéndolos por excepciones. Requisitos:

  • Campos: referencia (LIB-NNNN), titulo, autor, anioPublicacion, isbn (978-NNNNNNNNNN o 979-...), y el estado disponible.
  • NulosNullPointerException con Objects.requireNonNull y mensaje que identifique el campo.
  • Textos en blanco, formatos incorrectos y año fuera de [1450, 2100]IllegalArgumentException con el valor recibido en el mensaje.
  • Métodos prestar() y devolver(): prestar() sobre un material ya prestado y devolver() sobre uno disponible → IllegalStateException.
  • Extrae cada validación a un método privado estático con nombre descriptivo.
  • Añade Javadoc con @throws en el constructor y en los dos métodos de estado.
  • Escribe un main que demuestre cada una de las validaciones fallando, y una construcción correcta.

Reflexiona al final en un comentario: ¿por qué prestar() sobre un material ya prestado es IllegalStateException y no IllegalArgumentException?

Ejercicio 2: encadenamiento y pérdida de información

Escribe DemoEncadenamiento con dos versiones del mismo método de servicio, cargarFichaBien y cargarFichaMal, que parseen una línea titulo;autor;anio y construyan un record Ficha(String titulo, String autor, int anio):

  • La versión buena envuelve cualquier RuntimeException en un IllegalStateException conservando la causa.
  • La versión mala hace lo mismo sin la causa.

En el main:

  1. Llama a ambas con la línea "Java Efectivo;Bloch;dos mil dieciocho", captura la excepción y muestra los dos stack traces completos, uno tras otro, con un separador.
  2. Escribe un método analizar(Throwable t) que recorra la cadena de causas e informe de: número de eslabones, clase de la causa raíz, mensaje de la causa raíz y primera línea de código propio de la causa raíz.
  3. Aplícalo a ambas excepciones y muestra, en una tabla impresa por consola, qué información está disponible en cada caso.
  4. Añade un tercer caso con la línea "Solo un campo" para comprobar que también se conserva la ArrayIndexOutOfBoundsException.

Ejercicio 3: GestorPrestamos con propagación por capas

Escribe GestorPrestamos en com.nexussoftware.bibliotech.servicio con el método Prestamo prestar(String referenciaMaterial, String idEmpleado, int dia), que orqueste Catalogo, un registro de empleados y RegistroPrestamos.

Requisitos:

  • No captura nada de lo que no sepa manejar. Las excepciones de Catalogo y Empleado suben tal cual, salvo cuando haya que traducirlas.
  • Valida sus propios argumentos con fallo rápido: referencias no nulas ni en blanco, dia >= 1.
  • Genera la referencia del préstamo con el formato PR-NNNN a partir de un contador.
  • Si el material existe pero no está disponible, lanza IllegalStateException con un mensaje que incluya la referencia y el título.
  • Si el empleado ha alcanzado el límite, deja que suba la IllegalStateException de Empleado, pero envuélvela en un IllegalStateException que añada el contexto de la operación (qué material se intentaba prestar), conservando la causa.
  • Añade void devolver(String referenciaPrestamo, int dia) que lance NoSuchElementException si la referencia no existe, y deje subir la IllegalStateException de Prestamo si ya estaba devuelto.

En el main, monta un escenario con los tres empleados y los tres libros del proyecto, y provoca al menos cinco fallos distintos, mostrando para cada uno la clase, el mensaje y —si la hay— la causa.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

import java.util.Objects;

/**
 * Libro del catalogo de BiblioTech.
 *
 * Version del modulo 6: RECHAZA los datos invalidos en lugar de corregirlos.
 * Toda instancia que exista cumple sus invariantes por construccion.
 */
public class Libro {

    private static final int ANIO_MIN = 1450;    // Gutenberg
    private static final int ANIO_MAX = 2100;

    private final String referencia;
    private final String titulo;
    private final String autor;
    private final int anioPublicacion;
    private final String isbn;

    private boolean disponible = true;

    /**
     * Crea un libro validando todas sus invariantes.
     *
     * @param referencia      referencia del catalogo, formato LIB-NNNN
     * @param titulo          titulo no vacio
     * @param autor           autor no vacio
     * @param anioPublicacion anio entre 1450 y 2100
     * @param isbn            ISBN-13, formato 978-NNNNNNNNNN o 979-NNNNNNNNNN
     * @throws NullPointerException     si cualquier argumento de texto es nulo
     * @throws IllegalArgumentException si algun valor no cumple su formato o rango
     */
    public Libro(String referencia, String titulo, String autor,
                 int anioPublicacion, String isbn) {

        // 1. Nulos primero: si no, los matches() de abajo lanzarian un NPE
        //    sin mensaje util.
        Objects.requireNonNull(referencia, "La referencia no puede ser nula");
        Objects.requireNonNull(titulo,     "El titulo no puede ser nulo");
        Objects.requireNonNull(autor,      "El autor no puede ser nulo");
        Objects.requireNonNull(isbn,       "El ISBN no puede ser nulo");

        // 2. Formatos y rangos
        validarReferencia(referencia);
        validarTextoNoVacio(titulo, "titulo");
        validarTextoNoVacio(autor, "autor");
        validarAnio(anioPublicacion);
        validarIsbn(isbn);

        // 3. A partir de aqui todo es valido: asignacion limpia
        this.referencia = referencia;
        this.titulo = titulo.trim();
        this.autor = autor.trim();
        this.anioPublicacion = anioPublicacion;
        this.isbn = isbn;
    }

    // ---------- Validaciones ----------

    private static void validarReferencia(String referencia) {
        if (!referencia.matches("LIB-\\d{4}")) {
            throw new IllegalArgumentException(
                    "La referencia debe tener el formato LIB-NNNN, y era: '" + referencia + "'");
        }
    }

    private static void validarTextoNoVacio(String valor, String nombreCampo) {
        if (valor.isBlank()) {
            throw new IllegalArgumentException(
                    "El campo '" + nombreCampo + "' no puede estar vacio ni contener solo espacios");
        }
    }

    private static void validarAnio(int anio) {
        if (anio < ANIO_MIN || anio > ANIO_MAX) {
            throw new IllegalArgumentException(
                    "El anio de publicacion debe estar entre " + ANIO_MIN + " y " + ANIO_MAX
                            + ", y era: " + anio);
        }
    }

    private static void validarIsbn(String isbn) {
        if (!isbn.matches("97[89]-\\d{10}")) {
            throw new IllegalArgumentException(
                    "El ISBN debe tener el formato 978-NNNNNNNNNN o 979-NNNNNNNNNN, y era: '"
                            + isbn + "'");
        }
    }

    // ---------- Estado del prestamo ----------

    /**
     * Marca el libro como prestado.
     *
     * @throws IllegalStateException si ya estaba prestado
     */
    public void prestar() {
        if (!disponible) {
            throw new IllegalStateException(
                    "El libro " + referencia + " ('" + titulo + "') ya esta prestado");
        }
        disponible = false;
    }

    /**
     * Marca el libro como devuelto.
     *
     * @throws IllegalStateException si no estaba prestado
     */
    public void devolver() {
        if (disponible) {
            throw new IllegalStateException(
                    "El libro " + referencia + " ('" + titulo + "') no esta prestado");
        }
        disponible = true;
    }

    // ---------- Accesores ----------

    public String getReferencia()   { return referencia; }
    public String getTitulo()       { return titulo; }
    public String getAutor()        { return autor; }
    public int getAnioPublicacion() { return anioPublicacion; }
    public String getIsbn()         { return isbn; }
    public boolean estaDisponible() { return disponible; }

    @Override
    public String toString() {
        return referencia + " - '" + titulo + "' (" + autor + ", " + anioPublicacion + ") "
                + (disponible ? "[disponible]" : "[prestado]");
    }

    // ---------- Demostracion ----------

    public static void main(String[] args) {
        System.out.println("=== Construccion correcta ===");
        Libro valido = new Libro("LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001");
        System.out.println(valido);

        System.out.println("\n=== Validaciones que fallan ===");
        probar("referencia nula",
                () -> new Libro(null, "T", "A", 2000, "978-0000000001"));
        probar("titulo nulo",
                () -> new Libro("LIB-0002", null, "A", 2000, "978-0000000001"));
        probar("isbn nulo",
                () -> new Libro("LIB-0002", "T", "A", 2000, null));
        probar("referencia con formato malo",
                () -> new Libro("L-1", "T", "A", 2000, "978-0000000001"));
        probar("titulo en blanco",
                () -> new Libro("LIB-0002", "   ", "A", 2000, "978-0000000001"));
        probar("autor en blanco",
                () -> new Libro("LIB-0002", "T", "", 2000, "978-0000000001"));
        probar("anio demasiado antiguo",
                () -> new Libro("LIB-0002", "T", "A", 1200, "978-0000000001"));
        probar("anio en el futuro lejano",
                () -> new Libro("LIB-0002", "T", "A", 3000, "978-0000000001"));
        probar("isbn con formato malo",
                () -> new Libro("LIB-0002", "T", "A", 2000, "12345"));

        System.out.println("\n=== Estado ===");
        valido.prestar();
        System.out.println(valido);
        probar("prestar dos veces", valido::prestar);
        valido.devolver();
        System.out.println(valido);
        probar("devolver dos veces", valido::devolver);
    }

    private static void probar(String descripcion, Runnable accion) {
        try {
            accion.run();
            System.out.println("  [" + descripcion + "] NO fallo (inesperado)");
        } catch (RuntimeException e) {
            System.out.println("  [" + descripcion + "] "
                    + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

/*
 * ¿Por que prestar() sobre un libro ya prestado es IllegalStateException
 * y no IllegalArgumentException?
 *
 * Porque prestar() NO TIENE ARGUMENTOS. No hay nada que el llamador haya
 * pasado mal. El problema es el ESTADO del objeto: este mismo libro, en otro
 * momento (despues de devolverlo), aceptaria exactamente la misma llamada
 * sin problema.
 *
 * La regla:
 *   - IllegalArgumentException: el problema es LO QUE ME PASAS.
 *   - IllegalStateException   : el problema es CUANDO ME LO PIDES.
 *
 * Esta distincion no es cosmetica: permite a quien captura reaccionar de forma
 * distinta. Ante un IllegalArgumentException se corrige el dato de entrada;
 * ante un IllegalStateException se consulta o se cambia el estado antes de
 * reintentar (por ejemplo, poniendo el material en la cola de reservas).
 */

Salida (abreviada):

=== Construccion correcta ===
LIB-0001 - 'Java Efectivo' (Bloch, 2018) [disponible]

=== Validaciones que fallan ===
  [referencia nula] NullPointerException: La referencia no puede ser nula
  [titulo nulo] NullPointerException: El titulo no puede ser nulo
  [isbn nulo] NullPointerException: El ISBN no puede ser nulo
  [referencia con formato malo] IllegalArgumentException: La referencia debe tener el formato LIB-NNNN, y era: 'L-1'
  [titulo en blanco] IllegalArgumentException: El campo 'titulo' no puede estar vacio ni contener solo espacios
  [anio demasiado antiguo] IllegalArgumentException: El anio de publicacion debe estar entre 1450 y 2100, y era: 1200
  [isbn con formato malo] IllegalArgumentException: El ISBN debe tener el formato 978-NNNNNNNNNN o 979-NNNNNNNNNN, y era: '12345'

=== Estado ===
LIB-0001 - 'Java Efectivo' (Bloch, 2018) [prestado]
  [prestar dos veces] IllegalStateException: El libro LIB-0001 ('Java Efectivo') ya esta prestado
LIB-0001 - 'Java Efectivo' (Bloch, 2018) [disponible]
  [devolver dos veces] IllegalStateException: El libro LIB-0001 ('Java Efectivo') no esta prestado

Solución 2

package com.nexussoftware.bibliotech.presentacion;

/**
 * Demuestra, con dos stack traces enfrentados, exactamente que informacion
 * se pierde al envolver una excepcion sin conservar la causa.
 */
public class DemoEncadenamiento {

    public record Ficha(String titulo, String autor, int anio) { }

    // ---------- Capa de datos ----------

    /** Parsea una linea. Puede lanzar varias RuntimeException distintas. */
    private static Ficha parsear(String linea) {
        String[] campos = linea.split(";");
        String titulo = campos[0].trim();            // ArrayIndexOutOfBounds si falta
        String autor  = campos[1].trim();            // idem
        int anio      = Integer.parseInt(campos[2].trim());   // NumberFormat si no es numero
        return new Ficha(titulo, autor, anio);
    }

    // ---------- Capa de servicio: dos versiones ----------

    /** BIEN: envuelve conservando la causa. */
    public static Ficha cargarFichaBien(String linea) {
        try {
            return parsear(linea);
        } catch (RuntimeException e) {
            throw new IllegalStateException("No se pudo cargar la ficha de: '" + linea + "'", e);
        }
    }

    /** MAL: envuelve DESCARTANDO la causa. */
    public static Ficha cargarFichaMal(String linea) {
        try {
            return parsear(linea);
        } catch (RuntimeException e) {
            throw new IllegalStateException("No se pudo cargar la ficha de: '" + linea + "'");
            //                                                                            ^ falta , e
        }
    }

    // ---------- Analisis de la cadena de causas ----------

    public record Analisis(int eslabones, String claseRaiz, String mensajeRaiz, String lineaRaiz) {

        public String fila(String etiqueta) {
            return String.format("%-16s | %-9d | %-28s | %-38s | %s",
                    etiqueta, eslabones, claseRaiz, mensajeRaiz, lineaRaiz);
        }
    }

    public static Analisis analizar(Throwable t) {
        int eslabones = 1;
        Throwable raiz = t;

        // Recorrer hasta la causa raiz, con proteccion anticircular
        while (raiz.getCause() != null && raiz.getCause() != raiz && eslabones < 20) {
            raiz = raiz.getCause();
            eslabones++;
        }

        String lineaRaiz = "(no disponible)";
        for (StackTraceElement m : raiz.getStackTrace()) {
            if (m.getClassName().startsWith("com.nexussoftware")) {
                lineaRaiz = m.getMethodName() + ":" + m.getLineNumber();
                break;
            }
        }

        String mensaje = (raiz.getMessage() != null) ? raiz.getMessage() : "(sin mensaje)";
        return new Analisis(eslabones, raiz.getClass().getSimpleName(), mensaje, lineaRaiz);
    }

    // ---------- Demostracion ----------

    public static void main(String[] args) {
        String lineaMala   = "Java Efectivo;Bloch;dos mil dieciocho";
        String lineaCorta  = "Solo un campo";

        System.out.println("############ STACK TRACE CON CAUSA ############");
        IllegalStateException conCausa = capturar(() -> cargarFichaBien(lineaMala));
        conCausa.printStackTrace(System.out);

        System.out.println("\n############ STACK TRACE SIN CAUSA ############");
        IllegalStateException sinCausa = capturar(() -> cargarFichaMal(lineaMala));
        sinCausa.printStackTrace(System.out);

        System.out.println("\n############ CASO: FALTAN CAMPOS ############");
        IllegalStateException campos = capturar(() -> cargarFichaBien(lineaCorta));
        campos.printStackTrace(System.out);

        System.out.println("\n############ COMPARATIVA ############");
        System.out.printf("%-16s | %-9s | %-28s | %-38s | %s%n",
                "VERSION", "ESLABONES", "CLASE RAIZ", "MENSAJE RAIZ", "LINEA RAIZ");
        System.out.println("-".repeat(130));
        System.out.println(analizar(conCausa).fila("Con causa"));
        System.out.println(analizar(sinCausa).fila("Sin causa"));
        System.out.println(analizar(campos).fila("Faltan campos"));

        System.out.println("""

                CONCLUSION
                ----------
                Con causa: sabemos que fallo (NumberFormatException), con que dato
                ("dos mil dieciocho") y en que linea exacta del codigo.

                Sin causa: solo sabemos que "no se pudo cargar la ficha". Las cinco
                causas posibles (falta un campo, anio no numerico, titulo vacio,
                autor vacio, anio fuera de rango) producen EXACTAMENTE el mismo
                volcado. Y esa informacion no se puede recuperar: el objeto
                excepcion original se descarto y el recolector se lo llevo.

                Coste de evitarlo: cinco caracteres, ", e".""");
    }

    /** Ejecuta una accion que se espera que falle y devuelve la excepcion. */
    private static IllegalStateException capturar(Runnable accion) {
        try {
            accion.run();
            throw new AssertionError("Se esperaba que fallara");
        } catch (IllegalStateException e) {
            return e;
        }
    }
}

Salida (abreviada):

############ STACK TRACE CON CAUSA ############
java.lang.IllegalStateException: No se pudo cargar la ficha de: 'Java Efectivo;Bloch;dos mil dieciocho'
	at ...DemoEncadenamiento.cargarFichaBien(DemoEncadenamiento.java:28)
	at ...DemoEncadenamiento.lambda$main$0(DemoEncadenamiento.java:78)
	...
Caused by: java.lang.NumberFormatException: For input string: "dos mil dieciocho"
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at ...DemoEncadenamiento.parsear(DemoEncadenamiento.java:18)
	at ...DemoEncadenamiento.cargarFichaBien(DemoEncadenamiento.java:26)
	... 4 more

############ STACK TRACE SIN CAUSA ############
java.lang.IllegalStateException: No se pudo cargar la ficha de: 'Java Efectivo;Bloch;dos mil dieciocho'
	at ...DemoEncadenamiento.cargarFichaMal(DemoEncadenamiento.java:38)
	at ...DemoEncadenamiento.lambda$main$1(DemoEncadenamiento.java:82)
	...

############ COMPARATIVA ############
VERSION          | ESLABONES | CLASE RAIZ                   | MENSAJE RAIZ                           | LINEA RAIZ
--------------------------------------------------------------------------------------------------------------
Con causa        | 2         | NumberFormatException        | For input string: "dos mil dieciocho"  | parsear:18
Sin causa        | 1         | IllegalStateException        | No se pudo cargar la ficha de: 'Java...| cargarFichaMal:38
Faltan campos    | 2         | ArrayIndexOutOfBoundsException| Index 1 out of bounds for length 1    | parsear:17

La tabla resume el daño: en la versión sin causa, la "clase raíz" es la propia excepción envolvente y la "línea raíz" apunta a la línea del throw, que es donde se ocultó el problema, no donde ocurrió.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;

import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Prestamo;

/**
 * Orquesta prestamos y devoluciones entre el catalogo, los empleados y el registro.
 *
 * Politica de errores de esta clase:
 *  - Valida sus propios argumentos con fallo rapido.
 *  - Deja subir sin tocar las excepciones que ya son claras (material no encontrado).
 *  - ENVUELVE, conservando la causa, cuando puede anadir contexto util
 *    (que operacion se estaba intentando).
 *  - No captura NADA que no sepa manejar.
 */
public class GestorPrestamos {

    private final Catalogo catalogo;
    private final Map<String, Empleado> empleados = new HashMap<>();
    private final Map<String, Prestamo> prestamos = new HashMap<>();

    private int contadorPrestamos = 0;

    public GestorPrestamos(Catalogo catalogo) {
        this.catalogo = Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");
    }

    public void darDeAlta(Empleado empleado) {
        Objects.requireNonNull(empleado, "El empleado no puede ser nulo");
        if (empleados.containsKey(empleado.getIdentificador())) {
            throw new IllegalArgumentException(
                    "Ya existe un empleado con identificador " + empleado.getIdentificador());
        }
        empleados.put(empleado.getIdentificador(), empleado);
    }

    /**
     * Presta un material a un empleado.
     *
     * @throws NullPointerException     si alguna referencia es nula
     * @throws IllegalArgumentException si alguna referencia esta en blanco o el dia es invalido
     * @throws NoSuchElementException   si el material o el empleado no existen
     * @throws IllegalStateException    si el material no esta disponible o el empleado
     *                                  ha alcanzado su limite de prestamos
     */
    public Prestamo prestar(String referenciaMaterial, String idEmpleado, int dia) {

        // --- 1. Validacion de argumentos: fallo rapido ---
        Objects.requireNonNull(referenciaMaterial, "La referencia del material no puede ser nula");
        Objects.requireNonNull(idEmpleado, "El identificador del empleado no puede ser nulo");

        if (referenciaMaterial.isBlank()) {
            throw new IllegalArgumentException("La referencia del material no puede estar en blanco");
        }
        if (idEmpleado.isBlank()) {
            throw new IllegalArgumentException("El identificador del empleado no puede estar en blanco");
        }
        if (dia < 1) {
            throw new IllegalArgumentException("El dia debe ser 1 o posterior, y era: " + dia);
        }

        // --- 2. Localizar. NO se captura: obtenerPorReferencia ya lanza un
        //        NoSuchElementException con un mensaje perfecto. Envolverlo
        //        no anadiria nada, asi que se deja subir tal cual. ---
        Libro material = (Libro) catalogo.obtenerPorReferencia(referenciaMaterial);

        Empleado empleado = empleados.get(idEmpleado);
        if (empleado == null) {
            throw new NoSuchElementException(
                    "No existe ningun empleado con identificador '" + idEmpleado
                            + "'. Dados de alta: " + empleados.keySet());
        }

        // --- 3. Reglas de negocio comprobables por adelantado ---
        if (!material.estaDisponible()) {
            throw new IllegalStateException(
                    "El material " + referenciaMaterial + " ('" + material.getTitulo()
                            + "') no esta disponible");
        }

        // --- 4. Aqui SI se envuelve: la excepcion de Empleado no sabe que se
        //        estaba intentando prestar. Anadimos ese contexto conservando
        //        la causa, para que el Caused by: mantenga el detalle. ---
        String referenciaPrestamo = siguienteReferencia();
        Prestamo prestamo;
        try {
            prestamo = new Prestamo(referenciaPrestamo, material, empleado, dia);
        } catch (IllegalStateException e) {
            throw new IllegalStateException(
                    "No se pudo prestar " + referenciaMaterial + " ('" + material.getTitulo()
                            + "') a " + idEmpleado + " el dia " + dia, e);
        }

        prestamos.put(referenciaPrestamo, prestamo);
        return prestamo;
    }

    /**
     * Registra la devolucion de un prestamo.
     *
     * @throws NoSuchElementException si la referencia del prestamo no existe
     * @throws IllegalStateException  si el prestamo ya estaba devuelto (sube desde Prestamo)
     */
    public void devolver(String referenciaPrestamo, int dia) {
        Objects.requireNonNull(referenciaPrestamo, "La referencia del prestamo no puede ser nula");

        Prestamo prestamo = prestamos.get(referenciaPrestamo);
        if (prestamo == null) {
            throw new NoSuchElementException(
                    "No existe ningun prestamo con referencia '" + referenciaPrestamo
                            + "'. Registrados: " + prestamos.keySet());
        }

        // No se captura: si ya estaba devuelto, el IllegalStateException de
        // Prestamo ya dice referencia y dia. Envolverlo solo anadiria ruido.
        prestamo.registrarDevolucion(dia);
        prestamo.getTitular().registrarDevolucion();
    }

    private String siguienteReferencia() {
        contadorPrestamos++;
        return String.format("PR-%04d", contadorPrestamos);
    }

    public int prestamosActivos() {
        int activos = 0;
        for (Prestamo p : prestamos.values()) {
            if (!p.estaDevuelto()) { activos++; }
        }
        return activos;
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) {
        Catalogo catalogo = new Catalogo();
        catalogo.registrar(new Libro("LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001"));
        catalogo.registrar(new Libro("LIB-0002", "Patrones de Diseno", "GoF", 1994, "978-0000000002"));
        catalogo.registrar(new Libro("LIB-0003", "Refactorizacion", "Fowler", 1999, "978-0000000003"));

        GestorPrestamos gestor = new GestorPrestamos(catalogo);
        gestor.darDeAlta(new Empleado("Marta Ruiz", "EMP-001"));
        gestor.darDeAlta(new Empleado("Diego Alonso", "EMP-002"));
        gestor.darDeAlta(new Empleado("Nuria Vidal", "EMP-003"));

        System.out.println("=== Prestamos correctos ===");
        System.out.println(gestor.prestar("LIB-0001", "EMP-001", 10).getReferencia());
        System.out.println(gestor.prestar("LIB-0002", "EMP-001", 10).getReferencia());
        System.out.println("Activos: " + gestor.prestamosActivos());

        System.out.println("\n=== Fallos ===");
        fallar("1. Material inexistente", () -> gestor.prestar("LIB-9999", "EMP-001", 12));
        fallar("2. Empleado inexistente", () -> gestor.prestar("LIB-0003", "EMP-999", 12));
        fallar("3. Material ya prestado", () -> gestor.prestar("LIB-0001", "EMP-002", 12));
        fallar("4. Dia invalido",         () -> gestor.prestar("LIB-0003", "EMP-002", 0));
        fallar("5. Referencia nula",      () -> gestor.prestar(null, "EMP-002", 12));

        // 6. Limite de prestamos: Marta ya tiene 2, el tercero pasa y el cuarto no
        gestor.prestar("LIB-0003", "EMP-001", 12);
        catalogo.registrar(new Libro("LIB-0004", "Clean Code", "Martin", 2008, "978-0000000004"));
        fallar("6. Limite excedido", () -> gestor.prestar("LIB-0004", "EMP-001", 12));

        System.out.println("\n=== Devoluciones ===");
        gestor.devolver("PR-0001", 30);
        System.out.println("PR-0001 devuelto. Activos: " + gestor.prestamosActivos());
        fallar("7. Devolver dos veces",   () -> gestor.devolver("PR-0001", 31));
        fallar("8. Prestamo inexistente", () -> gestor.devolver("PR-9999", 31));
    }

    private static void fallar(String descripcion, Runnable accion) {
        try {
            accion.run();
            System.out.println(descripcion + " -> NO fallo (inesperado)");
        } catch (RuntimeException e) {
            System.out.println(descripcion + " -> " + e.getClass().getSimpleName()
                    + ": " + e.getMessage());
            if (e.getCause() != null) {
                System.out.println("      Causa: " + e.getCause().getClass().getSimpleName()
                        + ": " + e.getCause().getMessage());
            }
        }
    }
}

Salida:

=== Prestamos correctos ===
PR-0001
PR-0002
Activos: 2

=== Fallos ===
1. Material inexistente -> NoSuchElementException: No existe ningun material con la referencia 'LIB-9999'. El catalogo tiene 3 materiales.
2. Empleado inexistente -> NoSuchElementException: No existe ningun empleado con identificador 'EMP-999'. Dados de alta: [EMP-001, EMP-002, EMP-003]
3. Material ya prestado -> IllegalStateException: El material LIB-0001 ('Java Efectivo') no esta disponible
4. Dia invalido -> IllegalArgumentException: El dia debe ser 1 o posterior, y era: 0
5. Referencia nula -> NullPointerException: La referencia del material no puede ser nula
6. Limite excedido -> IllegalStateException: No se pudo prestar LIB-0004 ('Clean Code') a EMP-001 el dia 12
      Causa: IllegalStateException: El empleado EMP-001 (Marta Ruiz) ya tiene 3 prestamos, el maximo es 3

=== Devoluciones ===
PR-0001 devuelto. Activos: 2
7. Devolver dos veces -> IllegalStateException: El prestamo PR-0001 ya se devolvio el dia 30
8. Prestamo inexistente -> NoSuchElementException: No existe ningun prestamo con referencia 'PR-9999'. Registrados: [PR-0001, PR-0002, PR-0003]

Fíjate en el caso 6, que es el que ilustra la decisión de diseño central del ejercicio. La excepción de Empleado decía "EMP-001 ya tiene 3 préstamos", información correcta pero incompleta: no dice qué se estaba intentando hacer. Al envolverla, el mensaje de arriba aporta la operación (prestar LIB-0004 a EMP-001 el dia 12) y el Caused by: conserva el detalle. Las dos piezas juntas son un diagnóstico completo.

Y compara con los casos 1 y 3, donde no se envolvió: los mensajes de Catalogo y del propio gestor ya eran completos, así que añadir una capa solo habría alargado el stack trace sin aportar nada. Envolver por sistema es tan malo como no envolver nunca: se hace cuando añades contexto que la capa inferior no podía conocer.

Un problema pendiente que salta a la vista en este código: el catch (IllegalStateException e) del apartado 4 captura cualquier IllegalStateException que salga del constructor de Prestamo, tanto la del límite de préstamos como la del material no disponible. No puede distinguirlas sin leer el mensaje. Y ese (Libro) del casting es igual de frágil. Ambas cosas se resuelven con excepciones propias del dominio, que es la lección siguiente.

Conclusión

Ya sabes señalar un fallo, no solo capturarlo. Dominas throw para lanzar explícitamente —con la certeza de que termina el método al instante y de que el compilador rechaza el código posterior por inalcanzable— y throws para declarar en la firma, con la regla de capturar o declarar que impone a quien llame: capturar si sabe qué hacer, declarar si la decisión pertenece a alguien con más contexto. Y has visto esa cadena de propagación funcionando en cuatro niveles, donde los tres profundos detectan e informan y solo main decide, porque solo main sabe que BiblioTech puede arrancar con el catálogo vacío.

Sabes que declarar excepciones no comprobadas en throws es legal pero opcional, y que la práctica recomendada es documentarlas con @throws en el Javadoc, donde puedes explicar bajo qué condición se lanzan.

Tienes el vocabulario estándar de la validación de argumentos y la regla que lo ordena: nulo → NullPointerException con Objects.requireNonNull y su mensaje; valor inválido → IllegalArgumentException; momento inadecuado → IllegalStateException. Con la distinción que más cuesta y más rinde: el problema es lo que me pasas frente a el problema es cuándo me lo pides. Sabes validar los nulos primero, usar la variante con Supplier cuando el mensaje sea caro de construir, y extraer las validaciones largas a métodos privados con nombre.

Entiendes el principio de fallar rápido: comprobar las precondiciones al principio, antes de tocar nada, para que el error aparezca cerca de su causa, el resto del método pueda confiar y —lo más importante— el objeto nunca llegue a existir en estado inválido. Es lo que convierte las invariantes de 03-07 de aspiración en garantía.

Y dominas el encadenamiento de excepciones, que es la técnica que separa un sistema diagnosticable de uno que no lo es. Sabes que la causa se pasa en el constructor de dos argumentos —new IllegalStateException(mensaje, e)— o, en casos antiguos, con initCause. Has visto exactamente qué desaparece cuando se olvida: el tipo del fallo real, el dato culpable, la línea de código y toda la pila del punto original, información que no se recupera nunca. Cinco caracteres, , e, marcan la diferencia entre un diagnóstico en treinta segundos y una tarde de conjeturas.

Distingues relanzar con throw e —mismo objeto, mismo trace, para añadir contexto o registrar— de envolver, para cambiar el vocabulario; y sabes que envolver por sistema es tan malo como no envolver nunca: se hace cuando aportas contexto que la capa inferior no podía conocer. Conoces la traducción entre capas que impide que una capa filtre las excepciones de su implementación interna, el mismo principio que aplica Spring al convertir SQLException en su jerarquía propia. Y tienes dos detalles finos: el análisis preciso de relanzamiento de Java 7, que permite throw e desde un catch (Exception e) sin ampliar el throws mientras no reasignes el parámetro; y la regla de sobrescritura, que impide a una subclase declarar excepciones comprobadas más amplias que la superclase —una razón de peso más para preferir no comprobadas en las interfaces de dominio.

BiblioTech ha pagado sus dos deudas. Los constructores de Libro, Empleado y Prestamo ya no imprimen avisos ni inventan valores por defecto: rechazan los datos inválidos, de modo que ya no existen préstamos compartiendo la referencia PR-0000 ni libros publicados en el año 0. Catalogo.registrar lanza indicando el motivo exacto en lugar de un false indistinguible, y buscarPorReferencia ha desaparecido en su forma antigua: en su lugar hay dos métodos con contratos explícitos, obtenerPorReferencia —que nunca devuelve null y lanza si no encuentra— y buscarPorReferencia, que devuelve Optional. El null como valor de retorno se ha eliminado del catálogo. Y GestorPrestamos orquesta las tres piezas envolviendo solo donde añade contexto.

Pero el propio código de demostración ha dejado el problema siguiente a la vista: ese catch con cuatro tipos estándar unidos por |, y ese catch (IllegalStateException e) que no puede distinguir "el material no está disponible" de "el empleado ha alcanzado su límite" sin leer un mensaje de texto libre. IllegalArgumentException se está usando para tres cosas distintas, ninguna de estas excepciones transporta datos —para saber qué referencia estaba duplicada hay que parsear una cadena— y nada permite capturar "cualquier fallo de BiblioTech" de una sola vez.

Eso es la lección siguiente, Excepciones Personalizadas: por qué crear tus propias excepciones para nombrar el fallo en el lenguaje del dominio y transportar datos estructurados; cómo se crean extendiendo Exception o RuntimeException y el criterio para elegir; los cuatro constructores canónicos y por qué conviene ofrecerlos todos; cómo añadir campos y accesores —getReferencia(), getLimite()— para que quien capture pueda decidir en lugar de leer texto; y el diseño de una jerarquía de dominio con una raíz BiblioTechException y sus descendientes MaterialNoEncontradoException, ReferenciaDuplicadaException, LimitePrestamosExcedidoException, MaterialNoDisponibleException y PrestamoYaDevueltoException, que permitirá capturar todo el dominio de una vez. Con las convenciones de nombre y de mensaje, el criterio para saber cuándo no crear una excepción propia, y una nota avanzada sobre fillInStackTrace para las excepciones de control muy frecuentes.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados