Ya tienes todas las piezas técnicas: sabes leer un fallo, capturarlo, lanzarlo, darle nombre propio, garantizar la limpieza y cerrar recursos sin perder información. Lo que falta es la parte que no se aprende leyendo la especificación del lenguaje: decidir dónde va cada cosa.

Porque un sistema con excepciones perfectamente diseñadas puede seguir siendo inmanejable si cada capa captura lo que no le toca, si el usuario recibe un stack trace en la cara, si un fallo aparece cinco veces en el registro o —el caso de BiblioTech hasta ahora— si el registro no existe y todo el diagnóstico son System.out.println que se pierden al cerrar la terminal.

Esta lección cierra el módulo con dos mitades. La primera es estrategia: dónde se captura, qué hace cada capa, cómo se construye la frontera de errores del main, qué información llega al usuario y qué solo al registro, y cuándo conviene validar en lugar de lanzar. La segunda es logging: por qué System.out.println no es una opción en producción, cómo se usa java.util.logging de verdad, qué registrar en cada nivel, qué no registrar nunca, y cómo es el ecosistema real que verás en 11-07.

Al terminar, BiblioTech no tendrá ni un solo System.out.println de diagnóstico, su main tendrá una frontera que impide que nada escape sin registrarse, y los avisos serán registros con marca de tiempo, nivel y contexto, filtrables y archivables. Es decir: software que se puede poner en producción.

Contenido

  1. Dónde se captura una excepción
  2. Las capas de BiblioTech y qué hace cada una con los errores
  3. La frontera de errores del main
  4. El manejador global de excepciones no capturadas
  5. Qué ve el usuario y qué ve el registro
  6. Errores recuperables e irrecuperables: degradación elegante
  7. Reintentos con espera y su límite
  8. Validación previa frente a excepción
  9. El patrón "objeto resultado" y Optional
  10. Idempotencia y estado coherente
  11. Por qué System.out.println no sirve en producción
  12. Los niveles de log y qué registrar en cada uno
  13. java.util.logging en la práctica
  14. Handler, Formatter y logging.properties
  15. Registrar una excepción correctamente
  16. Qué NO registrar nunca
  17. Mensajes de log útiles y correlación
  18. El ecosistema real: SLF4J, Logback y Log4j2
  19. BiblioTech: la refactorización final
  20. Errores Comunes y Consejos
  21. Ejercicios

  1. Dónde se captura una excepción

La regla que resume todo lo demás:

Captura una excepción donde puedas tomar una decisión sobre ella, no donde se produce.

Suena obvio y sin embargo la mayor parte del código mal escrito la incumple. El reflejo de rodear con try-catch cualquier línea que pueda fallar produce esto:

// MAL: captura donde se produce, sin poder decidir nada
public Material buscar(String referencia) {
    try {
        return catalogo.obtenerPorReferencia(referencia);
    } catch (MaterialNoEncontradoException e) {
        e.printStackTrace();       // no es una decision
        return null;               // y ahora devolvemos el null que tanto costo eliminar
    }
}

Ese catch no decide nada: no recupera, no reintenta, no traduce y no aporta contexto. Lo único que hace es degradar una excepción con nombre y datos a un null mudo, deshaciendo todo el trabajo de 06-03 y 06-04.

La versión correcta es no capturar:

// BIEN: no se captura. Sube a quien pueda decidir.
public Material buscar(String referencia) {
    return catalogo.obtenerPorReferencia(referencia);
}

Las tres preguntas que decide si debes capturar aquí:

  1. ¿Puedo hacer algo distinto de propagar? Recuperar con un valor por defecto, reintentar, degradar el servicio. Si no, no captures.
  2. ¿Tengo contexto que añadir? Si puedo envolver aportando información que la capa inferior no conocía, captura y traduce conservando la causa (06-03).
  3. ¿Soy la frontera? Si por encima de mí solo está la JVM o el usuario, tengo que capturar sí o sí, porque nadie más va a hacerlo.

Si la respuesta a las tres es no, deja que suba. No capturar es una decisión activa y muchas veces la correcta.

  1. Las capas de BiblioTech y qué hace cada una con los errores

Una aplicación real se organiza en capas, y cada una tiene una política de errores distinta:

flowchart TB
    U["USUARIO<br/>Marta, Diego, Nuria"]

    subgraph P["PRESENTACION - MenuBiblioTech, ReciboConsola"]
        P1["CAPTURA todo lo que el usuario pueda entender<br/>Traduce a mensajes claros<br/>Registra lo inesperado<br/>NUNCA muestra un stack trace"]
    end

    subgraph S["SERVICIO - GestorPrestamos, Catalogo, ColaReservas"]
        S1["Aplica reglas de negocio y LANZA excepciones del dominio<br/>Traduce las de persistencia conservando la causa<br/>Garantiza estado coherente con finally<br/>Casi nunca captura"]
    end

    subgraph D["DOMINIO - Material, Libro, Empleado, Prestamo"]
        D1["Valida invariantes y LANZA<br/>Fail-fast en constructores y setters<br/>NUNCA captura ni registra"]
    end

    subgraph PE["PERSISTENCIA - modulo 7"]
        PE1["Lanza IOException y similares<br/>El servicio las traduce"]
    end

    U -->|"introduce datos"| P1
    P1 -->|"invoca"| S1
    S1 -->|"usa"| D1
    S1 -->|"lee y escribe"| PE1

    D1 -.->|"BiblioTechException"| S1
    PE1 -.->|"IOException"| S1
    S1 -.->|"BiblioTechException<br/>con la causa conservada"| P1
    P1 -.->|"mensaje claro,<br/>sin detalles internos"| U

En tabla:

Capa ¿Captura? ¿Lanza? ¿Registra? ¿Qué muestra?
Dominio Casi nunca , siempre que se viole una invariante No Nada: no conoce la interfaz
Servicio Solo para traducir o compensar Sí, del vocabulario del dominio Solo lo relevante para negocio Nada
Persistencia Solo para traducir Sí, de E/S Detalles técnicos Nada
Presentación Sí, todo lo esperable Rara vez Todo lo inesperado Mensajes al usuario

Las dos reglas que se derivan y que conviene grabar:

El dominio nunca registra ni imprime. Un Libro no sabe si su aplicación es de consola, web o móvil, ni si hay alguien mirando. Su única forma de comunicar un problema es lanzar. Es la abstracción de 03-08 aplicada al manejo de errores: cada capa habla el vocabulario de su nivel y no presupone nada de las de arriba.

La presentación es el filtro. Es la única capa que sabe quién está al otro lado y qué puede entender, y por tanto la única que puede decidir qué mensaje mostrar. También es la única que debería registrar los fallos inesperados con todo su detalle.

  1. La frontera de errores del main

Por muy bien repartidas que estén las responsabilidades, siempre puede escapar algo. La frontera de errores es el try-catch más externo de la aplicación, y su misión es que nada llegue jamás a la JVM sin registrarse y sin un mensaje digno.

package com.nexussoftware.bibliotech.presentacion;

import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.BiblioTechException;

/**
 * Punto de entrada de BiblioTech con frontera de errores.
 *
 * Estructura de la frontera, de lo mas especifico a lo mas general:
 *   1. BiblioTechException : fallo esperado del dominio -> mensaje limpio, salida 1
 *   2. RuntimeException    : bug -> mensaje generico con incidencia, registro completo, salida 2
 *   3. Error               : fallo de la JVM -> registro minimo y salida inmediata, salida 3
 */
public class BiblioTechApp {

    private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());

    public static void main(String[] args) {
        ConfiguracionLog.inicializar();
        instalarManejadorGlobal();

        LOG.info("BiblioTech arrancando");

        try {
            new MenuBiblioTech().arrancar();
            LOG.info("BiblioTech finalizado correctamente");

        } catch (BiblioTechException e) {
            // Fallo ESPERADO del dominio que nadie manejo. Es raro, pero legible.
            System.err.println();
            System.err.println("La operacion no se pudo completar: " + e.getMessage());
            LOG.log(Level.WARNING, "Excepcion de dominio no manejada [" + e.getCodigo() + "]", e);
            System.exit(1);

        } catch (RuntimeException e) {
            // Fallo INESPERADO: es un bug. El usuario no debe ver los detalles.
            String incidencia = generarIdIncidencia();
            System.err.println();
            System.err.println("Se ha producido un error interno.");
            System.err.println("Incidencia: " + incidencia);
            System.err.println("Comunique este codigo al equipo de soporte de Nexus Software.");
            LOG.log(Level.SEVERE, "Error no controlado. Incidencia " + incidencia, e);
            System.exit(2);

        } catch (Error e) {
            // Fallo de la JVM: no se puede recuperar. Registrar lo minimo y salir.
            // Se captura AQUI y solo aqui, para dejar constancia antes de morir.
            System.err.println();
            System.err.println("Error irrecuperable de la maquina virtual: "
                    + e.getClass().getSimpleName());
            try {
                LOG.log(Level.SEVERE, "Error irrecuperable", e);
            } catch (Throwable ignorado) {
                // Si ni siquiera se puede registrar (p. ej. OutOfMemoryError),
                // no hay nada mas que hacer. No se propaga para no ocultar el Error.
            }
            System.exit(3);
        }
    }

    /** Identificador corto que el usuario puede comunicar a soporte. */
    private static String generarIdIncidencia() {
        return "INC-" + Long.toHexString(System.nanoTime()).toUpperCase().substring(0, 8);
    }

    private static void instalarManejadorGlobal() {
        Thread.setDefaultUncaughtExceptionHandler((hilo, fallo) ->
                LOG.log(Level.SEVERE, "Excepcion no capturada en el hilo '"
                        + hilo.getName() + "'", fallo));
    }
}

Las decisiones que hacen buena esta frontera:

Elemento Por qué
Tres catch ordenados De lo específico a lo general (06-02). BiblioTechException primero, porque es lo que produce un mensaje útil
Distinguir dominio de bug Un fallo de negocio merece su mensaje; un bug merece una incidencia y silencio sobre los detalles
Identificador de incidencia El usuario comunica INC-A3F91C0B, y en el registro se encuentra el stack trace exacto. Sin exponer nada
Códigos de salida distintos 1 negocio, 2 bug, 3 fatal. Un script o un sistema de integración continua puede reaccionar de forma distinta
Capturar Error solo aquí Es la única excepción a la regla de 06-01: se captura para registrar antes de morir, nunca para continuar
El registro del Error, protegido Con un OutOfMemoryError, hasta escribir en el log puede fallar

Ese es el motivo por el que se dice que catch (Exception e) solo es legítimo en la frontera: aquí no hay nadie por encima, así que capturar ancho no oculta nada de nadie.

  1. El manejador global de excepciones no capturadas

La frontera del main cubre el hilo principal. Pero en cuanto haya más hilos —módulo 8—, una excepción que escape de un hilo secundario mata ese hilo en silencio y el programa sigue como si nada. Es una fuente clásica de fallos invisibles.

Thread.setDefaultUncaughtExceptionHandler instala una red de seguridad para todos los hilos:

package com.nexussoftware.bibliotech.presentacion;

import java.util.logging.Level;
import java.util.logging.Logger;

public final class ManejadorGlobal {

    private static final Logger LOG = Logger.getLogger(ManejadorGlobal.class.getName());

    private ManejadorGlobal() { }

    public static void instalar() {
        Thread.setDefaultUncaughtExceptionHandler((hilo, fallo) -> {
            // Este codigo se ejecuta cuando una excepcion escapa de CUALQUIER hilo
            LOG.log(Level.SEVERE,
                    "Excepcion no capturada en el hilo '" + hilo.getName()
                            + "' (id " + hilo.threadId() + ")", fallo);

            if (fallo instanceof Error) {
                LOG.severe("Error irrecuperable: se solicita el apagado de la aplicacion");
                System.exit(3);
            }
        });

        // Hook de apagado: ultima oportunidad de limpieza (06-05)
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            LOG.info("BiblioTech apagandose: consolidando estado");
            // En el modulo 7, aqui se persistira el catalogo
        }, "apagado-bibliotech"));
    }

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

        Thread hiloSecundario = new Thread(() -> {
            throw new IllegalStateException("Fallo en la tarea de mantenimiento");
        }, "mantenimiento");

        hiloSecundario.start();

        try { hiloSecundario.join(); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("El hilo principal sigue vivo, pero el fallo QUEDO REGISTRADO");
    }
}

Sin ese manejador, el hilo mantenimiento moriría imprimiendo un stack trace en System.err que en producción probablemente nadie leería, y la aplicación seguiría funcionando sin la tarea de mantenimiento y sin que nadie lo supiera.

Un detalle de precedencia: si un hilo concreto tiene su propio manejador (hilo.setUncaughtExceptionHandler(...)), ese tiene prioridad sobre el global.

  1. Qué ve el usuario y qué ve el registro

Una separación que se incumple constantemente y con consecuencias reales:

Usuario Registro
Mensaje en su idioma y vocabulario No necesariamente
Qué hacer a continuación No
Identificador de incidencia Sí (para correlacionar)
Clase de la excepción No
Stack trace NUNCA Sí, completo
Rutas de ficheros, nombres de tablas, consultas NUNCA
Datos internos del sistema No
Marca de tiempo y hilo No

Un stack trace en la cara del usuario es un problema por tres motivos, en orden de gravedad:

  1. Es un riesgo de seguridad. Revela paquetes, clases, versiones de librerías, rutas del sistema de ficheros y a veces fragmentos de consultas. Es información de reconocimiento gratuita para quien quiera atacar el sistema.
  2. No le sirve de nada. Marta Ruiz no puede hacer nada con NullPointerException at Prestamo.java:97.
  3. Destruye la confianza. Un muro de texto rojo comunica "esto está roto y nadie lo controla".

La versión correcta, aplicada:

// Lo que ve el usuario
System.err.println("No se ha podido completar el prestamo.");
System.err.println("Incidencia: INC-A3F91C0B");
System.err.println("Comunique este codigo al equipo de soporte.");

// Lo que va al registro
LOG.log(Level.SEVERE, "Fallo al prestar LIB-0001 a EMP-001 el dia 12. Incidencia INC-A3F91C0B", e);

El identificador de incidencia es la pieza que une ambos mundos: el usuario lo comunica, soporte lo busca en el registro, y aparece el stack trace completo con todo el contexto. Sin exponer nada.

Y una regla adicional, más sutil: los mensajes de las excepciones del dominio sí pueden mostrarse al usuario, porque están escritos en su vocabulario —"El material LIB-0001 está prestado y se espera su devolución el día 27"—. Los mensajes de las excepciones técnicas, no. Es exactamente la distinción que permite el método esCorregiblePorElUsuario() que pusiste en BiblioTechException en 06-04.

  1. Errores recuperables e irrecuperables: degradación elegante

Recuperable Irrecuperable
Definición Existe una alternativa aceptable No hay forma sensata de continuar
Respuesta Degradar el servicio y continuar Registrar y terminar de forma ordenada
Ejemplos en BiblioTech El fichero de catálogo no existe → arrancar vacío La configuración obligatoria falta → no arrancar
La cola de avisos no responde → apuntar y reintentar luego OutOfMemoryError
El exportador falla → informar y seguir operando Datos corruptos en el índice principal

La degradación elegante consiste en seguir dando el servicio que se pueda dar, informando de lo que no está disponible:

package com.nexussoftware.bibliotech.servicio;

import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Arranque de BiblioTech con degradacion elegante: la aplicacion funciona
 * aunque falten componentes opcionales, y solo aborta si falta algo esencial.
 */
public class ArranqueBiblioTech {

    private static final Logger LOG = Logger.getLogger(ArranqueBiblioTech.class.getName());

    private boolean avisosDisponibles = true;
    private boolean exportacionDisponible = true;

    public Catalogo arrancar(String rutaCatalogo) {
        Catalogo catalogo = new Catalogo();

        // --- Componente OPCIONAL 1: catalogo previo ---
        try {
            cargarCatalogo(catalogo, rutaCatalogo);
            LOG.info(() -> "Catalogo cargado: " + catalogo.tamano() + " materiales");

        } catch (CatalogoNoAccesibleException e) {
            // DEGRADACION: sin catalogo previo se puede trabajar; se empieza vacio.
            LOG.log(Level.WARNING,
                    "No se pudo cargar el catalogo desde '" + e.getRuta()
                            + "'. Se arranca con catalogo vacio.", e);
            System.out.println("Aviso: no habia catalogo previo. Se empieza de cero.");
        }

        // --- Componente OPCIONAL 2: servicio de avisos ---
        try {
            conectarServicioAvisos();
            LOG.info("Servicio de avisos disponible");
        } catch (RuntimeException e) {
            avisosDisponibles = false;
            LOG.log(Level.WARNING, "Servicio de avisos no disponible; se acumularan en cola", e);
            System.out.println("Aviso: los avisos de vencimiento no se enviaran en esta sesion.");
        }

        // --- Componente ESENCIAL: configuracion ---
        // Si esto falla, NO se degrada: se aborta. Continuar con una tarifa
        // desconocida produciria multas incorrectas, que es peor que no arrancar.
        int diasPrestamo = leerConfiguracionObligatoria("dias.prestamo");
        LOG.config(() -> "Configuracion: dias.prestamo=" + diasPrestamo);

        return catalogo;
    }

    public boolean puedeEnviarAvisos()  { return avisosDisponibles; }
    public boolean puedeExportar()      { return exportacionDisponible; }

    private void cargarCatalogo(Catalogo catalogo, String ruta) throws CatalogoNoAccesibleException {
        throw new CatalogoNoAccesibleException(ruta,
                new java.io.FileNotFoundException(ruta + " (No such file or directory)"));
    }

    private void conectarServicioAvisos() {
        throw new IllegalStateException("El servicio de avisos no responde en el puerto 8080");
    }

    private int leerConfiguracionObligatoria(String clave) {
        return 15;   // en el modulo 7 vendra de un fichero .properties
    }
}

El criterio para decidir entre degradar y abortar:

Degrada cuando el servicio reducido siga siendo correcto. Aborta cuando continuar produciría resultados incorrectos.

Arrancar sin catálogo previo da un servicio reducido pero correcto: la biblioteca está vacía y eso es cierto. Arrancar sin conocer la tarifa diaria produciría multas incorrectas, que es mucho peor que no arrancar: un sistema que da números equivocados con toda naturalidad es más peligroso que uno que no arranca.

  1. Reintentos con espera y su límite

Ya viste el patrón en 06-02. Aquí van las reglas de decisión, que es lo que se aprende con la experiencia:

Qué reintentar y qué no:

Reintentar No reintentar
Fallo de red o de conexión IllegalArgumentException: el dato seguirá siendo inválido
Fichero temporalmente bloqueado MaterialNoEncontradoException: seguirá sin existir
Servicio que devuelve "no disponible" LimitePrestamosExcedidoException: la regla no cambia sola
Bloqueo de base de datos Cualquier error de programación

La regla: reintenta lo que depende del entorno; nunca lo que depende de los datos o de las reglas.

Las cuatro condiciones de un reintento correcto:

  1. Límite de intentos. Sin él, un servicio caído convierte tu aplicación en un bucle infinito.
  2. Espera creciente (backoff exponencial): 200, 400, 800, 1600 ms. Reintentar en ráfaga agrava la caída del servicio remoto.
  3. Fallo definitivo al agotarlos, conservando el último error como causa. Nunca devolver null fingiendo éxito.
  4. Idempotencia de la operación. Esta es la que más se olvida: si el préstamo se registró y solo falló la confirmación, reintentar creará dos préstamos. Solo se reintenta lo que se puede repetir sin efectos secundarios.

Un refinamiento que verás en sistemas reales: el cortacircuitos (circuit breaker). Si un servicio falla repetidamente, se deja de llamar durante un tiempo en lugar de reintentar en cada operación. Es un patrón de arquitectura que queda fuera del alcance de este curso, pero conviene conocer el nombre.

  1. Validación previa frente a excepción

Muchas veces puedes elegir entre preguntar antes o intentar y capturar. La tabla de criterios:

Criterio Validación previa (if) Excepción
Frecuencia del fallo Alta: es un caso normal Baja: es excepcional
Coste de comprobar Barato (isEmpty, containsKey) Caro o imposible sin intentarlo
Condición de carrera Puede cambiar entre la comprobación y el uso Inmune: es atómico
Legibilidad Mejor si son una o dos condiciones Mejor si son muchas o están dispersas
Rendimiento ~70 veces más rápido (06-02) Irrelevante si es raro

Ejemplos aplicados:

// VALIDACION PREVIA: barata, el caso es frecuente y no hay carrera
if (catalogo.existe(referencia)) {
    Material m = catalogo.obtenerPorReferencia(referencia);
    // ...
}

// Mejor aun con el metodo que devuelve Optional
catalogo.buscarPorReferencia(referencia)
        .ifPresent(m -> System.out.println(m.getTitulo()));

// EXCEPCION: la conversion no se puede validar sin hacerla
try {
    int dias = Integer.parseInt(entrada);
} catch (NumberFormatException e) {
    // Escribir un validador de enteros correcto (signo, rango, overflow)
    // es mas propenso a errores que capturar
}

// EXCEPCION OBLIGATORIA: condicion de carrera
// Con ficheros o recursos compartidos, "comprobar y luego usar" no es atomico:
// entre el exists() y el open() otro proceso puede haber borrado el fichero.
try (BufferedReader r = new BufferedReader(new FileReader(ruta))) {
    // ...
} catch (FileNotFoundException e) {
    // La unica forma fiable
}

Ese último caso tiene nombre propio, TOCTOU (time-of-check to time-of-use), y es una fuente conocida de fallos y de vulnerabilidades. Siempre que el recurso sea compartido —ficheros, base de datos, memoria entre hilos—, la comprobación previa no garantiza nada y hay que manejar la excepción de todos modos.

La regla práctica final: valida lo que sea barato y estable de comprobar; captura lo que no se pueda saber sin intentarlo.

  1. El patrón "objeto resultado" y Optional

No todo fallo tiene que ser una excepción. Hay dos alternativas legítimas.

Optional, para la ausencia de valor:

// Ya lo usas en BiblioTech desde 06-03
public Optional<Material> buscarPorReferencia(String referencia) {
    return Optional.ofNullable(indicePorReferencia.get(referencia));
}

Comunica "puede que no haya nada, y eso es normal" de una forma que el compilador ayuda a no ignorar. Se desarrolla en 10-04.

El objeto resultado, cuando necesitas transportar el motivo del fallo sin lanzar. Es especialmente útil en validaciones en lote, donde quieres recoger todos los errores y no solo el primero:

package com.nexussoftware.bibliotech.servicio;

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

/**
 * Resultado de una operacion que puede fallar, SIN lanzar.
 *
 * Util cuando quieres acumular VARIOS errores en lugar de abortar en el primero:
 * validar un formulario, importar un fichero, comprobar reglas de negocio.
 */
public class Resultado {

    private final boolean exito;
    private final List<String> errores;

    private Resultado(boolean exito, List<String> errores) {
        this.exito = exito;
        this.errores = List.copyOf(errores);
    }

    public static Resultado ok() {
        return new Resultado(true, List.of());
    }

    public static Resultado conErrores(List<String> errores) {
        return new Resultado(false, errores);
    }

    public boolean esExito()         { return exito; }
    public List<String> getErrores() { return errores; }

    public String describir() {
        if (exito) { return "OK"; }
        return "Rechazado (" + errores.size() + " problemas):\n  - "
                + String.join("\n  - ", errores);
    }

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

    /** Ejemplo: validar un alta acumulando TODOS los problemas. */
    public static Resultado validarAlta(String referencia, String titulo, int anio, String isbn) {
        List<String> errores = new ArrayList<>();

        if (referencia == null || !referencia.matches("LIB-\\d{4}")) {
            errores.add("La referencia debe tener el formato LIB-NNNN, y era: " + referencia);
        }
        if (titulo == null || titulo.isBlank()) {
            errores.add("El titulo no puede estar vacio");
        }
        if (anio < 1450 || anio > 2100) {
            errores.add("El anio debe estar entre 1450 y 2100, y era: " + anio);
        }
        if (isbn == null || !isbn.matches("97[89]-\\d{10}")) {
            errores.add("El ISBN debe tener el formato 978-NNNNNNNNNN, y era: " + isbn);
        }

        return errores.isEmpty() ? ok() : conErrores(errores);
    }

    public static void main(String[] args) {
        System.out.println(validarAlta("LIB-0001", "Java Efectivo", 2018, "978-0000000001")
                .describir());
        System.out.println();
        System.out.println(validarAlta("L-1", "", 1200, "12345").describir());
    }
}

Salida:

OK

Rechazado (4 problemas):
  - La referencia debe tener el formato LIB-NNNN, y era: L-1
  - El titulo no puede estar vacio
  - El anio debe estar entre 1450 y 2100, y era: 1200
  - El ISBN debe tener el formato 978-NNNNNNNNNN, y era: 12345

Con excepciones, el usuario habría corregido la referencia, reenviado, descubierto lo del título, corregido, reenviado... cuatro vueltas. Con el objeto resultado, ve los cuatro problemas de una vez.

Cuándo usar cada mecanismo:

Mecanismo Cuándo
Excepción El fallo es excepcional y el llamador normalmente no puede continuar
Optional La ausencia de valor es un resultado normal
Objeto resultado Hay varios errores que comunicar a la vez, o el fallo es tan frecuente que es parte del flujo
boolean Solo cuando "no" es una respuesta legítima y única (Set.add)

  1. Idempotencia y estado coherente

Dos propiedades que hacen que un sistema sobreviva a los fallos.

Idempotente significa que repetir la operación produce el mismo resultado que hacerla una vez.

// NO idempotente: dos llamadas, dos prestamos
gestor.prestar("LIB-0001", "EMP-001", 12);

// Idempotente: la segunda llamada no hace nada
sesion.close();
colaReservas.cancelar("LIB-0001", "EMP-001");   // si no habia, no pasa nada

Por qué importa aquí: una operación idempotente se puede reintentar sin miedo. Si no lo es, un reintento tras un fallo de red puede duplicar el efecto. Es la cuarta condición del apartado 7.

La técnica habitual para hacer idempotente algo que no lo es de forma natural: un identificador de operación que permita detectar el duplicado.

public Prestamo prestar(String idOperacion, String referencia, String idEmpleado, int dia) {
    // Si esta operacion ya se proceso, se devuelve el resultado anterior
    Prestamo yaHecho = operacionesProcesadas.get(idOperacion);
    if (yaHecho != null) {
        LOG.info(() -> "Operacion " + idOperacion + " ya procesada; se devuelve el resultado previo");
        return yaHecho;
    }
    // ... proceso normal ...
}

Estado coherente es lo que resolviste en 06-05 con el patrón de banderas y compensación en finally. La regla que lo resume:

Una operación debe dejar el sistema en un estado válido, tanto si termina como si falla. Nunca a medias.

Y el orden que lo facilita, aplicando lo de 06-03:

  1. Validar todo (no modifica nada).
  2. Preparar los objetos nuevos (aún no visibles para nadie).
  3. Aplicar los cambios, lo más tarde y lo más juntos posible.
  4. Compensar en finally si no se llegó al final.

  1. Por qué System.out.println no sirve en producción

Empieza la segunda mitad. Todo el diagnóstico de BiblioTech hasta ahora ha sido System.out.println, y hay que sustituirlo. Estos son los siete motivos:

Problema Consecuencia real
No se puede filtrar O ves todo o no ves nada. Con mil líneas por minuto, encontrar la relevante es imposible
No se puede desactivar El diagnóstico de depuración sigue imprimiéndose en producción, con su coste
No tiene marca de tiempo "¿Cuándo ocurrió esto? ¿Antes o después de aquello?" Sin respuesta
No tiene nivel Un aviso rutinario y un fallo grave se ven exactamente igual
No dice de dónde viene Ni clase ni método ni hilo. En un programa multihilo, es ilegible
No se archiva ni rota Se pierde al cerrar la terminal. Y si rediriges a fichero, crece sin límite hasta llenar el disco
Es síncrono y bloqueante Escribir en consola bloquea el hilo. En un bucle caliente, hunde el rendimiento

Compara la misma información:

// System.out.println
System.out.println("AVISO: referencia duplicada");
AVISO: referencia duplicada
// Logger
LOG.warning("Alta rechazada: referencia LIB-0001 duplicada, ya la usa 'Java Efectivo'");
2026-08-05 11:42:07.312 [main] WARNING com.nexussoftware.bibliotech.servicio.Catalogo registrar
  Alta rechazada: referencia LIB-0001 duplicada, ya la usa 'Java Efectivo'

El segundo se puede filtrar por nivel, por clase o por hilo; se puede archivar y rotar; se puede desactivar sin tocar el código; y dice cuándo, dónde y qué.

Y una regla que confunde a mucha gente:

System.out.println sigue siendo correcto para la SALIDA del programa, es decir, para lo que el usuario ha pedido ver: el menú, el listado del catálogo, el recibo. Lo que hay que sustituir es el diagnóstico: los avisos, las trazas, los errores.

La prueba para distinguirlos: ¿esto lo ha pedido el usuario? Si sí, es salida. Si no, es log.

  1. Los niveles de log y qué registrar en cada uno

java.util.logging define siete niveles. Estos son, con su equivalencia en SLF4J/Logback, que es lo que verás en el ecosistema real:

java.util.logging SLF4J/Logback Cuándo usarlo Ejemplo en BiblioTech
SEVERE ERROR El sistema no puede hacer su trabajo. Requiere intervención Índice del catálogo corrupto; error no controlado
WARNING WARN Algo va mal pero se ha podido continuar No había catálogo previo; el servicio de avisos no responde
INFO INFO Hitos del funcionamiento normal Arranque, apagado, préstamo completado, importación terminada
CONFIG DEBUG Parámetros de configuración al arrancar dias.prestamo=15, tarifa.diaria=0.25
FINE DEBUG Detalle útil para depurar Entrada y salida de los métodos de servicio
FINER TRACE Más detalle aún Cada iteración de un bucle de importación
FINEST TRACE Máximo detalle Volcado de estructuras internas

La guía práctica, que es lo que de verdad hay que interiorizar:

SEVERE/ERROR — Alguien tiene que mirar esto. Si registras a este nivel algo que no requiere intervención, estás entrenando al equipo a ignorar los errores. Es el fallo más común de los sistemas de log mal configurados: cuando todo es un error, nada lo es.

WARNING/WARN — Ha ido mal pero seguimos. Degradaciones, reintentos, configuraciones sospechosas, uso de valores por defecto porque faltaba un parámetro.

INFO — Los hitos del negocio. Debe poder leerse como la historia de lo que ha hecho la aplicación. Si tiene tanto ruido que no se puede leer, hay cosas ahí que deberían ser FINE.

CONFIG/FINE/FINER/FINEST — Desactivados en producción por defecto. Se activan cuando hay que investigar algo concreto. Por eso deben ser baratos cuando están desactivados, que es de lo que trata el siguiente apartado.

Y un error de criterio muy frecuente: una excepción capturada y manejada correctamente no es un SEVERE. Si el catálogo no existía y arrancaste vacío, eso es un WARNING: el sistema hizo lo previsto. SEVERE es para lo que nadie ha previsto.

  1. java.util.logging en la práctica

java.util.logging (JUL) viene en el JDK, sin dependencias. Es la elección para este curso precisamente por eso: puedes ejecutar todo sin descargar nada. En 11-07 verás el estándar real del ecosistema.

Obtener un Logger:

package com.nexussoftware.bibliotech.servicio;

import java.util.logging.Logger;

public class Catalogo {

    /**
     * Un Logger por clase, estatico y final, nombrado con el nombre completo
     * de la clase. Es la convencion universal, y permite filtrar por paquete:
     * activar el nivel FINE para com.nexussoftware.bibliotech.servicio y dejar
     * el resto en INFO.
     */
    private static final Logger LOG = Logger.getLogger(Catalogo.class.getName());

    // ...
}

Por qué static final: porque el Logger no depende de la instancia, obtenerlo es relativamente caro y así se hace una sola vez por clase.

Registrar mensajes:

LOG.severe("Mensaje grave");
LOG.warning("Aviso");
LOG.info("Informacion");
LOG.config("Configuracion");
LOG.fine("Detalle de depuracion");

// Forma general, con el nivel explicito
LOG.log(Level.INFO, "Mensaje");

// CON EXCEPCION: la forma correcta de registrar un fallo
LOG.log(Level.SEVERE, "Mensaje descriptivo", excepcion);

La forma perezosa con Supplier (Java 8+), que es importante:

// MAL: la concatenacion se hace SIEMPRE, aunque FINE este desactivado
LOG.fine("Buscando " + referencia + " en un catalogo de " + materiales.size()
        + " materiales, indice con " + indice.size() + " entradas");

// BIEN: la lambda solo se evalua si el nivel FINE esta activo
LOG.fine(() -> "Buscando " + referencia + " en un catalogo de " + materiales.size()
        + " materiales, indice con " + indice.size() + " entradas");

Es el mismo principio de evaluación perezosa de las lambdas de 04-05 y del Objects.requireNonNull con Supplier de 06-03. En un método muy llamado, la diferencia entre construir esa cadena un millón de veces y no construirla ninguna es perfectamente medible.

La alternativa clásica, antes de las lambdas, era comprobar antes:

if (LOG.isLoggable(Level.FINE)) {
    LOG.fine("Mensaje caro: " + calcularDiagnostico());
}

Sigue siendo válida y a veces necesaria —cuando el cálculo del mensaje es caro de verdad—, pero la forma con Supplier es más limpia.

La jerarquía de loggers. Los Logger se organizan por su nombre, separado por puntos, formando un árbol:

flowchart TB
    R["'' (raiz)<br/>nivel INFO, ConsoleHandler"]
    R --> C["com"]
    C --> N["com.nexussoftware"]
    N --> B["com.nexussoftware.bibliotech"]
    B --> D["...bibliotech.dominio"]
    B --> S["...bibliotech.servicio<br/>nivel FINE"]
    B --> P["...bibliotech.presentacion"]
    S --> S1["...servicio.Catalogo"]
    S --> S2["...servicio.GestorPrestamos"]

Un Logger sin nivel propio hereda el de su padre, y sus mensajes se propagan a los Handler de todos sus ancestros. Esto es lo que permite configurar con precisión:

// Activar el detalle SOLO en la capa de servicio
Logger.getLogger("com.nexussoftware.bibliotech.servicio").setLevel(Level.FINE);
// El resto sigue en INFO

  1. Handler, Formatter y logging.properties

Tres piezas que hay que distinguir:

Pieza Qué hace
Logger Recibe los mensajes y decide si pasan el filtro de nivel
Handler Los envía a un destino: consola, fichero, socket, memoria
Formatter Convierte el registro en texto
Filter Criterio adicional más fino que el nivel

Los Handler incluidos en el JDK:

Handler Destino
ConsoleHandler System.err
FileHandler Un fichero, con rotación por tamaño y número
StreamHandler Un OutputStream cualquiera
SocketHandler Un host y puerto remotos
MemoryHandler Un búfer circular en memoria

Configuración programática completa para BiblioTech:

package com.nexussoftware.bibliotech.presentacion;

import java.io.IOException;
import java.util.logging.ConsoleHandler;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

/**
 * Configuracion del sistema de registro de BiblioTech.
 *
 * Dos destinos:
 *   - CONSOLA: solo INFO y superiores, en formato compacto de una linea.
 *   - FICHERO: todo desde FINE, con rotacion, para diagnostico posterior.
 */
public final class ConfiguracionLog {

    private static final String RUTA_LOG = "bibliotech-%g.log";   // %g = numero de rotacion
    private static final int TAMANO_MAXIMO = 1_000_000;           // 1 MB por fichero
    private static final int FICHEROS_ROTACION = 5;               // 5 ficheros como maximo

    private static boolean inicializado = false;

    private ConfiguracionLog() { }

    public static synchronized void inicializar() {
        if (inicializado) {
            return;                          // idempotente (06-06)
        }
        inicializado = true;

        Logger raiz = Logger.getLogger("");

        // 1. Quitar los handlers por defecto: los sustituimos por los nuestros
        for (Handler h : raiz.getHandlers()) {
            raiz.removeHandler(h);
        }

        // 2. El logger raiz acepta todo; cada handler filtra por su cuenta
        raiz.setLevel(Level.ALL);

        // 3. CONSOLA: solo lo importante, formato compacto
        ConsoleHandler consola = new ConsoleHandler();
        consola.setLevel(Level.INFO);
        consola.setFormatter(new FormatoCompacto());
        raiz.addHandler(consola);

        // 4. FICHERO: todo el detalle, con rotacion
        try {
            FileHandler fichero = new FileHandler(RUTA_LOG, TAMANO_MAXIMO, FICHEROS_ROTACION, true);
            fichero.setLevel(Level.FINE);
            fichero.setFormatter(new FormatoDetallado());
            raiz.addHandler(fichero);
        } catch (IOException e) {
            // Si no se puede crear el fichero, la aplicacion NO debe caerse:
            // se degrada a solo consola y se avisa. Degradacion elegante.
            raiz.log(Level.WARNING,
                    "No se pudo crear el fichero de log; solo habra registro en consola", e);
        }

        // 5. Nivel especifico por paquete
        Logger.getLogger("com.nexussoftware.bibliotech.servicio").setLevel(Level.FINE);
        Logger.getLogger("com.nexussoftware.bibliotech.dominio").setLevel(Level.INFO);

        Logger.getLogger(ConfiguracionLog.class.getName()).config("Sistema de registro inicializado");
    }

    /** Una linea por evento: nivel, clase corta y mensaje. */
    private static class FormatoCompacto extends Formatter {
        @Override
        public String format(LogRecord registro) {
            String clase = registro.getSourceClassName();
            String corta = (clase == null) ? "?" : clase.substring(clase.lastIndexOf('.') + 1);

            StringBuilder sb = new StringBuilder();
            sb.append(String.format("[%-7s] %-18s %s%n",
                    registro.getLevel().getName(), corta, formatMessage(registro)));

            if (registro.getThrown() != null) {
                sb.append("          causa: ").append(registro.getThrown()).append('\n');
            }
            return sb.toString();
        }
    }

    /** Formato completo para el fichero, con marca de tiempo, hilo y stack trace. */
    private static class FormatoDetallado extends Formatter {
        @Override
        public String format(LogRecord registro) {
            StringBuilder sb = new StringBuilder();

            // Marca de tiempo. Se usa el instante en milisegundos y se formatea
            // sin java.time, que es materia de 10-05.
            sb.append(String.format("%tF %<tT.%<tL", registro.getMillis()));
            sb.append(String.format(" [%-6d] %-7s ",
                    registro.getLongThreadID(), registro.getLevel().getName()));
            sb.append(registro.getSourceClassName()).append('.')
              .append(registro.getSourceMethodName()).append(" - ");
            sb.append(formatMessage(registro)).append('\n');

            // El stack trace completo, indentado
            Throwable t = registro.getThrown();
            if (t != null) {
                sb.append("    ").append(t).append('\n');
                for (StackTraceElement marco : t.getStackTrace()) {
                    sb.append("        at ").append(marco).append('\n');
                }
                // Causas y suprimidas (06-01, 06-06)
                Throwable causa = t.getCause();
                int nivel = 1;
                while (causa != null && nivel <= 5) {
                    sb.append("    Caused by: ").append(causa).append('\n');
                    causa = causa.getCause();
                    nivel++;
                }
                for (Throwable suprimida : t.getSuppressed()) {
                    sb.append("    Suppressed: ").append(suprimida).append('\n');
                }
            }
            return sb.toString();
        }
    }
}

Configuración por fichero. La alternativa a configurar en código es un fichero logging.properties, que se activa al arrancar la JVM:

# logging.properties de BiblioTech

# Handlers globales
handlers = java.util.logging.ConsoleHandler, java.util.logging.FileHandler

# Nivel del logger raiz: deja pasar todo, los handlers filtran
.level = ALL

# --- Consola: solo INFO y superiores ---
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

# --- Fichero: todo desde FINE, con rotacion ---
java.util.logging.FileHandler.pattern = bibliotech-%g.log
java.util.logging.FileHandler.limit = 1000000
java.util.logging.FileHandler.count = 5
java.util.logging.FileHandler.append = true
java.util.logging.FileHandler.level = FINE
java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter

# Formato de SimpleFormatter: fecha, nivel, clase.metodo, mensaje, excepcion
java.util.logging.SimpleFormatter.format = %1$tF %1$tT.%1$tL %4$-7s [%2$s] %5$s%6$s%n

# --- Niveles por paquete ---
com.nexussoftware.bibliotech.level = INFO
com.nexussoftware.bibliotech.servicio.level = FINE
com.nexussoftware.bibliotech.dominio.level = INFO

Y se arranca así:

java -Djava.util.logging.config.file=logging.properties \
     -cp . com.nexussoftware.bibliotech.presentacion.BiblioTechApp

La ventaja decisiva de la configuración por fichero: se puede cambiar el nivel de log sin recompilar ni tocar el código. En producción, cuando hay que investigar un problema, se sube el nivel a FINE en el paquete afectado, se reproduce, y se vuelve a bajar.

  1. Registrar una excepción correctamente

La forma correcta y las incorrectas, una al lado de otra:

try {
    gestor.prestar(referencia, idEmpleado, dia);

} catch (BiblioTechException e) {

    // ==================== CORRECTO ====================
    // El Throwable va como TERCER argumento: el Formatter imprime el stack
    // trace completo, con sus causas y suprimidas, en el mismo evento de log.
    LOG.log(Level.WARNING, "No se pudo prestar " + referencia + " a " + idEmpleado, e);

    // ==================== INCORRECTO ====================

    // 1. printStackTrace: va a System.err, sin nivel, sin fecha, sin filtro
    e.printStackTrace();

    // 2. Solo el mensaje: pierde el stack trace y todas las causas
    LOG.warning("Error: " + e.getMessage());

    // 3. Concatenar la excepcion: da solo toString(), sin pila
    LOG.warning("Error: " + e);

    // 4. Registrar Y relanzar: el mismo fallo aparecera dos veces
    LOG.log(Level.SEVERE, "Error", e);
    throw e;
}

Sobre el cuarto caso, que merece su propia regla:

Registra donde manejas. Si vas a relanzar, no registres el stack trace completo: la capa que finalmente maneje el error lo hará, y con más contexto. Como mucho, añade una línea de contexto a nivel FINE.

Si cada capa registra y relanza, un solo fallo produce cinco eventos SEVERE con cinco stack traces casi idénticos, y encontrar el primero se convierte en arqueología.

Y una advertencia sobre el nivel: una excepción capturada y manejada correctamente rara vez es SEVERE. El MaterialNoEncontradoException que se produce porque el usuario escribió mal una referencia es INFO o FINE, no un error del sistema. Reserva SEVERE para lo que requiere que alguien intervenga.

  1. Qué NO registrar nunca

Un log es un fichero que se copia, se envía por correo, se agrega a un sistema centralizado y se guarda meses. Todo lo que escribas ahí sale del control de tu aplicación.

Nunca registres:

Categoría Ejemplos
Credenciales Contraseñas, incluso cifradas o "solo los primeros caracteres"
Tokens y claves Tokens de sesión, claves de API, cookies de autenticación
Datos financieros Números de tarjeta, CVV, cuentas bancarias
Datos personales sensibles DNI, dirección, salud, datos biométricos
Contenido completo de peticiones Puede llevar cualquiera de los anteriores dentro
Volcados de objetos enteros LOG.fine("Usuario: " + usuario) imprimirá lo que haya en su toString(), hoy y dentro de dos años

Ese último punto es el más traicionero, porque el fallo aparece después:

// Hoy: Empleado.toString() devuelve "EMP-001 (Marta Ruiz)". Parece inofensivo.
LOG.fine(() -> "Procesando " + empleado);

// Dentro de un ano alguien anade el DNI y el telefono al toString()...
// y de repente estas registrando datos personales en todos los ficheros de log,
// sin que nadie haya tocado esta linea.

La defensa: registra campos concretos, no objetos.

LOG.fine(() -> "Procesando empleado " + empleado.getIdentificador());

Cuando necesites registrar algo parcialmente identificable, enmascáralo:

/** Deja visibles los ultimos 4 caracteres: 978-0000000001 -> ***0001 */
private static String enmascarar(String valor) {
    if (valor == null || valor.length() <= 4) {
        return "****";
    }
    return "***" + valor.substring(valor.length() - 4);
}

Advertencia formal: en un sistema real, qué datos personales se pueden registrar, durante cuánto tiempo y con qué medidas de protección no es una decisión técnica. Está regulado —en la Unión Europea, por el RGPD— y debe revisarlo el responsable de protección de datos o el departamento de cumplimiento normativo de tu organización. Ante la duda, no lo registres y pregunta. Un log con datos personales mal gestionados es un incidente de seguridad y puede acarrear sanciones.

En BiblioTech, EMP-001 es un identificador interno perfectamente registrable; el nombre completo de la persona ya es un dato personal, y en un sistema real habría que consultarlo antes de meterlo en el log.

  1. Mensajes de log útiles y correlación

Un mensaje de log responde a cinco preguntas: qué pasó, con qué datos, en qué operación, con qué resultado y —si falló— por qué.

Mensaje Valoración
"Error" Inútil
"Error al prestar" Falta todo el contexto
"Error al prestar LIB-0001" Falta quién y cuándo
"Prestamo rechazado: LIB-0001 a EMP-001 el dia 12. Motivo: material no disponible hasta el dia 27" Completo

Los identificadores que siempre deben aparecer en BiblioTech:

  • Referencia del material (LIB-0001) y referencia del préstamo (PR-0007).
  • Identificador del empleado (EMP-001), no su nombre.
  • Día de la operación.
  • Identificador de operación, para correlacionar varias líneas del mismo flujo.

Sobre ese último punto: en un sistema con muchas operaciones concurrentes, las líneas de log de distintos flujos se entrelazan y no hay forma de saber cuáles pertenecen a la misma. La solución es un identificador de correlación que se propague por todas las capas y aparezca en cada mensaje:

LOG.info(() -> "[" + idOperacion + "] Prestamo " + referenciaPrestamo + " completado");

Con él, filtrar por INC-A3F91C0B en el fichero devuelve la historia completa de esa operación, de principio a fin, aunque hubiera cien más en paralelo. Los sistemas modernos lo automatizan con el MDC de SLF4J o con trazabilidad distribuida (OpenTelemetry), y el log estructurado —eventos en JSON con campos nombrados en lugar de texto libre— permite además consultarlos como si fueran una base de datos. Ambos temas se retoman en 11-07 y en 12-07.

  1. El ecosistema real: SLF4J, Logback y Log4j2

java.util.logging es lo que has usado aquí porque viene en el JDK y no requiere instalar nada. Pero en un proyecto profesional te encontrarás otra cosa.

Componente Qué es
SLF4J Una fachada: una API de logging sin implementación. Tu código depende solo de ella
Logback La implementación más usada, del mismo autor que SLF4J. La que trae Spring Boot por defecto
Log4j2 La otra gran implementación, con muy buen rendimiento gracias a su registro asíncrono
java.util.logging La del JDK. Menos flexible, pero sin dependencias

Por qué existe la fachada: tu código escribe contra SLF4J, y en el momento del despliegue se elige la implementación. Si mañana cambias de Logback a Log4j2, no tocas ni una línea de tu código: cambias una dependencia. Es exactamente el principio de programar contra interfaces de 04-01, aplicado a las librerías.

Así se ve el mismo código con SLF4J:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Catalogo {

    private static final Logger LOG = LoggerFactory.getLogger(Catalogo.class);

    public void registrar(Material material) {
        // Sintaxis de marcadores {}: sin concatenacion y sin lambda.
        // El mensaje solo se compone si el nivel esta activo.
        LOG.debug("Registrando {} en un catalogo de {} materiales",
                  material.getReferencia(), materiales.size());
        // ...
    }
}

Las ventajas frente a JUL: la sintaxis de marcadores {} resuelve el problema de la evaluación perezosa sin lambdas, la configuración en XML o YAML es mucho más expresiva, hay appenders para todos los destinos imaginables, y el soporte de log estructurado en JSON viene de serie.

Todo lo que has aprendido en esta lección se traslada tal cual: los niveles, un logger por clase, registrar la excepción como argumento y no concatenarla, no registrar datos sensibles, registrar donde manejas. Solo cambia la API. Las librerías esenciales del ecosistema, incluida esta, se ven en 11-07.

  1. BiblioTech: la refactorización final

Ahora todo junto. Primero, el Catalogo con logging en lugar de println:

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;

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

/**
 * Catalogo de BiblioTech, version final del modulo 6.
 *
 * Cambios respecto a 06-04:
 *   - Cero System.out.println: todo el diagnostico va al Logger.
 *   - Los mensajes de log llevan identificadores, no nombres de personas.
 *   - Los mensajes caros usan Supplier para no construirse si el nivel esta desactivado.
 */
public class Catalogo {

    private static final Logger LOG = Logger.getLogger(Catalogo.class.getName());

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

    private String propietarioDelBloqueo = null;

    public void registrar(Material material) {
        Objects.requireNonNull(material, "El material a registrar no puede ser nulo");
        String referencia = material.getReferencia();

        // FINE: detalle de depuracion, desactivado en produccion.
        // Con Supplier, la concatenacion no se ejecuta si FINE esta apagado.
        LOG.fine(() -> "Registrando " + referencia + " (catalogo actual: "
                + materiales.size() + " materiales)");

        Material existente = indicePorReferencia.get(referencia);
        if (existente != null) {
            // Se registra a nivel FINE y se LANZA. No se registra a nivel superior
            // porque quien capture decidira su gravedad: en una importacion masiva
            // un duplicado es rutina, en un alta manual es un aviso.
            LOG.fine(() -> "Alta rechazada: referencia " + referencia + " duplicada");
            throw new ReferenciaDuplicadaException(
                    ReferenciaDuplicadaException.Tipo.REFERENCIA, referencia,
                    existente.getTitulo());
        }

        if (material instanceof Libro libro) {
            String tituloExistente = titulosPorIsbn.get(libro.getIsbn());
            if (tituloExistente != null) {
                LOG.fine(() -> "Alta rechazada: ISBN duplicado en " + referencia);
                throw new ReferenciaDuplicadaException(
                        ReferenciaDuplicadaException.Tipo.ISBN, libro.getIsbn(), tituloExistente);
            }
            titulosPorIsbn.put(libro.getIsbn(), libro.getTitulo());
        }

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

        // INFO: hito de negocio. Se lee como la historia de lo que hace la aplicacion.
        LOG.info(() -> "Material registrado: " + referencia
                + " (total " + materiales.size() + ")");
    }

    public Material obtenerPorReferencia(String referencia) {
        Objects.requireNonNull(referencia, "La referencia no puede ser nula");

        Material encontrado = indicePorReferencia.get(referencia);
        if (encontrado == null) {
            LOG.fine(() -> "Referencia no encontrada: " + referencia);
            throw new MaterialNoEncontradoException(referencia, materiales.size());
        }
        return encontrado;
    }

    public Optional<Material> buscarPorReferencia(String referencia) {
        if (referencia == null) { return Optional.empty(); }
        return Optional.ofNullable(indicePorReferencia.get(referencia));
    }

    public void bloquear(String idPropietario) {
        if (propietarioDelBloqueo != null && !propietarioDelBloqueo.equals(idPropietario)) {
            LOG.warning(() -> "Bloqueo denegado a " + idPropietario
                    + ": lo tiene " + propietarioDelBloqueo);
            throw new IllegalStateException(
                    "El catalogo esta bloqueado por " + propietarioDelBloqueo);
        }
        propietarioDelBloqueo = idPropietario;
        LOG.fine(() -> "Bloqueo del catalogo adquirido por " + idPropietario);
    }

    public void desbloquear(String idPropietario) {
        if (!Objects.equals(propietarioDelBloqueo, idPropietario)) {
            // WARNING: algo raro pasa, pero no impide continuar
            LOG.warning(() -> "Intento de desbloqueo por " + idPropietario
                    + " sin ser el propietario (" + propietarioDelBloqueo + ")");
            return;
        }
        propietarioDelBloqueo = null;
        LOG.fine(() -> "Bloqueo del catalogo liberado por " + idPropietario);
    }

    public boolean estaBloqueado()  { return propietarioDelBloqueo != null; }
    public boolean existe(String r) { return r != null && indicePorReferencia.containsKey(r); }
    public int tamano()             { return materiales.size(); }
    public List<Material> listar()  { return List.copyOf(materiales); }
}

Ahora GestorPrestamos, con logging, identificador de operación y compensación:

package com.nexussoftware.bibliotech.servicio;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.*;

/** Orquesta prestamos con registro completo y estado coherente. */
public class GestorPrestamos {

    private static final Logger LOG = Logger.getLogger(GestorPrestamos.class.getName());

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

    private int contador = 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");
        empleados.put(empleado.getIdentificador(), empleado);
        // Se registra el IDENTIFICADOR, no el nombre: el nombre es un dato personal
        LOG.info(() -> "Empleado dado de alta: " + empleado.getIdentificador());
    }

    public Prestamo prestar(String idOperacion, String referencia, String idEmpleado, int dia) {
        Objects.requireNonNull(idOperacion, "El identificador de operacion no puede ser nulo");
        Objects.requireNonNull(referencia, "La referencia no puede ser nula");
        Objects.requireNonNull(idEmpleado, "El identificador de empleado no puede ser nulo");
        if (dia < 1) {
            throw new IllegalArgumentException("El dia debe ser 1 o posterior, y era: " + dia);
        }

        LOG.fine(() -> "[" + idOperacion + "] Iniciando prestamo de " + referencia
                + " a " + idEmpleado + " el dia " + dia);

        // --- Fase 1: lectura y validacion (no modifica nada) ---
        Material material = catalogo.obtenerPorReferencia(referencia);
        Empleado empleado = empleados.get(idEmpleado);
        if (empleado == null) {
            throw new MaterialNoEncontradoException(idEmpleado, empleados.size());
        }
        if (!material.estaDisponible()) {
            throw new MaterialNoDisponibleException(referencia, material.getTitulo(),
                    "otro empleado", dia + Prestamo.DIAS_PRESTAMO);
        }
        if (!empleado.puedeTomarPrestado()) {
            throw new LimitePrestamosExcedidoException(idEmpleado, empleado.getNombre(),
                    empleado.getPrestamosAcumulados(), Empleado.MAX_PRESTAMOS_SIMULTANEOS);
        }

        // --- Fase 2: modificacion con compensacion (06-05) ---
        boolean materialMarcado = false;
        boolean cupoConsumido = false;
        boolean completado = false;
        String referenciaPrestamo = siguienteReferencia();

        try {
            material.prestar();
            materialMarcado = true;

            empleado.registrarPrestamo();
            cupoConsumido = true;

            Prestamo prestamo = new Prestamo(referenciaPrestamo, material, empleado, dia);
            prestamos.put(referenciaPrestamo, prestamo);
            completado = true;

            LOG.info(() -> "[" + idOperacion + "] Prestamo " + referenciaPrestamo
                    + " registrado: " + referencia + " -> " + idEmpleado + " (dia " + dia + ")");
            return prestamo;

        } finally {
            if (!completado) {
                // WARNING, no SEVERE: la compensacion es el comportamiento previsto
                LOG.warning(() -> "[" + idOperacion + "] Prestamo incompleto de " + referencia
                        + "; compensando");
                try {
                    if (cupoConsumido)   { empleado.registrarDevolucion(); }
                    if (materialMarcado) { material.devolver(); }
                    LOG.fine(() -> "[" + idOperacion + "] Estado restaurado");
                } catch (RuntimeException fallo) {
                    // SEVERE de verdad: la compensacion fallo y el estado puede
                    // haber quedado incoherente. Esto SI requiere intervencion.
                    LOG.log(Level.SEVERE, "[" + idOperacion
                            + "] LA COMPENSACION FALLO. Estado posiblemente incoherente en "
                            + referencia, fallo);
                }
            }
        }
    }

    public void devolver(String idOperacion, String referenciaPrestamo, int dia) {
        Prestamo prestamo = prestamos.get(referenciaPrestamo);
        if (prestamo == null) {
            throw new MaterialNoEncontradoException(referenciaPrestamo, prestamos.size());
        }
        if (prestamo.estaDevuelto()) {
            throw new PrestamoYaDevueltoException(referenciaPrestamo, prestamo.getDiaInicio());
        }

        prestamo.registrarDevolucion(dia);
        prestamo.getTitular().registrarDevolucion();

        int retraso = prestamo.calcularDiasRetraso(dia);
        if (retraso > 0) {
            LOG.info(() -> "[" + idOperacion + "] Devolucion de " + referenciaPrestamo
                    + " con " + retraso + " dias de retraso");
        } else {
            LOG.info(() -> "[" + idOperacion + "] Devolucion de " + referenciaPrestamo + " en plazo");
        }
    }

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

    public int prestamosRegistrados() { return prestamos.size(); }
}

Y la capa de presentación, que es donde se captura y se traduce:

package com.nexussoftware.bibliotech.presentacion;

import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.servicio.GestorPrestamos;

/**
 * Capa de presentacion: la UNICA que captura para el usuario, la unica que
 * imprime por consola lo que el usuario ha pedido ver, y la unica que decide
 * que mensaje mostrar en cada caso.
 */
public class OperacionesConsola {

    private static final Logger LOG = Logger.getLogger(OperacionesConsola.class.getName());

    private final GestorPrestamos gestor;
    private int contadorOperaciones = 0;

    public OperacionesConsola(GestorPrestamos gestor) {
        this.gestor = gestor;
    }

    /**
     * Ejecuta un prestamo traduciendo cada fallo a un mensaje comprensible.
     *
     * Aqui SI se captura: es la frontera con el usuario y hay decisiones que tomar.
     */
    public void prestar(String referencia, String idEmpleado, int dia) {
        String idOperacion = siguienteIdOperacion();

        try {
            gestor.prestar(idOperacion, referencia, idEmpleado, dia);
            // SALIDA del programa, no log: es lo que el usuario ha pedido ver
            System.out.println("Prestado " + referencia + " a " + idEmpleado
                    + ". Devolucion prevista: dia " + (dia + Prestamo.DIAS_PRESTAMO));

        } catch (MaterialNoEncontradoException e) {
            // Fallo esperado y corregible: mensaje amable, log de bajo nivel
            System.out.println("No existe el material '" + e.getReferenciaBuscada() + "'.");
            if (e.catalogoVacio()) {
                System.out.println("El catalogo esta vacio: cargue primero los materiales.");
            }
            LOG.fine(() -> "[" + idOperacion + "] Referencia inexistente: "
                    + e.getReferenciaBuscada());

        } catch (MaterialNoDisponibleException e) {
            System.out.println("'" + e.getTitulo() + "' esta prestado.");
            System.out.println("Disponible previsiblemente en " + e.diasDeEspera(dia) + " dias.");
            System.out.println("Puede reservarlo con la opcion 5.");
            LOG.fine(() -> "[" + idOperacion + "] Material no disponible: " + e.getReferencia());

        } catch (LimitePrestamosExcedidoException e) {
            System.out.printf("Ha alcanzado el limite de %d prestamos simultaneos.%n",
                    e.getLimite());
            System.out.printf("Debe devolver %d material(es) antes de tomar otro.%n",
                    e.devolucionesNecesarias());
            LOG.fine(() -> "[" + idOperacion + "] Limite excedido por " + e.getIdEmpleado());

        } catch (BiblioTechException e) {
            // Red de seguridad del dominio: capturable gracias a la raiz comun (06-04)
            System.out.println("No se pudo completar la operacion: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + idOperacion + "] Fallo de dominio no previsto ["
                    + e.getCodigo() + "]", e);

        } catch (RuntimeException e) {
            // BUG: el usuario no ve los detalles, el registro los ve todos
            System.out.println("Se ha producido un error interno.");
            System.out.println("Incidencia: " + idOperacion);
            LOG.log(Level.SEVERE, "[" + idOperacion + "] Error no controlado al prestar "
                    + referencia + " a " + idEmpleado, e);
        }
    }

    private String siguienteIdOperacion() {
        contadorOperaciones++;
        return String.format("OP-%05d", contadorOperaciones);
    }

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

    public static void main(String[] args) {
        ConfiguracionLog.inicializar();

        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"));

        OperacionesConsola consola = new OperacionesConsola(gestor);

        System.out.println("\n----- Operaciones -----");
        consola.prestar("LIB-0001", "EMP-001", 10);
        consola.prestar("LIB-0001", "EMP-002", 11);    // no disponible
        consola.prestar("LIB-9999", "EMP-001", 11);    // no encontrado
        consola.prestar("LIB-0002", "EMP-001", 11);
        consola.prestar("LIB-0003", "EMP-001", 11);
        consola.prestar("LIB-0002", "EMP-001", 12);    // no disponible (ya lo tiene)
    }
}

Salida en consola (nivel INFO y superiores del log, más la salida del programa):

[INFO   ] Catalogo           Material registrado: LIB-0001 (total 1)
[INFO   ] Catalogo           Material registrado: LIB-0002 (total 2)
[INFO   ] Catalogo           Material registrado: LIB-0003 (total 3)
[INFO   ] GestorPrestamos    Empleado dado de alta: EMP-001
[INFO   ] GestorPrestamos    Empleado dado de alta: EMP-002

----- Operaciones -----
[INFO   ] GestorPrestamos    [OP-00001] Prestamo PR-0001 registrado: LIB-0001 -> EMP-001 (dia 10)
Prestado LIB-0001 a EMP-001. Devolucion prevista: dia 25
'Java Efectivo' esta prestado.
Disponible previsiblemente en 14 dias.
Puede reservarlo con la opcion 5.
No existe el material 'LIB-9999'.
[INFO   ] GestorPrestamos    [OP-00004] Prestamo PR-0002 registrado: LIB-0002 -> EMP-001 (dia 11)
Prestado LIB-0002 a EMP-001. Devolucion prevista: dia 26
[INFO   ] GestorPrestamos    [OP-00005] Prestamo PR-0003 registrado: LIB-0003 -> EMP-001 (dia 11)
Prestado LIB-0003 a EMP-001. Devolucion prevista: dia 26
'Patrones de Diseno' esta prestado.
Disponible previsiblemente en 14 dias.
Puede reservarlo con la opcion 5.

Y en bibliotech-0.log, con todo el detalle:

2026-08-05 11:42:07.104 [1     ] CONFIG  ...ConfiguracionLog.inicializar - Sistema de registro inicializado
2026-08-05 11:42:07.118 [1     ] FINE    ...Catalogo.registrar - Registrando LIB-0001 (catalogo actual: 0 materiales)
2026-08-05 11:42:07.119 [1     ] INFO    ...Catalogo.registrar - Material registrado: LIB-0001 (total 1)
...
2026-08-05 11:42:07.201 [1     ] FINE    ...GestorPrestamos.prestar - [OP-00002] Iniciando prestamo de LIB-0001 a EMP-002 el dia 11
2026-08-05 11:42:07.202 [1     ] FINE    ...OperacionesConsola.prestar - [OP-00002] Material no disponible: LIB-0001
2026-08-05 11:42:07.205 [1     ] FINE    ...Catalogo.obtenerPorReferencia - Referencia no encontrada: LIB-9999
2026-08-05 11:42:07.206 [1     ] FINE    ...OperacionesConsola.prestar - [OP-00003] Referencia inexistente: LIB-9999

Compara la consola y el fichero. La consola muestra lo que el usuario necesita: mensajes claros y los hitos importantes. El fichero contiene la historia completa, con marca de tiempo, hilo, clase, método e identificador de operación, filtrable por cualquiera de esos campos. Y no hay un solo System.out.println de diagnóstico en todo el proyecto.

Errores Comunes y Consejos

Capturar donde se produce en lugar de donde se decide. Un catch que solo imprime y devuelve null degrada una excepción con nombre y datos al null mudo que costó dos lecciones eliminar. Si no puedes decidir, no captures.

Mostrar un stack trace al usuario. Es un riesgo de seguridad —revela paquetes, versiones y rutas—, no le sirve de nada y destruye la confianza. Mensaje claro más identificador de incidencia; el trace, al registro.

Registrar y relanzar en cada capa. Un fallo aparece cinco veces con cinco trazas casi idénticas. Registra donde manejas.

Registrar todo como SEVERE. Cuando todo es un error, nadie mira los errores. SEVERE es solo para lo que requiere que alguien intervenga.

Concatenar en las llamadas de log de nivel bajo. LOG.fine("... " + x + " ...") construye la cadena aunque FINE esté desactivado. Usa la forma con Supplier: LOG.fine(() -> "...").

Registrar objetos completos. LOG.fine("Empleado: " + empleado) imprimirá lo que devuelva toString() hoy y dentro de dos años, cuando alguien le añada el DNI. Registra campos concretos.

Registrar credenciales, tokens o datos personales. Los logs se copian, se envían y se archivan durante meses. Es un incidente de seguridad, y en un sistema real la política la marca el responsable de protección de datos, no tú.

printStackTrace() en producción. Va a System.err sin nivel, sin fecha, sin filtro y sin contexto. Usa LOG.log(Level.X, "mensaje", e).

Reintentar operaciones no idempotentes. Si el préstamo se registró y solo falló la confirmación, el reintento crea un segundo préstamo. Solo se reintenta lo que se puede repetir sin efectos secundarios.

Confiar en la validación previa con recursos compartidos. Entre el exists() y el open() el fichero puede desaparecer. Es TOCTOU, y la excepción hay que manejarla de todos modos.

Un Logger no estático o creado en cada llamada. Obtenerlo tiene coste. private static final Logger LOG = Logger.getLogger(MiClase.class.getName());, una vez por clase.

Consejo: usa un identificador de operación en todos los mensajes de un mismo flujo. Convierte un log ilegible en una historia que se puede seguir con un solo filtro.

Consejo: lee tu propio log como si fueras el de guardia a las tres de la mañana. Si no puedes reconstruir qué pasó, faltan datos. Si hay tanto ruido que no encuentras nada, sobran líneas en INFO.

Consejo: configura por fichero, no en código. Poder subir el nivel a FINE en un paquete concreto sin recompilar es lo que salva una investigación en producción.

Consejo: al pasar a SLF4J en 11-07, todo esto se traslada tal cual. Solo cambia la API; los criterios son los mismos.

Ejercicios

Ejercicio 1: la política de errores de cada capa

Para cada uno de los ocho escenarios, responde: (a) en qué capa se detecta, (b) en qué capa debe capturarse, (c) qué nivel de log corresponde, (d) qué mensaje exacto ve el usuario y (e) qué mensaje exacto va al registro. Justifica cada decisión.

  1. Marta escribe "doce" donde se pedía el número de días.
  2. Libro recibe anioPublicacion = 1200 en su constructor.
  3. El fichero catalogo.txt no existe al arrancar.
  4. Un NullPointerException en Prestamo.descripcionTitular porque el titular es null.
  5. Diego intenta su cuarto préstamo simultáneo.
  6. El servicio de avisos no responde al notificar un vencimiento.
  7. El disco se llena mientras se exporta el catálogo.
  8. OutOfMemoryError durante una importación masiva.

Después, escribe la clase PoliticaDeErrores con un método String decidir(Throwable t) que, dado un Throwable, devuelva una recomendación con el nivel de log, si el mensaje es apto para el usuario y la acción sugerida. Apóyate en BiblioTechException.esCorregiblePorElUsuario().

Ejercicio 2: RegistroOperaciones con java.util.logging

Escribe RegistroOperaciones en com.nexussoftware.bibliotech.servicio, que envuelva cualquier operación de BiblioTech aportando registro completo y medición.

Requisitos:

  • <T> T ejecutar(String nombreOperacion, String idEmpleado, Supplier<T> operacion):
    • Genera un identificador de operación OP-NNNNN.
    • Registra a FINE el inicio con el identificador, el nombre y el empleado.
    • Mide la duración con System.nanoTime().
    • Si tiene éxito: registra a INFO el fin con la duración en milisegundos.
    • Si lanza una BiblioTechException: la registra a WARNING con la excepción como tercer argumento y la relanza.
    • Si lanza cualquier otra RuntimeException: la registra a SEVERE con el identificador como incidencia y la envuelve en una IllegalStateException cuyo mensaje incluya la incidencia, conservando la causa.
    • Usa try-finally para que la duración se registre también cuando falla.
  • Un método void volcarEstadisticas() que muestre, por nombre de operación: número de ejecuciones, éxitos, fallos y duración media.
  • Todos los mensajes deben usar la forma perezosa con Supplier.
  • No debe registrar el nombre de ningún empleado, solo su identificador.

En el main, configura el logging con dos handlers (consola a INFO, fichero a FINE), ejecuta al menos seis operaciones —correctas, con fallo de dominio y con bug— y muestra las estadísticas.

Ejercicio 3: auditoría de manejo de errores

Este es el MenuBiblioTech que ha sobrevivido a todo el módulo sin revisarse. Tiene diez problemas de manejo de errores y de logging. Encuéntralos, explica el daño de cada uno y reescribe la clase aplicando todo lo aprendido en el módulo.

public class MenuBiblioTech {

    public void arrancar() {
        while (true) {
            try {
                System.out.print("Opcion: ");
                int opcion = Integer.parseInt(new Scanner(System.in).nextLine());
                switch (opcion) {
                    case 1: prestar(); break;
                    case 2: devolver(); break;
                    case 0: System.exit(0);
                }
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
    }

    private void prestar() throws Exception {
        Scanner sc = new Scanner(System.in);
        System.out.print("Referencia: ");
        String ref = sc.nextLine();
        System.out.print("Empleado: ");
        String emp = sc.nextLine();

        try {
            gestor.prestar(ref, emp, 1);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
            System.out.println(e.getStackTrace()[0]);
            throw e;
        } finally {
            sc.close();
        }
    }

    private void devolver() {
        try {
            gestor.devolver(leerReferencia(), 1);
        } catch (Exception e) {
        }
        System.out.println("Devolucion procesada");
    }
}

Pista sobre dos de ellos que son fáciles de pasar por alto: uno tiene que ver con dónde se crea el Scanner y otro con lo que el último método afirma al usuario.

Soluciones

Solución 1

Tabla de decisiones:

# Escenario (a) Se detecta (b) Se captura (c) Nivel (d) Usuario (e) Registro
1 "doce" como número de días Presentación (parseInt) Presentación FINE "'doce' no es un numero. Escriba un entero entre 1 y 30." [OP-00012] Entrada no numerica en dias: 'doce'
2 anioPublicacion = 1200 Dominio (constructor) Presentación o importador WARNING en lote, FINE en alta manual "El anio debe estar entre 1450 y 2100. Ha escrito 1200." Alta rechazada de LIB-0007: anio 1200 fuera de rango
3 No existe catalogo.txt Persistencia main/arranque WARNING "No habia catalogo previo. Se empieza de cero." No se pudo cargar el catalogo desde 'catalogo.txt' + stack trace
4 NullPointerException por titular nulo Dominio Frontera SEVERE "Error interno. Incidencia INC-A3F91C0B." [INC-A3F91C0B] Error no controlado al emitir recibo + trace completo
5 Cuarto préstamo de Diego Dominio (Empleado) Presentación FINE "Ha alcanzado el limite de 3 prestamos. Devuelva 1 material." [OP-00018] Limite excedido por EMP-002 (3/3)
6 Servicio de avisos no responde Servicio Servicio (degradación) WARNING (nada, o "los avisos se enviarán más tarde") Servicio de avisos no disponible; 3 avisos en cola + trace
7 Disco lleno al exportar Persistencia (IOException) Presentación SEVERE "No se pudo exportar el catalogo. Compruebe el espacio en disco." Fallo de E/S al exportar a 'catalogo-export.txt' + trace
8 OutOfMemoryError JVM Solo la frontera SEVERE "Error irrecuperable. La aplicacion se cerrara." Error irrecuperable + clase y trace, con el registro protegido

Justificaciones de los casos menos evidentes:

Caso 2: el nivel depende del contexto, no del tipo. En una importación masiva, un año inválido en una línea de mil es rutina y merece WARNING en el informe agregado; en un alta manual es un error del usuario y basta FINE. Es la misma excepción con dos políticas, como en el ejercicio 3 de 06-02.

Caso 4: un NullPointerException es siempre un bug, nunca algo que el usuario pueda corregir. De ahí SEVERE y el mensaje genérico con incidencia: el usuario no debe ver Prestamo.java:97 ni saber que existe una clase Prestamo.

Caso 6: se captura en servicio, no en presentación, porque es ahí donde está el conocimiento para degradar: acumular los avisos en cola y seguir. La presentación no tiene por qué enterarse de que existe un servicio de avisos.

Caso 7: SEVERE aunque el usuario pueda actuar, porque un disco lleno afecta a todo el sistema y alguien de operaciones debe enterarse.

Caso 8: el único caso en que se captura un Error, y solo en la frontera, solo para dejar constancia antes de morir, y con el propio registro protegido porque escribir el log también puede fallar sin memoria.

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;
import java.util.NoSuchElementException;
import java.util.logging.Level;

import com.nexussoftware.bibliotech.dominio.BiblioTechException;

/**
 * Clasifica un Throwable y recomienda como tratarlo.
 * Materializa la tabla de decisiones de esta leccion.
 */
public final class PoliticaDeErrores {

    /** Recomendacion completa para un fallo concreto (record, 04-07). */
    public record Recomendacion(Level nivel, boolean mensajeAptoParaUsuario,
                                boolean requiereIncidencia, String accion) {

        @Override
        public String toString() {
            return String.format("nivel=%-7s | usuario=%-5s | incidencia=%-5s | %s",
                    nivel.getName(), mensajeAptoParaUsuario, requiereIncidencia, accion);
        }
    }

    private PoliticaDeErrores() { }

    public static Recomendacion decidir(Throwable t) {
        if (t == null) {
            return new Recomendacion(Level.INFO, false, false, "Nada que hacer");
        }

        // 1. Error de la JVM: irrecuperable
        if (t instanceof Error) {
            return new Recomendacion(Level.SEVERE, false, true,
                    "Registrar (protegido) y terminar de forma ordenada. NO continuar.");
        }

        // 2. Excepcion del dominio: fallo esperado, mensaje ya escrito para el usuario
        if (t instanceof BiblioTechException bte) {
            if (bte.esCorregiblePorElUsuario()) {
                return new Recomendacion(Level.FINE, true, false,
                        "Mostrar el mensaje tal cual y permitir reintentar.");
            }
            return new Recomendacion(Level.WARNING, true, false,
                    "Mostrar el mensaje e indicar que no depende del usuario.");
        }

        // 3. Fallos de E/S: condicion del entorno
        if (t instanceof IOException) {
            return new Recomendacion(Level.SEVERE, false, true,
                    "Degradar si hay alternativa; si no, abortar la operacion. Avisar a operaciones.");
        }

        // 4. Validaciones tecnicas frecuentes en la frontera con el usuario
        if (t instanceof NumberFormatException) {
            return new Recomendacion(Level.FINE, true, false,
                    "Pedir el dato de nuevo indicando el formato esperado.");
        }
        if (t instanceof NoSuchElementException) {
            return new Recomendacion(Level.FINE, true, false,
                    "Informar de que no se encontro y ofrecer alternativas.");
        }

        // 5. Fallos de programacion: el usuario no debe ver detalles
        if (t instanceof NullPointerException
                || t instanceof IndexOutOfBoundsException
                || t instanceof ClassCastException) {
            return new Recomendacion(Level.SEVERE, false, true,
                    "BUG: mensaje generico con incidencia, stack trace completo al registro.");
        }

        // 6. Resto de no comprobadas: probablemente tambien un bug
        if (t instanceof RuntimeException) {
            return new Recomendacion(Level.SEVERE, false, true,
                    "Tratar como bug hasta demostrar lo contrario.");
        }

        // 7. Comprobadas no contempladas
        return new Recomendacion(Level.WARNING, false, true,
                "Condicion externa: registrar con detalle y decidir en la capa superior.");
    }

    public static void main(String[] args) {
        Throwable[] casos = {
                new NumberFormatException("For input string: \"doce\""),
                new IllegalArgumentException("El anio debe estar entre 1450 y 2100, y era: 1200"),
                new IOException("catalogo.txt (No such file or directory)"),
                new NullPointerException("Cannot invoke \"Empleado.getNombre()\""),
                new OutOfMemoryError("Java heap space"),
                new NoSuchElementException("No line found")
        };

        System.out.printf("%-32s %s%n", "EXCEPCION", "RECOMENDACION");
        System.out.println("-".repeat(120));
        for (Throwable t : casos) {
            System.out.printf("%-32s %s%n", t.getClass().getSimpleName(), decidir(t));
        }
    }
}

Salida:

EXCEPCION                        RECOMENDACION
------------------------------------------------------------------------------------------------------------------------
NumberFormatException            nivel=FINE    | usuario=true  | incidencia=false | Pedir el dato de nuevo indicando el formato esperado.
IllegalArgumentException         nivel=SEVERE  | usuario=false | incidencia=true  | Tratar como bug hasta demostrar lo contrario.
IOException                      nivel=SEVERE  | usuario=false | incidencia=true  | Degradar si hay alternativa; si no, abortar la operacion. Avisar a operaciones.
NullPointerException             nivel=SEVERE  | usuario=false | incidencia=true  | BUG: mensaje generico con incidencia, stack trace completo al registro.
OutOfMemoryError                 nivel=SEVERE  | usuario=false | incidencia=true  | Registrar (protegido) y terminar de forma ordenada. NO continuar.
NoSuchElementException           nivel=FINE    | usuario=true  | incidencia=false | Informar de que no se encontro y ofrecer alternativas.

Un hallazgo interesante del propio ejercicio: el IllegalArgumentException con el mensaje del año inválido cae en la categoría "bug", cuando en realidad era una validación de negocio perfectamente esperada. Es exactamente el argumento a favor de las excepciones propias de 06-04: si esa validación lanzara AnioPublicacionInvalidoException extends CatalogoException, la política la clasificaría bien automáticamente. Con excepciones estándar, la clasificación automática es imposible.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.BiblioTechException;

/**
 * Envuelve operaciones de BiblioTech aportando registro, medicion y
 * politica uniforme de errores.
 *
 * Es un ejemplo del patron "decorador de servicios" que en el modulo 11
 * hacen los frameworks con aspectos y anotaciones.
 */
public class RegistroOperaciones {

    private static final Logger LOG = Logger.getLogger(RegistroOperaciones.class.getName());

    /** Estadisticas acumuladas de una operacion (mutable a proposito). */
    private static class Estadistica {
        int ejecuciones;
        int exitos;
        int fallos;
        long nanosTotales;

        double mediaMs() {
            return (ejecuciones == 0) ? 0.0 : (nanosTotales / 1_000_000.0) / ejecuciones;
        }
    }

    private final Map<String, Estadistica> estadisticas = new HashMap<>();
    private int contador = 0;

    /**
     * Ejecuta una operacion registrando inicio, fin, duracion y fallos.
     *
     * @param nombreOperacion nombre logico (PRESTAR, DEVOLVER, IMPORTAR...)
     * @param idEmpleado      IDENTIFICADOR del empleado, nunca su nombre
     * @param operacion       la operacion a ejecutar
     */
    public <T> T ejecutar(String nombreOperacion, String idEmpleado, Supplier<T> operacion) {
        String idOperacion = siguienteId();
        long inicio = System.nanoTime();
        boolean exito = false;

        // Forma perezosa: si FINE esta desactivado, la cadena no se construye
        LOG.fine(() -> "[" + idOperacion + "] INICIO " + nombreOperacion
                + " (empleado " + idEmpleado + ")");

        try {
            T resultado = operacion.get();
            exito = true;
            return resultado;

        } catch (BiblioTechException e) {
            // Fallo ESPERADO del dominio: WARNING con la excepcion como
            // tercer argumento, y se RELANZA para que decida quien llame.
            LOG.log(Level.WARNING, "[" + idOperacion + "] " + nombreOperacion
                    + " rechazada [" + e.getCodigo() + "]", e);
            throw e;

        } catch (RuntimeException e) {
            // Fallo INESPERADO: es un bug. SEVERE con el identificador como
            // incidencia, y se ENVUELVE conservando la causa (06-03).
            LOG.log(Level.SEVERE, "[" + idOperacion + "] Error no controlado en "
                    + nombreOperacion, e);
            throw new IllegalStateException(
                    "Error interno en " + nombreOperacion + ". Incidencia: " + idOperacion, e);

        } finally {
            // La duracion se registra SIEMPRE, con exito y con fallo (06-05)
            long duracion = System.nanoTime() - inicio;
            acumular(nombreOperacion, exito, duracion);

            final boolean huboExito = exito;
            final double ms = duracion / 1_000_000.0;

            if (huboExito) {
                LOG.info(() -> String.format("[%s] FIN %s OK (%.2f ms)",
                        idOperacion, nombreOperacion, ms));
            } else {
                LOG.fine(() -> String.format("[%s] FIN %s FALLIDA (%.2f ms)",
                        idOperacion, nombreOperacion, ms));
            }
        }
    }

    /** Variante sin resultado. */
    public void ejecutarSinResultado(String nombreOperacion, String idEmpleado, Runnable operacion) {
        ejecutar(nombreOperacion, idEmpleado, () -> {
            operacion.run();
            return null;
        });
    }

    private synchronized void acumular(String nombre, boolean exito, long nanos) {
        Estadistica e = estadisticas.computeIfAbsent(nombre, n -> new Estadistica());
        e.ejecuciones++;
        e.nanosTotales += nanos;
        if (exito) { e.exitos++; } else { e.fallos++; }
    }

    private synchronized String siguienteId() {
        contador++;
        return String.format("OP-%05d", contador);
    }

    public void volcarEstadisticas() {
        System.out.println();
        System.out.printf("%-14s %-12s %-9s %-9s %s%n",
                "OPERACION", "EJECUCIONES", "EXITOS", "FALLOS", "MEDIA (ms)");
        System.out.println("-".repeat(62));

        estadisticas.forEach((nombre, e) ->
                System.out.printf("%-14s %-12d %-9d %-9d %.2f%n",
                        nombre, e.ejecuciones, e.exitos, e.fallos, e.mediaMs()));
    }

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

    public static void main(String[] args) {
        com.nexussoftware.bibliotech.presentacion.ConfiguracionLog.inicializar();

        Catalogo catalogo = new Catalogo();
        catalogo.registrar(new com.nexussoftware.bibliotech.dominio.Libro(
                "LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001"));
        catalogo.registrar(new com.nexussoftware.bibliotech.dominio.Libro(
                "LIB-0002", "Patrones de Diseno", "GoF", 1994, "978-0000000002"));

        RegistroOperaciones registro = new RegistroOperaciones();

        System.out.println("\n----- Operaciones -----");

        // 1 y 2: correctas
        registro.ejecutar("CONSULTAR", "EMP-001",
                () -> catalogo.obtenerPorReferencia("LIB-0001").getTitulo());
        registro.ejecutar("CONSULTAR", "EMP-002",
                () -> catalogo.obtenerPorReferencia("LIB-0002").getTitulo());

        // 3: fallo de dominio esperado, se relanza tal cual
        try {
            registro.ejecutar("CONSULTAR", "EMP-001",
                    () -> catalogo.obtenerPorReferencia("LIB-9999").getTitulo());
        } catch (BiblioTechException e) {
            System.out.println("Rechazada: " + e.getMessage());
        }

        // 4: alta correcta
        registro.ejecutarSinResultado("ALTA", "EMP-003", () ->
                catalogo.registrar(new com.nexussoftware.bibliotech.dominio.Libro(
                        "LIB-0003", "Refactorizacion", "Fowler", 1999, "978-0000000003")));

        // 5: alta duplicada, fallo de dominio
        try {
            registro.ejecutarSinResultado("ALTA", "EMP-003", () ->
                    catalogo.registrar(new com.nexussoftware.bibliotech.dominio.Libro(
                            "LIB-0001", "Copia", "X", 2020, "978-0000000009")));
        } catch (BiblioTechException e) {
            System.out.println("Rechazada: " + e.getMessage());
        }

        // 6: BUG simulado, se envuelve con incidencia
        try {
            registro.ejecutar("INFORME", "EMP-001", () -> {
                String nulo = null;
                return nulo.length();          // NullPointerException
            });
        } catch (IllegalStateException e) {
            System.out.println("Error interno: " + e.getMessage());
            System.out.println("  causa registrada: " + e.getCause().getClass().getSimpleName());
        }

        registro.volcarEstadisticas();
    }
}

Salida (consola, nivel INFO):

----- Operaciones -----
[INFO   ] RegistroOperaciones [OP-00001] FIN CONSULTAR OK (0,08 ms)
[INFO   ] RegistroOperaciones [OP-00002] FIN CONSULTAR OK (0,01 ms)
[WARNING] RegistroOperaciones [OP-00003] CONSULTAR rechazada [MATERIALNOENCONTRADO]
          causa: com.nexussoftware.bibliotech.dominio.MaterialNoEncontradoException: No existe...
Rechazada: No existe ningun material con la referencia 'LIB-9999' (el catalogo tiene 2 materiales)
[INFO   ] Catalogo            Material registrado: LIB-0003 (total 3)
[INFO   ] RegistroOperaciones [OP-00004] FIN ALTA OK (0,15 ms)
[WARNING] RegistroOperaciones [OP-00005] ALTA rechazada [REFERENCIADUPLICADA]
Rechazada: Ya existe un material con la referencia LIB-0001: 'Java Efectivo'
[SEVERE ] RegistroOperaciones [OP-00006] Error no controlado en INFORME
Error interno: Error interno en INFORME. Incidencia: OP-00006
  causa registrada: NullPointerException

OPERACION      EJECUCIONES  EXITOS    FALLOS    MEDIA (ms)
--------------------------------------------------------------
CONSULTAR      3            2         1         0.04
ALTA           2            1         1         0.09
INFORME        1            0         1         0.05

Las tres decisiones de diseño del ejercicio:

  1. Los fallos de dominio se relanzan tal cual; los bugs se envuelven. Los primeros ya tienen un mensaje apto para el usuario y un tipo que la presentación puede distinguir; los segundos necesitan una incidencia y ocultar los detalles.
  2. El nivel refleja quién debe mirarlo. WARNING para un rechazo de negocio —alguien podría querer saber cuántos hay— y SEVERE para un bug, que requiere intervención de desarrollo.
  3. La duración se mide en el finally, así que se registra también en los fallos. Saber que una operación tardó 4 segundos antes de fallar es información valiosa: sugiere un tiempo de espera agotado y no un rechazo inmediato.

Solución 3

Los diez problemas:

# Problema Daño
1 catch (Throwable t) en el bucle Atrapa OutOfMemoryError y StackOverflowError y sigue el bucle, con la JVM ya inservible
2 t.printStackTrace() como manejo Vuelca un trace crudo en la cara del usuario: riesgo de seguridad, inútil para él y sin registro
3 new Scanner(System.in) dentro del bucle Crea un Scanner nuevo en cada vuelta. Con el sc.close() de prestar() se cierra System.in y todas las lecturas posteriores lanzan NoSuchElementException
4 sc.close() en finally cerrando System.in El error de 06-06: cierra la entrada estándar de todo el proceso
5 System.exit(0) dentro del try Salta los finally (06-05) y cualquier limpieza pendiente
6 switch sin default Una opción inválida no produce ningún efecto ni mensaje: el usuario cree que no funcionó el teclado
7 catch (Exception e) + throw e en prestar Captura demasiado ancho y encima relanza tras imprimir: registro duplicado y el throws Exception contamina la firma
8 System.out.println(e.getStackTrace()[0]) Filtra la clase y la línea internas al usuario. Y lanza ArrayIndexOutOfBoundsException si el trace está vacío (el fast throw de 06-01)
9 catch (Exception e) { } vacío en devolver El peor error del módulo: el fallo desaparece sin dejar rastro
10 "Devolucion procesada" fuera del try Se imprime aunque la devolución haya fallado. La aplicación miente al usuario

Hay un undécimo problema estructural: no existe ningún Logger. Todo el diagnóstico es printStackTrace y println, así que no hay nada que filtrar, archivar ni analizar.

Versión corregida:

package com.nexussoftware.bibliotech.presentacion;

import java.util.NoSuchElementException;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.servicio.GestorPrestamos;

/**
 * Menu principal de BiblioTech, version final del modulo 6.
 *
 * Politica de errores:
 *   - Un unico Scanner de instancia sobre System.in, que NUNCA se cierra.
 *   - Se captura por TIPO, del mas especifico al mas general.
 *   - Los fallos de dominio producen mensajes para el usuario y log FINE.
 *   - Los bugs producen un mensaje generico con incidencia y log SEVERE.
 *   - No se captura Throwable: los Error suben a la frontera del main.
 *   - Cero System.out.println de diagnostico.
 */
public class MenuBiblioTech {

    private static final Logger LOG = Logger.getLogger(MenuBiblioTech.class.getName());

    private static final int DIAS_PRESTAMO = 15;

    /** UN SOLO Scanner, creado una vez y jamas cerrado (06-06). */
    private final Scanner scanner = new Scanner(System.in);

    private final GestorPrestamos gestor;
    private int contadorOperaciones = 0;
    private boolean salir = false;

    public MenuBiblioTech(GestorPrestamos gestor) {
        this.gestor = gestor;
    }

    public void arrancar() {
        LOG.info("Menu de BiblioTech iniciado");

        while (!salir) {
            mostrarMenu();
            int opcion = leerOpcion(0, 3);

            // Cada opcion maneja SUS PROPIOS errores: aqui no hay red que
            // capture de todo, porque una red asi ocultaria bugs.
            switch (opcion) {
                case 1 -> prestar();
                case 2 -> devolver();
                case 3 -> listar();
                case 0 -> salir();
                default -> System.out.println("Opcion no reconocida: " + opcion);
            }
        }
        LOG.info("Menu de BiblioTech finalizado");
    }

    private void mostrarMenu() {
        System.out.println("""

                ===== BiblioTech - Nexus Software =====
                1. Prestar material
                2. Devolver material
                3. Listar catalogo
                0. Salir
                =======================================""");
    }

    /** Reintento indefinido: hay una persona delante que puede corregir (06-02). */
    private int leerOpcion(int min, int max) {
        while (true) {
            System.out.print("Opcion: ");
            String linea = scanner.nextLine().trim();
            try {
                int valor = Integer.parseInt(linea);
                if (valor < min || valor > max) {
                    System.out.printf("Debe estar entre %d y %d.%n", min, max);
                    continue;
                }
                return valor;
            } catch (NumberFormatException e) {
                System.out.println("'" + linea + "' no es un numero. Escriba un digito de "
                        + min + " a " + max + ".");
                LOG.fine(() -> "Entrada no numerica en el menu: '" + linea + "'");
            }
        }
    }

    private void prestar() {
        String idOperacion = siguienteIdOperacion();

        System.out.print("Referencia del material: ");
        String referencia = scanner.nextLine().trim();
        System.out.print("Identificador del empleado: ");
        String idEmpleado = scanner.nextLine().trim();
        int dia = leerEnteroConLimite("Dia del prestamo (1-365)", 1, 365);
        if (dia == -1) {
            System.out.println("Operacion cancelada.");
            return;
        }

        try {
            gestor.prestar(idOperacion, referencia, idEmpleado, dia);
            // SALIDA del programa (lo que el usuario pidio ver), no log
            System.out.println("Prestado " + referencia + ". Devolucion prevista: dia "
                    + (dia + DIAS_PRESTAMO));

        } catch (MaterialNoEncontradoException e) {
            System.out.println("No existe el material '" + e.getReferenciaBuscada() + "'.");
            LOG.fine(() -> "[" + idOperacion + "] Referencia inexistente: "
                    + e.getReferenciaBuscada());

        } catch (MaterialNoDisponibleException e) {
            System.out.println("'" + e.getTitulo() + "' esta prestado.");
            System.out.println("Disponible en unos " + e.diasDeEspera(dia) + " dias.");
            LOG.fine(() -> "[" + idOperacion + "] Material no disponible: " + e.getReferencia());

        } catch (LimitePrestamosExcedidoException e) {
            System.out.printf("Limite de %d prestamos alcanzado. Devuelva %d material(es).%n",
                    e.getLimite(), e.devolucionesNecesarias());
            LOG.fine(() -> "[" + idOperacion + "] Limite excedido por " + e.getIdEmpleado());

        } catch (BiblioTechException e) {
            System.out.println("No se pudo completar el prestamo: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + idOperacion + "] Fallo de dominio no previsto ["
                    + e.getCodigo() + "]", e);

        } catch (RuntimeException e) {
            // BUG. El usuario ve una incidencia; el registro, todo el detalle.
            System.out.println("Error interno. Incidencia: " + idOperacion);
            LOG.log(Level.SEVERE, "[" + idOperacion + "] Error no controlado al prestar "
                    + referencia + " a " + idEmpleado, e);
        }
        // NO se captura Throwable: un Error debe subir a la frontera del main.
    }

    private void devolver() {
        String idOperacion = siguienteIdOperacion();

        System.out.print("Referencia del prestamo (PR-NNNN): ");
        String referenciaPrestamo = scanner.nextLine().trim();
        int dia = leerEnteroConLimite("Dia de la devolucion (1-365)", 1, 365);
        if (dia == -1) {
            System.out.println("Operacion cancelada.");
            return;
        }

        try {
            gestor.devolver(idOperacion, referenciaPrestamo, dia);
            // DENTRO del try: solo se afirma el exito si de verdad lo hubo
            System.out.println("Devolucion de " + referenciaPrestamo + " procesada.");

        } catch (PrestamoYaDevueltoException e) {
            System.out.println("Ese prestamo ya se devolvio el dia "
                    + e.getDiaDevolucionOriginal() + ".");
            LOG.fine(() -> "[" + idOperacion + "] Devolucion duplicada de "
                    + e.getReferenciaPrestamo());

        } catch (MaterialNoEncontradoException e) {
            System.out.println("No existe el prestamo '" + e.getReferenciaBuscada() + "'.");
            LOG.fine(() -> "[" + idOperacion + "] Prestamo inexistente: "
                    + e.getReferenciaBuscada());

        } catch (BiblioTechException e) {
            System.out.println("No se pudo completar la devolucion: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + idOperacion + "] Fallo de dominio en devolucion", e);

        } catch (RuntimeException e) {
            System.out.println("Error interno. Incidencia: " + idOperacion);
            LOG.log(Level.SEVERE, "[" + idOperacion + "] Error no controlado al devolver "
                    + referenciaPrestamo, e);
        }
    }

    private void listar() {
        System.out.println("(listado del catalogo)");
    }

    /**
     * Salida ORDENADA: se marca la bandera y el bucle termina solo.
     * Nada de System.exit aqui: eso saltaria los finally y la limpieza (06-05).
     */
    private void salir() {
        salir = true;
        System.out.println("Hasta luego.");
    }

    private int leerEnteroConLimite(String peticion, int min, int max) {
        for (int intento = 1; intento <= 3; intento++) {
            System.out.print(peticion + ": ");
            String linea;
            try {
                linea = scanner.nextLine().trim();
            } catch (NoSuchElementException e) {
                // Ocurre si System.in se cierra o llega al final (entrada redirigida)
                LOG.log(Level.WARNING, "Entrada estandar agotada; se cancela la operacion", e);
                return -1;
            }
            try {
                int valor = Integer.parseInt(linea);
                if (valor < min || valor > max) {
                    System.out.printf("Fuera de rango [%d..%d]. Intento %d de 3.%n",
                            min, max, intento);
                    continue;
                }
                return valor;
            } catch (NumberFormatException e) {
                System.out.printf("'%s' no es un numero. Intento %d de 3.%n", linea, intento);
            }
        }
        return -1;
    }

    private String siguienteIdOperacion() {
        contadorOperaciones++;
        return String.format("OP-%05d", contadorOperaciones);
    }
}

Los once problemas resueltos, uno a uno:

  1. Se captura por tipo, nunca Throwable. Los Error suben a la frontera del main.
  2. Ni un solo printStackTrace: todo va al Logger con su nivel.
  3. Un solo Scanner de instancia, creado en el campo.
  4. Nunca se cierra System.in.
  5. System.exit sustituido por una bandera y una salida ordenada del bucle.
  6. switch con default que informa.
  7. Capturas específicas, sin relanzar, y prestar() ya no declara throws.
  8. Cero información interna en pantalla: identificador de incidencia y nada más.
  9. Ningún catch vacío.
  10. La confirmación de éxito está dentro del try.
  11. Existe un Logger por clase, con niveles distinguidos y mensajes perezosos.

Conclusión

Has cerrado el módulo con la parte que convierte el conocimiento técnico en software de producción.

En estrategia, tienes la regla que gobierna todo lo demás: captura donde puedas decidir, no donde se produce, con sus tres preguntas —¿puedo hacer algo distinto de propagar?, ¿tengo contexto que añadir?, ¿soy la frontera?— y la consecuencia de que no capturar es una decisión activa y muchas veces la correcta. Conoces la política de errores de cada capa de BiblioTech: el dominio valida, lanza y nunca registra ni imprime, porque no sabe quién hay al otro lado; el servicio aplica reglas, traduce conservando la causa y garantiza estado coherente; la presentación es el filtro que captura todo lo esperable y decide qué mensaje mostrar.

Sabes construir la frontera de errores del main, con sus tres catch ordenados que distinguen un fallo de dominio (mensaje limpio) de un bug (incidencia y silencio sobre los detalles) y de un Error (registro protegido y salida inmediata) —la única excepción legítima a la regla de no capturar Error—, con códigos de salida distintos para que un script pueda reaccionar. Y sabes instalar el manejador global con Thread.setDefaultUncaughtExceptionHandler, que impide que una excepción escapada de un hilo secundario mate ese hilo en silencio, junto con el hook de apagado de 06-05.

Tienes clara la separación entre lo que ve el usuario —mensaje en su vocabulario, qué hacer, identificador de incidencia— y lo que va al registro —clase, stack trace, rutas, contexto—, con el motivo de fondo: un stack trace en pantalla es un riesgo de seguridad antes que una molestia. Y el identificador de incidencia como pieza que une ambos mundos sin exponer nada.

Distingues recuperable de irrecuperable con el criterio que decide: degrada cuando el servicio reducido siga siendo correcto; aborta cuando continuar produciría resultados incorrectos —arrancar sin catálogo es correcto, calcular multas con una tarifa desconocida no—. Conoces las cuatro condiciones de un reintento sensato, incluida la que más se olvida: la idempotencia de la operación. Sabes cuándo validar previamente y cuándo capturar, con la advertencia de TOCTOU que hace obligatoria la excepción cuando el recurso es compartido. Y conoces las alternativas a lanzar: Optional para la ausencia normal de valor y el objeto resultado para acumular varios errores a la vez en lugar de abortar en el primero.

En logging, tienes los siete motivos por los que System.out.println no sirve en producción —no se filtra, no se desactiva, no lleva fecha ni nivel ni origen, no se archiva y bloquea—, con la distinción que evita el exceso contrario: System.out sigue siendo correcto para la salida que el usuario ha pedido; lo que se sustituye es el diagnóstico. Conoces la tabla de niveles y qué merece cada uno, con la advertencia de que cuando todo es SEVERE, nadie mira los errores y de que una excepción manejada correctamente rara vez lo es.

Dominas java.util.logging en la práctica: un Logger static final por clase nombrado con la clase completa, la jerarquía por paquetes que permite activar FINE solo en la capa de servicio, la forma perezosa con Supplier que evita construir mensajes que nadie va a leer, los Handler de consola y de fichero con rotación, los Formatter propios, y la configuración por logging.properties que permite subir el nivel en producción sin recompilar. Sabes registrar una excepción correctamente —LOG.log(Level.X, "mensaje", e), con el Throwable como tercer argumento— y por qué las cuatro alternativas son peores, incluida la de registrar y relanzar en cada capa. Sabes qué no registrar nunca, con la trampa del toString() que hoy es inofensivo y dentro de dos años incluye un DNI, la técnica de enmascarar, y la advertencia formal de que en un sistema real esto lo revisa el responsable de protección de datos, no tú. Y sabes escribir mensajes útiles con identificadores en lugar de nombres, y correlacionarlos con un identificador de operación. Con el mapa del ecosistema real: SLF4J como fachada, Logback y Log4j2 como implementaciones, y la certeza de que todo lo aprendido aquí se traslada tal cual —solo cambia la API—, que es lo que verás en 11-07.

BiblioTech, al cerrar el módulo 6, es otro sistema. Su jerarquía de excepciones —BiblioTechException abstracta como raíz, CatalogoException y PrestamoException como ramas, y cinco hojas con datos estructurados— nombra cada fallo en el lenguaje del negocio y transporta lo que quien captura necesita para decidir. Sus constructores rechazan los datos inválidos en lugar de corregirlos con avisos, así que ya no existen préstamos con referencia PR-0000 ni libros publicados en el año 0. Catalogo ha eliminado el null como valor de retorno: obtenerPorReferencia lanza y buscarPorReferencia devuelve Optional. GestorPrestamos valida antes de modificar, marca cada paso con una bandera y compensa en finally, de modo que un fallo a mitad deja el material disponible, el cupo restituido y ningún préstamo fantasma, con la excepción llegando intacta. SesionBiblioteca y ExportadorCatalogo son AutoCloseable, se cierran pase lo que pase y no pierden ninguna excepción por el camino. MenuBiblioTech captura por tipo, traduce cada fallo a un mensaje comprensible, no muestra jamás un detalle interno y usa un único Scanner que nunca se cierra. Y main tiene una frontera de errores con incidencias, códigos de salida y manejador global. No queda un solo System.out.println de diagnóstico en todo el proyecto: todo el registro pasa por Logger, con marca de tiempo, nivel, clase, hilo e identificador de operación, en consola para lo importante y en fichero rotado para todo el detalle.

De las cinco fragilidades que declaraste al cerrar el módulo 5, cuatro están resueltas: los datos inválidos ya no abortan el programa, los errores no se comunican con null ni false mudos, los avisos no van por System.out.println, y las operaciones no dejan el estado incoherente al fallar a medias.

Queda la quinta, y es la más simple de enunciar: nada se guarda al salir. El catálogo, los préstamos, las multas, las reservas y el historial viven íntegramente en memoria. Cierra BiblioTech y desaparecen tres módulos de trabajo. La próxima ejecución empieza con un catálogo vacío, como si nunca hubiera existido. Y hay una segunda carencia, más discreta: la aplicación no puede leer nada del exterior más allá de lo que se teclea, así que cargar un inventario de mil materiales significa teclearlos uno a uno.

En el módulo 7, Entrada/Salida de Archivos, se resuelve. Verás la lectura y la escritura de ficheros con la API que este módulo solo ha rozado —FileReader, FileWriter y el cierre garantizado que ya dominas—; los flujos de bytes y de caracteres y por qué son dos jerarquías distintas; BufferedReader y BufferedWriter, y qué gana exactamente el búfer; la serialización de objetos, que retomará el serialVersionUID que declaraste en tus excepciones; la API NIO.2 con Path y Files, la forma moderna de trabajar con el sistema de ficheros; y los formatos de intercambio CSV y Properties, con los que BiblioTech por fin cargará su configuración —esas constantes DIAS_PRESTAMO, TARIFA_DIARIA, MULTA_MAXIMA y UMBRAL_LEVE que llevan seis módulos escritas a fuego en el código— desde un fichero externo. Al terminarlo, BiblioTech recordará. Y todo lo que has aprendido en este módulo —las excepciones comprobadas de E/S, el try-with-resources, la traducción entre capas, la degradación elegante cuando un fichero no existe y el registro de lo que ocurre— será exactamente la herramienta que necesitarás desde la primera línea.

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