Módulo 21: Proyecto final

Objetivos del módulo

  • Desarrollar de principio a fin una aplicación de consola completa: gestión de una biblioteca
  • Pasar de un enunciado a una lista de requisitos y de ahí a un diagrama de clases UML
  • Organizar el código en paquetes por capas: modelo, excepciones, servicio, persistencia e interfaz de consola
  • Aplicar en un mismo proyecto herencia, clases selladas, interfaces, enumeraciones, records y pattern matching
  • Usar colecciones (List y Map) y excepciones propias con una jerarquía coherente
  • Guardar y cargar los datos en ficheros CSV
  • Construir un menú de consola robusto que valide toda la entrada del usuario
  • Probar la lógica con JUnit y gestionar el proyecto con Maven
  • Revisar la calidad del resultado con una lista de comprobación profesional

1. Enunciado y requisitos

1.1. Enunciado

La biblioteca municipal quiere sustituir sus fichas de papel por un pequeño programa. En el módulo 18 ya diseñaste un modelo de biblioteca en UML; ahora vas a construir la aplicación real, simplificando algunos aspectos (no distinguimos ejemplares) y añadiendo lo que necesita un programa de verdad: guardar los datos, validar la entrada y tener pruebas.

La biblioteca presta libros (código, título, año, autor y número de páginas) y revistas (código, título, año y número). Los libros se prestan 15 días y las revistas 7. Los socios se identifican por su DNI, tienen un nombre y una modalidad: estándar (hasta 2 préstamos a la vez) o premium (hasta 5). Un recurso solo puede estar prestado a un socio a la vez. Al devolver con retraso se cobra una multa de 0,50 € por día. El programa debe permitir dar de alta recursos y socios, buscar por título, prestar, devolver y consultar los préstamos activos y los vencidos. Los datos deben conservarse entre ejecuciones.

1.2. Requisitos

Id Requisito funcional
RF1 Listar el catálogo indicando qué recursos están prestados
RF2 Buscar recursos por una parte del título, sin distinguir mayúsculas
RF3 Dar de alta libros y revistas con código único
RF4 Dar de alta socios con DNI válido (formato y letra) y único
RF5 Prestar un recurso disponible a un socio que no haya alcanzado su límite
RF6 Devolver un recurso y calcular la multa por retraso
RF7 Listar los préstamos activos marcando los vencidos y su multa
RF8 Guardar los datos al salir y cargarlos al arrancar
Id Requisito no funcional
RNF1 Aplicación de consola en Java 25, proyecto Maven
RNF2 Persistencia en ficheros CSV legibles (UTF-8, separador ;)
RNF3 Ninguna entrada incorrecta del usuario debe cerrar el programa
RNF4 La lógica de negocio debe estar cubierta por pruebas JUnit
RNF5 Código organizado en paquetes por responsabilidad, documentado y con nombres significativos

💡 Consejo: Numerar los requisitos te permite comprobar al final que todos están implementados y probados (sección 9). Es lo mismo que hace un equipo profesional con una matriz de trazabilidad.


2. Análisis: del enunciado a las clases

Una técnica clásica para descubrir las clases es subrayar en el enunciado los sustantivos (candidatos a clases o atributos) y los verbos (candidatos a métodos):

En el enunciado Decisión de diseño
libros, revistas Clases Libro y Revista con una superclase común Recurso (generalización)
código, título, año Atributos comunes en Recurso
«se prestan 15 días / 7 días» Cada subclase responde a diasPrestamo(); lo define la interfaz Prestable
«solo libros y revistas» Recurso es una clase sellada (sealed ... permits Libro, Revista)
socios, DNI, nombre Socio como record: es un dato inmutable con validación
estándar, premium, «hasta 2 / 5» Enumeración TipoSocio con el atributo maxPrestamos
préstamo, fechas, multa Clase Prestamo (mutable: se marca la devolución)
prestar, devolver, buscar Métodos de un servicio Biblioteca, que guarda las colecciones
«conservarse entre ejecuciones» Clase RepositorioCsv en la capa de persistencia
«no puede prestarse», «límite alcanzado» Excepciones propias del negocio

Diagrama de clases UML del modelo y el servicio de la biblioteca Figura 1. Diagrama de clases del proyecto (capas de modelo y servicio).

Dos decisiones importantes:

  • Biblioteca guarda las colecciones en mapas (Map<String, Recurso> por código y Map<String, Socio> por DNI) para buscar en tiempo constante, y en una List<Prestamo> para los préstamos, que no tienen clave natural. Se usa LinkedHashMap para que el catálogo se liste en el orden en que se dio de alta.
  • Prestamo guarda el código del recurso y el DNI del socio, no referencias a los objetos. Así cada fila del CSV es independiente y, al cargar, Biblioteca comprueba que los códigos existen. Es la misma idea que una clave ajena en una base de datos.

3. Diseño: paquetes y capas

Paquetes de la aplicación organizados en capas con sus dependencias Figura 2. Arquitectura en capas. Las flechas discontinuas son dependencias: la interfaz usa el servicio, el servicio usa el modelo.

Paquete Responsabilidad Clases
modelo Las entidades del dominio y sus reglas internas (validaciones, cálculo de la multa) Prestable, Recurso, Libro, Revista, TipoSocio, Socio, Prestamo
excepciones Errores de negocio, todos subclases de BibliotecaException 6 clases
servicio Casos de uso: prestar, devolver, buscar… Coordina el modelo Biblioteca
persistencia Leer y escribir los ficheros CSV RepositorioCsv
ui Hablar con el usuario: menú, lectura validada, mensajes MenuConsola, Consola
(raíz) Arrancar la aplicación y conectar las piezas App

📘 Concepto: Separar la aplicación en capas permite cambiar una sin tocar las demás. Si mañana la biblioteca quiere una interfaz web o guardar en una base de datos, solo cambian ui o persistencia: el modelo y el servicio, que contienen las reglas de negocio y están cubiertos por las pruebas, siguen igual. Por eso ni el modelo ni el servicio contienen un solo System.out.println ni un Scanner.


4. Estructura del proyecto Maven

Árbol de carpetas del proyecto biblioteca Figura 3. Estructura del proyecto. Las pruebas reproducen la estructura de paquetes de src/main/java.

El pom.xml añade a lo visto en los módulos 19 y 20 la configuración de maven-jar-plugin para indicar la clase principal, de modo que el .jar sea ejecutable con java -jar:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>es.curso</groupId>
    <artifactId>biblioteca</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.1.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.16.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.6.0</version>
            </plugin>
            <plugin>
                <!-- Indica la clase principal para poder ejecutar: java -jar target/biblioteca-1.0.jar -->
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.5.1</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>es.curso.biblioteca.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

5. El modelo

5.1. Prestable, Recurso, Libro y Revista

La interfaz Prestable define el contrato «se presta durante N días» y un método por defecto que calcula la fecha límite:

package es.curso.biblioteca.modelo;

import java.time.LocalDate;

/** Algo que se puede prestar durante un número de días. */
public interface Prestable {

    /**
     * Días que dura el préstamo.
     *
     * @return número de días de préstamo
     */
    int diasPrestamo();

    /**
     * Calcula la fecha en la que hay que devolver el elemento.
     *
     * @param desde fecha del préstamo
     * @return {@code desde} más {@link #diasPrestamo()} días
     */
    default LocalDate fechaLimite(LocalDate desde) {
        return desde.plusDays(diasPrestamo());
    }
}

Recurso es abstracta y sellada: no se pueden crear recursos «genéricos» y solo Libro y Revista pueden heredar de ella. Implementa Prestable (sin definir diasPrestamo(), que queda para las subclases) y Comparable para ordenar por código:

package es.curso.biblioteca.modelo;

/** Elemento del catálogo de la biblioteca. Solo puede ser un libro o una revista. */
public abstract sealed class Recurso implements Prestable, Comparable<Recurso> permits Libro, Revista {

    private final String codigo;
    private final String titulo;
    private final int anio;

    protected Recurso(String codigo, String titulo, int anio) {
        this.codigo = Validacion.texto(codigo, "código").toUpperCase();
        this.titulo = Validacion.texto(titulo, "título");
        this.anio = Validacion.rango(anio, 1450, 2100, "año");
    }

    public String getCodigo() { return codigo; }
    public String getTitulo() { return titulo; }
    public int getAnio() { return anio; }

    /** Nombre del tipo de recurso, en mayúsculas ("LIBRO", "REVISTA"). */
    public abstract String tipo();

    /** Datos propios de cada tipo de recurso, para mostrarlos al usuario. */
    public abstract String detalle();

    @Override
    public boolean equals(Object o) {
        return o instanceof Recurso r && codigo.equals(r.codigo);
    }

    @Override
    public int hashCode() {
        return codigo.hashCode();
    }

    @Override
    public int compareTo(Recurso otro) {
        return codigo.compareTo(otro.codigo);
    }

    @Override
    public String toString() {
        return String.format("%-5s %-8s %-28s %4d  %s", codigo, tipo(), titulo, anio, detalle());
    }
}
package es.curso.biblioteca.modelo;

public final class Libro extends Recurso {

    public static final int DIAS_PRESTAMO = 15;

    private final String autor;
    private final int paginas;

    public Libro(String codigo, String titulo, int anio, String autor, int paginas) {
        super(codigo, titulo, anio);
        this.autor = Validacion.texto(autor, "autor");
        this.paginas = Validacion.rango(paginas, 1, 10_000, "páginas");
    }

    public String getAutor() { return autor; }
    public int getPaginas() { return paginas; }

    @Override public int diasPrestamo() { return DIAS_PRESTAMO; }
    @Override public String tipo() { return "LIBRO"; }
    @Override public String detalle() { return autor + ", " + paginas + " págs."; }
}

Revista es análoga, con el atributo numero y DIAS_PRESTAMO = 7. Las validaciones comunes están en una clase de utilidad con visibilidad de paquete (sin public), porque solo la usa el modelo:

package es.curso.biblioteca.modelo;

/** Comprobaciones comunes de los datos del modelo (visibilidad de paquete). */
final class Validacion {

    private Validacion() { }

    /** Exige un texto no vacío y sin ';' (el separador de los ficheros CSV). */
    static String texto(String valor, String campo) {
        if (valor == null || valor.isBlank()) {
            throw new IllegalArgumentException("El campo " + campo + " es obligatorio");
        }
        if (valor.contains(";")) {
            throw new IllegalArgumentException("El campo " + campo + " no puede contener ';'");
        }
        return valor.strip();
    }

    static int rango(int valor, int min, int max, String campo) {
        if (valor < min || valor > max) {
            throw new IllegalArgumentException("El campo " + campo + " debe estar entre " + min + " y " + max);
        }
        return valor;
    }
}

⚠️ Importante: Los textos no pueden contener ; porque es el separador de los CSV. Validarlo en el constructor garantiza que nunca existirá un objeto que luego no se pueda guardar: es mejor rechazar el dato al principio que corromper el fichero al final.

5.2. TipoSocio y Socio

package es.curso.biblioteca.modelo;

/** Modalidades de socio y número máximo de préstamos simultáneos de cada una. */
public enum TipoSocio {
    ESTANDAR(2),
    PREMIUM(5);

    private final int maxPrestamos;

    TipoSocio(int maxPrestamos) {
        this.maxPrestamos = maxPrestamos;
    }

    public int getMaxPrestamos() {
        return maxPrestamos;
    }
}

Socio es un record con constructor compacto: valida y normaliza los datos (el DNI se pasa a mayúsculas) antes de asignar los componentes. Reutiliza el algoritmo de la letra del DNI que desarrollaste con TDD en el módulo 19:

package es.curso.biblioteca.modelo;

/** Socio de la biblioteca. Es inmutable: un record con validación en el constructor compacto. */
public record Socio(String dni, String nombre, TipoSocio tipo) {

    private static final String LETRAS_DNI = "TRWAGMYFPDXBNJZSQVHLCKE";

    public Socio {
        dni = Validacion.texto(dni, "DNI").toUpperCase();
        if (!esDniValido(dni)) {
            throw new IllegalArgumentException("DNI no válido: " + dni);
        }
        nombre = Validacion.texto(nombre, "nombre");
        if (tipo == null) {
            throw new IllegalArgumentException("El tipo de socio es obligatorio");
        }
    }

    /** Comprueba el formato (8 cifras y letra) y la letra de control del DNI. */
    public static boolean esDniValido(String dni) {
        if (dni == null || !dni.matches("\\d{8}[A-Z]")) {
            return false;
        }
        int numero = Integer.parseInt(dni.substring(0, 8));
        return dni.charAt(8) == LETRAS_DNI.charAt(numero % LETRAS_DNI.length());
    }

    @Override
    public String toString() {
        return String.format("%-10s %-22s %s", dni, nombre, tipo);
    }
}

5.3. Prestamo

Es la única clase del modelo con estado mutable: se crea activo y se marca como devuelto.

package es.curso.biblioteca.modelo;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

/** Préstamo de un recurso a un socio. Guarda los códigos, no los objetos, para facilitar el CSV. */
public class Prestamo {

    public static final double MULTA_POR_DIA = 0.50;

    private final String codigoRecurso;
    private final String dniSocio;
    private final LocalDate fechaPrestamo;
    private final LocalDate fechaLimite;
    private LocalDate fechaDevolucion;            // null mientras el préstamo está activo

    public Prestamo(String codigoRecurso, String dniSocio, LocalDate fechaPrestamo,
                    LocalDate fechaLimite, LocalDate fechaDevolucion) {
        if (fechaLimite.isBefore(fechaPrestamo)) {
            throw new IllegalArgumentException("La fecha límite es anterior al préstamo");
        }
        this.codigoRecurso = codigoRecurso;
        this.dniSocio = dniSocio;
        this.fechaPrestamo = fechaPrestamo;
        this.fechaLimite = fechaLimite;
        this.fechaDevolucion = fechaDevolucion;
    }

    public boolean estaActivo() {
        return fechaDevolucion == null;
    }

    public boolean estaVencido(LocalDate hoy) {
        return estaActivo() && hoy.isAfter(fechaLimite);
    }

    /** Días de retraso hasta hoy (si sigue activo) o hasta la devolución. Nunca negativo. */
    public long diasRetraso(LocalDate hoy) {
        LocalDate fin = estaActivo() ? hoy : fechaDevolucion;
        return Math.max(0, ChronoUnit.DAYS.between(fechaLimite, fin));
    }

    public double multa(LocalDate hoy) {
        return diasRetraso(hoy) * MULTA_POR_DIA;
    }

    public void registrarDevolucion(LocalDate fecha) {
        if (!estaActivo()) {
            throw new IllegalStateException("El préstamo ya estaba devuelto");
        }
        if (fecha.isBefore(fechaPrestamo)) {
            throw new IllegalArgumentException("La devolución no puede ser anterior al préstamo");
        }
        fechaDevolucion = fecha;
    }

    public String getCodigoRecurso() { return codigoRecurso; }
    public String getDniSocio() { return dniSocio; }
    public LocalDate getFechaPrestamo() { return fechaPrestamo; }
    public LocalDate getFechaLimite() { return fechaLimite; }
    public LocalDate getFechaDevolucion() { return fechaDevolucion; }

    @Override
    public String toString() {
        return String.format("%-5s -> %s  del %s al %s%s", codigoRecurso, dniSocio, fechaPrestamo, fechaLimite,
                estaActivo() ? "" : "  (devuelto el " + fechaDevolucion + ")");
    }
}

💡 Consejo: diasRetraso y multa reciben la fecha hoy como parámetro en lugar de llamar a LocalDate.now(). Así las pruebas son repetibles (la F-I-R-S-T del módulo 19): un test que pase hoy seguirá pasando dentro de un año.


6. Excepciones propias

Todas heredan de BibliotecaException, que es comprobada (extends Exception): el compilador obliga a quien llama a prestar() a tratar los errores de negocio, que no son fallos de programación sino situaciones previstas.

package es.curso.biblioteca.excepciones;

/** Excepción base (comprobada) de todos los errores de negocio de la biblioteca. */
public class BibliotecaException extends Exception {

    public BibliotecaException(String mensaje) {
        super(mensaje);
    }

    public BibliotecaException(String mensaje, Throwable causa) {
        super(mensaje, causa);
    }
}
package es.curso.biblioteca.excepciones;

public class RecursoNoDisponibleException extends BibliotecaException {
    public RecursoNoDisponibleException(String mensaje) {
        super(mensaje);
    }
}
Excepción Cuándo se lanza
ElementoNoEncontradoException Código de recurso o DNI que no existen; devolver algo que no está prestado
ElementoDuplicadoException Alta con un código o DNI ya registrados
RecursoNoDisponibleException Prestar un recurso que ya está prestado
LimitePrestamosException El socio ya tiene el máximo de préstamos de su modalidad
PersistenciaException Error al leer o escribir los CSV (guarda la causa original)

Los datos mal formados (un año fuera de rango, un DNI con la letra incorrecta) lanzan IllegalArgumentException, la excepción estándar de Java para argumentos no válidos. La interfaz captura ambas familias.


7. El servicio: la lógica de negocio

Biblioteca concentra las reglas del enunciado. Fíjate en que prestar comprueba todas las condiciones antes de modificar nada: si algo falla, el estado queda intacto.

Diagrama de secuencia de la operación prestar con sus comprobaciones y excepciones Figura 4. Secuencia de la opción «Prestar»: cuatro comprobaciones, cada una con su excepción, antes de crear el préstamo.

package es.curso.biblioteca.servicio;

import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.ElementoDuplicadoException;
import es.curso.biblioteca.excepciones.ElementoNoEncontradoException;
import es.curso.biblioteca.excepciones.LimitePrestamosException;
import es.curso.biblioteca.excepciones.RecursoNoDisponibleException;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Socio;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Lógica de negocio: catálogo, socios y préstamos. No sabe nada de consola ni de ficheros. */
public class Biblioteca {

    private final Map<String, Recurso> catalogo = new LinkedHashMap<>();
    private final Map<String, Socio> socios = new LinkedHashMap<>();
    private final List<Prestamo> prestamos = new ArrayList<>();

    // ---------- Catálogo ----------

    public void anadirRecurso(Recurso recurso) throws ElementoDuplicadoException {
        if (catalogo.containsKey(recurso.getCodigo())) {
            throw new ElementoDuplicadoException("Ya existe un recurso con el código " + recurso.getCodigo());
        }
        catalogo.put(recurso.getCodigo(), recurso);
    }

    public Recurso buscarRecurso(String codigo) throws ElementoNoEncontradoException {
        Recurso r = catalogo.get(codigo.strip().toUpperCase());
        if (r == null) {
            throw new ElementoNoEncontradoException("No existe el recurso " + codigo);
        }
        return r;
    }

    public List<Recurso> buscarPorTitulo(String texto) {
        String buscado = texto.strip().toLowerCase();
        List<Recurso> resultado = new ArrayList<>();
        for (Recurso r : catalogo.values()) {
            if (r.getTitulo().toLowerCase().contains(buscado)) {
                resultado.add(r);
            }
        }
        resultado.sort(Comparator.comparing(Recurso::getTitulo));
        return resultado;
    }

    public boolean estaDisponible(String codigo) {
        return prestamoActivoDe(codigo) == null;
    }

    // ---------- Socios ----------

    public void anadirSocio(Socio socio) throws ElementoDuplicadoException {
        if (socios.containsKey(socio.dni())) {
            throw new ElementoDuplicadoException("Ya existe un socio con el DNI " + socio.dni());
        }
        socios.put(socio.dni(), socio);
    }

    public Socio buscarSocio(String dni) throws ElementoNoEncontradoException {
        Socio s = socios.get(dni.strip().toUpperCase());
        if (s == null) {
            throw new ElementoNoEncontradoException("No existe el socio con DNI " + dni);
        }
        return s;
    }

    // ---------- Préstamos ----------

    public Prestamo prestar(String codigo, String dni, LocalDate hoy) throws BibliotecaException {
        Recurso recurso = buscarRecurso(codigo);
        Socio socio = buscarSocio(dni);
        if (!estaDisponible(recurso.getCodigo())) {
            throw new RecursoNoDisponibleException("\"" + recurso.getTitulo() + "\" ya está prestado");
        }
        int activos = prestamosActivosDe(socio.dni()).size();
        if (activos >= socio.tipo().getMaxPrestamos()) {
            throw new LimitePrestamosException(socio.nombre() + " ya tiene " + activos
                    + " préstamos (máximo " + socio.tipo().getMaxPrestamos() + ")");
        }
        Prestamo p = new Prestamo(recurso.getCodigo(), socio.dni(), hoy, recurso.fechaLimite(hoy), null);
        prestamos.add(p);
        return p;
    }

    /** Registra la devolución y devuelve la multa por retraso (0 si se devuelve a tiempo). */
    public double devolver(String codigo, LocalDate hoy) throws ElementoNoEncontradoException {
        Prestamo p = prestamoActivoDe(codigo.strip().toUpperCase());
        if (p == null) {
            throw new ElementoNoEncontradoException("El recurso " + codigo + " no está prestado");
        }
        p.registrarDevolucion(hoy);
        return p.multa(hoy);
    }

    /** Añade un préstamo leído de fichero, comprobando que recurso y socio existen. */
    public void registrarPrestamo(Prestamo p) throws BibliotecaException {
        buscarRecurso(p.getCodigoRecurso());
        buscarSocio(p.getDniSocio());
        if (p.estaActivo() && !estaDisponible(p.getCodigoRecurso())) {
            throw new RecursoNoDisponibleException("Dos préstamos activos del recurso " + p.getCodigoRecurso());
        }
        prestamos.add(p);
    }

    public List<Prestamo> prestamosActivos() {
        List<Prestamo> activos = new ArrayList<>();
        for (Prestamo p : prestamos) {
            if (p.estaActivo()) activos.add(p);
        }
        return activos;
    }

    public List<Prestamo> prestamosVencidos(LocalDate hoy) {
        List<Prestamo> vencidos = new ArrayList<>();
        for (Prestamo p : prestamos) {
            if (p.estaVencido(hoy)) vencidos.add(p);
        }
        return vencidos;
    }

    public List<Prestamo> prestamosActivosDe(String dni) {
        List<Prestamo> resultado = new ArrayList<>();
        for (Prestamo p : prestamos) {
            if (p.estaActivo() && p.getDniSocio().equals(dni)) resultado.add(p);
        }
        return resultado;
    }

    private Prestamo prestamoActivoDe(String codigo) {
        for (Prestamo p : prestamos) {
            if (p.estaActivo() && p.getCodigoRecurso().equals(codigo)) return p;
        }
        return null;
    }

    // ---------- Consultas para la interfaz y la persistencia ----------

    public List<Recurso> getRecursos() { return List.copyOf(catalogo.values()); }
    public List<Socio> getSocios() { return List.copyOf(socios.values()); }
    public List<Prestamo> getPrestamos() { return List.copyOf(prestamos); }
}

💡 Consejo: Los métodos getRecursos(), getSocios() y getPrestamos() devuelven copias inmutables (List.copyOf). Si devolvieran las colecciones internas, cualquier clase podría añadir un préstamo saltándose las reglas de prestar(). Es la encapsulación del módulo 11 aplicada a las colecciones.


8. Persistencia en CSV

Cada colección se guarda en su propio fichero con una línea de cabecera. Estos son los ficheros de datos con los que arranca la sesión de la sección 10:

--- datos/recursos.csv
tipo;codigo;titulo;anio;autor_o_numero;paginas
LIBRO;L001;El Quijote;1605;Miguel de Cervantes;1376
LIBRO;L002;Cien años de soledad;1967;Gabriel García Márquez;471
LIBRO;L003;El nombre del viento;2007;Patrick Rothfuss;880
REVISTA;R001;Muy Interesante;2026;540;
REVISTA;R002;National Geographic;2026;312;
--- datos/socios.csv
dni;nombre;tipo
12345678Z;Marta López;PREMIUM
87654321X;Jorge Sanz;ESTANDAR
--- datos/prestamos.csv
codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion
L003;12345678Z;2026-09-01;2026-09-16;2026-09-14
L001;12345678Z;2026-09-28;2026-10-13;
R001;87654321X;2026-09-15;2026-09-22;

Un campo vacío al final de un préstamo significa que sigue activo. El repositorio usa java.nio.file (módulo 17), un switch con pattern matching sobre la jerarquía sellada para escribir cada tipo de recurso y convierte cualquier error de lectura en una PersistenciaException que indica qué fichero y qué fila fallan:

package es.curso.biblioteca.persistencia;

import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.servicio.Biblioteca;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

/** Guarda y carga la biblioteca en tres ficheros CSV separados por ';'. */
public class RepositorioCsv {

    private static final String SEP = ";";
    private static final String RECURSOS = "recursos.csv";
    private static final String SOCIOS = "socios.csv";
    private static final String PRESTAMOS = "prestamos.csv";

    private final Path carpeta;

    public RepositorioCsv(Path carpeta) {
        this.carpeta = carpeta;
    }

    // ---------- Guardar ----------

    public void guardar(Biblioteca biblioteca) throws PersistenciaException {
        List<String> recursos = new ArrayList<>(List.of("tipo;codigo;titulo;anio;autor_o_numero;paginas"));
        for (Recurso r : biblioteca.getRecursos()) {
            recursos.add(switch (r) {                                  // pattern matching (Java 21)
                case Libro l -> String.join(SEP, "LIBRO", l.getCodigo(), l.getTitulo(),
                        String.valueOf(l.getAnio()), l.getAutor(), String.valueOf(l.getPaginas()));
                case Revista v -> String.join(SEP, "REVISTA", v.getCodigo(), v.getTitulo(),
                        String.valueOf(v.getAnio()), String.valueOf(v.getNumero()), "");
            });
        }
        List<String> socios = new ArrayList<>(List.of("dni;nombre;tipo"));
        for (Socio s : biblioteca.getSocios()) {
            socios.add(String.join(SEP, s.dni(), s.nombre(), s.tipo().name()));
        }
        List<String> prestamos = new ArrayList<>(List.of("codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion"));
        for (Prestamo p : biblioteca.getPrestamos()) {
            prestamos.add(String.join(SEP, p.getCodigoRecurso(), p.getDniSocio(), p.getFechaPrestamo().toString(),
                    p.getFechaLimite().toString(), p.estaActivo() ? "" : p.getFechaDevolucion().toString()));
        }
        try {
            Files.createDirectories(carpeta);
            Files.write(carpeta.resolve(RECURSOS), recursos, StandardCharsets.UTF_8);
            Files.write(carpeta.resolve(SOCIOS), socios, StandardCharsets.UTF_8);
            Files.write(carpeta.resolve(PRESTAMOS), prestamos, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new PersistenciaException("No se pudo guardar en " + carpeta + ": " + e.getMessage(), e);
        }
    }

    // ---------- Cargar ----------

    /** Carga los datos; si la carpeta no existe devuelve una biblioteca vacía. */
    public Biblioteca cargar() throws PersistenciaException {
        Biblioteca biblioteca = new Biblioteca();
        if (!Files.isDirectory(carpeta)) {
            return biblioteca;
        }
        for (String[] c : leer(RECURSOS)) {
            procesar(RECURSOS, c, () -> biblioteca.anadirRecurso(switch (c[0]) {
                case "LIBRO" -> new Libro(c[1], c[2], Integer.parseInt(c[3]), c[4], Integer.parseInt(c[5]));
                case "REVISTA" -> new Revista(c[1], c[2], Integer.parseInt(c[3]), Integer.parseInt(c[4]));
                default -> throw new IllegalArgumentException("tipo desconocido " + c[0]);
            }));
        }
        for (String[] c : leer(SOCIOS)) {
            procesar(SOCIOS, c, () -> biblioteca.anadirSocio(new Socio(c[0], c[1], TipoSocio.valueOf(c[2]))));
        }
        for (String[] c : leer(PRESTAMOS)) {
            procesar(PRESTAMOS, c, () -> biblioteca.registrarPrestamo(new Prestamo(c[0], c[1],
                    LocalDate.parse(c[2]), LocalDate.parse(c[3]), c[4].isEmpty() ? null : LocalDate.parse(c[4]))));
        }
        return biblioteca;
    }

    /** Lee un fichero (si existe) y devuelve sus filas ya separadas, sin la cabecera. */
    private List<String[]> leer(String fichero) throws PersistenciaException {
        Path ruta = carpeta.resolve(fichero);
        List<String[]> filas = new ArrayList<>();
        if (!Files.exists(ruta)) {
            return filas;
        }
        try {
            List<String> lineas = Files.readAllLines(ruta, StandardCharsets.UTF_8);
            for (String linea : lineas.subList(Math.min(1, lineas.size()), lineas.size())) { // sin cabecera
                if (!linea.isBlank()) {
                    filas.add(linea.split(SEP, -1));                       // -1: conserva campos vacíos
                }
            }
        } catch (IOException e) {
            throw new PersistenciaException("No se pudo leer " + ruta + ": " + e.getMessage(), e);
        }
        return filas;
    }

    /** Operación sobre una fila que puede fallar. */
    @FunctionalInterface
    private interface Operacion {
        void ejecutar() throws BibliotecaException;
    }

    /** Ejecuta la operación y convierte cualquier error en un PersistenciaException con contexto. */
    private void procesar(String fichero, String[] campos, Operacion op) throws PersistenciaException {
        try {
            op.ejecutar();
        } catch (BibliotecaException | RuntimeException e) {
            throw new PersistenciaException(fichero + ", fila [" + String.join(SEP, campos) + "]: " + e.getMessage(), e);
        }
    }
}

Dos detalles avanzados que merece la pena entender:

  • linea.split(SEP, -1): el -1 hace que split conserve los campos vacíos del final; sin él, "R001;87654321X;2026-09-15;2026-09-22;" daría 4 campos en lugar de 5.
  • procesar recibe la operación como una lambda (() -> biblioteca.anadirSocio(...)) gracias a la interfaz funcional Operacion. Así el tratamiento de errores, que es igual para los tres ficheros, se escribe una sola vez (DRY). Las lambdas se estudian a fondo en el curso avanzado.

⚠️ Importante: El switch de guardar no tiene default. Como Recurso es sellada, el compilador sabe que solo hay libros y revistas. Si añades una subclase Dvd a permits y olvidas actualizar este switch, el proyecto no compila (the switch expression does not cover all possible input values): el compilador te avisa del sitio exacto que debes cambiar. Lo comprobarás en el ejercicio 1.


9. La interfaz de consola

9.1. Lectura validada

Consola encapsula el Scanner y repite la pregunta hasta obtener un dato válido. Así ninguna entrada incorrecta (letras donde se espera un número, valores fuera de rango, líneas vacías) cierra el programa (RNF3):

package es.curso.biblioteca.ui;

import java.io.Console;
import java.util.Scanner;

/** Lectura validada de datos por teclado. Repite la pregunta hasta obtener un valor correcto. */
public class Consola {

    /** Se lanza cuando la entrada se termina (por ejemplo, al leer de una tubería). */
    public static class FinDeEntradaException extends RuntimeException { }

    private final Scanner teclado = new Scanner(System.in);
    // Si la entrada no es un terminal (tubería o fichero), mostramos lo leído para que la sesión se entienda
    private final boolean eco;

    public Consola() {
        Console consola = System.console();
        this.eco = consola == null || !consola.isTerminal();        // isTerminal(): Java 22
    }

    public String leerTexto(String pregunta) {
        while (true) {
            String linea = leerLinea(pregunta);
            if (!linea.isEmpty()) {
                return linea;
            }
            System.out.println("  [!] El valor no puede estar vacío.");
        }
    }

    public int leerEntero(String pregunta, int min, int max) {
        while (true) {
            String linea = leerLinea(pregunta);
            try {
                int valor = Integer.parseInt(linea);
                if (valor >= min && valor <= max) {
                    return valor;
                }
                System.out.println("  [!] Debe estar entre " + min + " y " + max + ".");
            } catch (NumberFormatException e) {
                System.out.println("  [!] \"" + linea + "\" no es un número entero.");
            }
        }
    }

    private String leerLinea(String pregunta) {
        System.out.print(pregunta);
        if (!teclado.hasNextLine()) {
            System.out.println();
            throw new FinDeEntradaException();
        }
        String linea = teclado.nextLine().strip();
        if (eco) {
            System.out.println(linea);
        }
        return linea;
    }
}

Cuando la entrada no viene de un terminal sino de una tubería o un fichero (como en la sesión de la sección 10), Consola muestra lo leído para que la transcripción se entienda; Console.isTerminal() existe desde Java 22. Y si la entrada se acaba, lanza FinDeEntradaException para que el menú guarde y termine limpiamente en lugar de fallar con NoSuchElementException.

9.2. El menú

MenuConsola traduce cada opción en una llamada al servicio y es el único lugar donde se capturan las excepciones de negocio para mostrárselas al usuario:

package es.curso.biblioteca.ui;

import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.persistencia.RepositorioCsv;
import es.curso.biblioteca.servicio.Biblioteca;

import java.time.LocalDate;
import java.util.List;

/** Interfaz de usuario de consola: muestra el menú y traduce cada opción en llamadas al servicio. */
public class MenuConsola {

    private final Biblioteca biblioteca;
    private final RepositorioCsv repositorio;
    private final Consola consola;
    private final LocalDate hoy;

    public MenuConsola(Biblioteca biblioteca, RepositorioCsv repositorio, Consola consola, LocalDate hoy) {
        this.biblioteca = biblioteca;
        this.repositorio = repositorio;
        this.consola = consola;
        this.hoy = hoy;
    }

    public void ejecutar() {
        boolean salir = false;
        try {
            while (!salir) {
                mostrarMenu();
                int opcion = consola.leerEntero("Opción: ", 0, 9);
                try {
                    switch (opcion) {
                        case 1 -> listarCatalogo();
                        case 2 -> buscarPorTitulo();
                        case 3 -> altaLibro();
                        case 4 -> altaRevista();
                        case 5 -> altaSocio();
                        case 6 -> listarSocios();
                        case 7 -> prestar();
                        case 8 -> devolver();
                        case 9 -> listarPrestamos();
                        case 0 -> salir = true;
                        default -> throw new IllegalStateException("Opción imposible: " + opcion);
                    }
                } catch (BibliotecaException | IllegalArgumentException e) {
                    System.out.println("  [ERROR] " + e.getMessage());
                }
            }
        } catch (Consola.FinDeEntradaException e) {
            System.out.println("Fin de la entrada.");
        }
        guardar();
    }

    private void mostrarMenu() {
        System.out.println();
        System.out.println("===== BIBLIOTECA (" + hoy + ") =====");
        System.out.println("1. Listar catálogo        6. Listar socios");
        System.out.println("2. Buscar por título      7. Prestar");
        System.out.println("3. Alta de libro          8. Devolver");
        System.out.println("4. Alta de revista        9. Préstamos activos");
        System.out.println("5. Alta de socio          0. Guardar y salir");
    }

    private void listarCatalogo() {
        for (Recurso r : biblioteca.getRecursos()) {
            System.out.println("  " + r + (biblioteca.estaDisponible(r.getCodigo()) ? "" : "  [PRESTADO]"));
        }
    }

    private void buscarPorTitulo() {
        String texto = consola.leerTexto("Texto a buscar: ");
        List<Recurso> encontrados = biblioteca.buscarPorTitulo(texto);
        if (encontrados.isEmpty()) {
            System.out.println("  No hay resultados.");
        }
        for (Recurso r : encontrados) {
            System.out.println("  " + r);
        }
    }

    private void altaLibro() throws BibliotecaException {
        String codigo = consola.leerTexto("Código: ");
        String titulo = consola.leerTexto("Título: ");
        int anio = consola.leerEntero("Año: ", 1450, hoy.getYear());
        String autor = consola.leerTexto("Autor: ");
        int paginas = consola.leerEntero("Páginas: ", 1, 10_000);
        biblioteca.anadirRecurso(new Libro(codigo, titulo, anio, autor, paginas));
        System.out.println("  [OK] Libro dado de alta.");
    }

    private void altaRevista() throws BibliotecaException {
        String codigo = consola.leerTexto("Código: ");
        String titulo = consola.leerTexto("Título: ");
        int anio = consola.leerEntero("Año: ", 1450, hoy.getYear());
        int numero = consola.leerEntero("Número: ", 1, 100_000);
        biblioteca.anadirRecurso(new Revista(codigo, titulo, anio, numero));
        System.out.println("  [OK] Revista dada de alta.");
    }

    private void altaSocio() throws BibliotecaException {
        String dni = consola.leerTexto("DNI: ");
        String nombre = consola.leerTexto("Nombre: ");
        TipoSocio[] tipos = TipoSocio.values();
        for (int i = 0; i < tipos.length; i++) {
            System.out.println("  " + (i + 1) + ". " + tipos[i] + " (hasta " + tipos[i].getMaxPrestamos() + " préstamos)");
        }
        TipoSocio tipo = tipos[consola.leerEntero("Tipo: ", 1, tipos.length) - 1];
        biblioteca.anadirSocio(new Socio(dni, nombre, tipo));
        System.out.println("  [OK] Socio dado de alta.");
    }

    private void listarSocios() {
        for (Socio s : biblioteca.getSocios()) {
            System.out.println("  " + s + "  préstamos activos: " + biblioteca.prestamosActivosDe(s.dni()).size());
        }
    }

    private void prestar() throws BibliotecaException {
        String codigo = consola.leerTexto("Código del recurso: ");
        String dni = consola.leerTexto("DNI del socio: ");
        Prestamo p = biblioteca.prestar(codigo, dni, hoy);
        System.out.println("  [OK] Prestado hasta el " + p.getFechaLimite() + ".");
    }

    private void devolver() throws BibliotecaException {
        String codigo = consola.leerTexto("Código del recurso: ");
        double multa = biblioteca.devolver(codigo, hoy);
        System.out.println(multa == 0 ? "  [OK] Devuelto a tiempo."
                : String.format("  [OK] Devuelto con retraso. Multa: %.2f €", multa));
    }

    private void listarPrestamos() {
        List<Prestamo> activos = biblioteca.prestamosActivos();
        System.out.println("  Activos: " + activos.size());
        for (Prestamo p : activos) {
            String aviso = p.estaVencido(hoy)
                    ? String.format("  VENCIDO hace %d días (%.2f €)", p.diasRetraso(hoy), p.multa(hoy)) : "";
            System.out.println("  " + p + aviso);
        }
    }

    private void guardar() {
        try {
            repositorio.guardar(biblioteca);
            System.out.println("Datos guardados. ¡Hasta pronto!");
        } catch (BibliotecaException e) {
            System.out.println("[ERROR] " + e.getMessage());
        }
    }
}

9.3. El punto de entrada

package es.curso.biblioteca;

import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.persistencia.RepositorioCsv;
import es.curso.biblioteca.servicio.Biblioteca;
import es.curso.biblioteca.ui.Consola;
import es.curso.biblioteca.ui.MenuConsola;

import java.nio.file.Path;
import java.time.LocalDate;

/**
 * Punto de entrada. Argumentos opcionales: carpeta de datos (por defecto "datos")
 * y fecha de hoy en formato AAAA-MM-DD (útil para pruebas y demostraciones).
 */
public class App {

    public static void main(String[] args) {
        Path carpeta = Path.of(args.length > 0 ? args[0] : "datos");
        LocalDate hoy = args.length > 1 ? LocalDate.parse(args[1]) : LocalDate.now();
        RepositorioCsv repositorio = new RepositorioCsv(carpeta);
        try {
            Biblioteca biblioteca = repositorio.cargar();
            new MenuConsola(biblioteca, repositorio, new Consola(), hoy).ejecutar();
        } catch (PersistenciaException e) {
            System.err.println("No se pudieron cargar los datos: " + e.getMessage());
            System.exit(1);
        }
    }
}

App es la única clase que conoce todas las piezas: crea el repositorio, carga la biblioteca, construye el menú y lo ejecuta. El segundo argumento opcional permite fijar la fecha «de hoy», lo que hace las demostraciones reproducibles.


10. Compilar, probar y ejecutar

10.1. Pruebas JUnit de la lógica

Las pruebas cubren las reglas de negocio (servicio), los cálculos y validaciones del modelo y la persistencia. Las del servicio:

package es.curso.biblioteca.servicio;

import es.curso.biblioteca.excepciones.ElementoDuplicadoException;
import es.curso.biblioteca.excepciones.ElementoNoEncontradoException;
import es.curso.biblioteca.excepciones.LimitePrestamosException;
import es.curso.biblioteca.excepciones.RecursoNoDisponibleException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Servicio Biblioteca")
class BibliotecaTest {

    private static final LocalDate HOY = LocalDate.of(2026, 10, 5);
    private Biblioteca biblioteca;

    @BeforeEach
    void setUp() throws Exception {
        biblioteca = new Biblioteca();
        biblioteca.anadirRecurso(new Libro("L001", "El Quijote", 1605, "Miguel de Cervantes", 1376));
        biblioteca.anadirRecurso(new Libro("L002", "Cien años de soledad", 1967, "Gabriel García Márquez", 471));
        biblioteca.anadirRecurso(new Revista("R001", "Muy Interesante", 2026, 540));
        biblioteca.anadirSocio(new Socio("12345678Z", "Marta López", TipoSocio.ESTANDAR));
    }

    @Test
    @DisplayName("Prestar un libro fija la fecha límite a 15 días y lo marca como no disponible")
    void prestarLibro() throws Exception {
        Prestamo p = biblioteca.prestar("L001", "12345678Z", HOY);
        assertAll(
                () -> assertEquals(LocalDate.of(2026, 10, 20), p.getFechaLimite()),
                () -> assertFalse(biblioteca.estaDisponible("L001")),
                () -> assertEquals(1, biblioteca.prestamosActivos().size()));
    }

    @Test
    @DisplayName("Una revista se presta 7 días")
    void prestarRevista() throws Exception {
        assertEquals(HOY.plusDays(7), biblioteca.prestar("r001", "12345678z", HOY).getFechaLimite());
    }

    @Test
    void noSePuedePrestarUnRecursoYaPrestado() throws Exception {
        biblioteca.prestar("L001", "12345678Z", HOY);
        assertThrows(RecursoNoDisponibleException.class, () -> biblioteca.prestar("L001", "12345678Z", HOY));
    }

    @Test
    void unSocioEstandarNoPuedeSuperarSuLimite() throws Exception {
        biblioteca.prestar("L001", "12345678Z", HOY);
        biblioteca.prestar("L002", "12345678Z", HOY);
        LimitePrestamosException e = assertThrows(LimitePrestamosException.class,
                () -> biblioteca.prestar("R001", "12345678Z", HOY));
        assertTrue(e.getMessage().contains("máximo 2"));
    }

    @Test
    void recursoOSocioInexistentes() {
        assertAll(
                () -> assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.prestar("X999", "12345678Z", HOY)),
                () -> assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.prestar("L001", "87654321X", HOY)));
    }

    @Test
    void noSeAdmitenCodigosDuplicados() {
        assertThrows(ElementoDuplicadoException.class,
                () -> biblioteca.anadirRecurso(new Revista("L001", "Otra", 2020, 1)));
    }

    @Test
    @DisplayName("Devolver a tiempo no tiene multa y deja el recurso disponible")
    void devolverATiempo() throws Exception {
        biblioteca.prestar("L001", "12345678Z", HOY);
        assertEquals(0.0, biblioteca.devolver("L001", HOY.plusDays(15)));
        assertTrue(biblioteca.estaDisponible("L001"));
    }

    @Test
    @DisplayName("Devolver 3 días tarde cuesta 1,50 €")
    void devolverConRetraso() throws Exception {
        biblioteca.prestar("L001", "12345678Z", HOY);
        assertEquals(1.50, biblioteca.devolver("L001", HOY.plusDays(18)), 0.001);
    }

    @Test
    void devolverAlgoNoPrestadoLanzaExcepcion() {
        assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.devolver("L002", HOY));
    }

    @Test
    void buscarPorTituloNoDistingueMayusculas() {
        assertEquals("El Quijote", biblioteca.buscarPorTitulo("QUIJ").getFirst().getTitulo());
        assertTrue(biblioteca.buscarPorTitulo("inexistente").isEmpty());
    }
}

Las del modelo usan pruebas parametrizadas con valores límite para la multa (el último día de plazo y el primero de retraso) y para los DNI incorrectos:

package es.curso.biblioteca.modelo;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;

class ModeloTest {

    private static final LocalDate LIMITE = LocalDate.of(2026, 10, 20);

    @ParameterizedTest(name = "devuelto el {0}: {1} días de retraso, multa {2} €")
    @CsvSource({
            "2026-10-15, 0, 0.0",     // antes de la fecha límite
            "2026-10-20, 0, 0.0",     // valor límite: el último día
            "2026-10-21, 1, 0.5",     // valor límite: un día tarde
            "2026-11-03, 14, 7.0"
    })
    void multaPorRetraso(LocalDate devolucion, long dias, double multa) {
        Prestamo p = new Prestamo("L001", "12345678Z", LocalDate.of(2026, 10, 5), LIMITE, null);
        p.registrarDevolucion(devolucion);
        assertEquals(dias, p.diasRetraso(devolucion));
        assertEquals(multa, p.multa(devolucion), 0.001);
    }

    @Test
    void noSePuedeDevolverDosVeces() {
        Prestamo p = new Prestamo("L001", "12345678Z", LocalDate.of(2026, 10, 5), LIMITE, null);
        p.registrarDevolucion(LIMITE);
        assertThrows(IllegalStateException.class, () -> p.registrarDevolucion(LIMITE));
    }

    @ParameterizedTest
    @ValueSource(strings = {"12345678A", "1234567Z", "ABCDEFGHZ", ""})
    void dniIncorrectosSeRechazan(String dni) {
        assertThrows(IllegalArgumentException.class, () -> new Socio(dni, "Ana", TipoSocio.PREMIUM));
    }

    @Test
    void elDniSeNormalizaAMayusculas() {
        assertEquals("12345678Z", new Socio("12345678z", "Ana", TipoSocio.PREMIUM).dni());
    }

    @Test
    void losTextosNoPuedenContenerElSeparadorCsv() {
        assertThrows(IllegalArgumentException.class, () -> new Libro("L9", "Título;roto", 2000, "Autor", 10));
    }
}

Las de persistencia usan @TempDir: JUnit inyecta una carpeta temporal vacía en cada prueba y la borra al terminar, de modo que las pruebas nunca tocan los datos reales:

package es.curso.biblioteca.persistencia;

import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.servicio.Biblioteca;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class RepositorioCsvTest {

    @TempDir
    Path carpeta;               // JUnit crea una carpeta temporal nueva para cada prueba y la borra después

    @Test
    void loQueSeGuardaSeVuelveACargarIgual() throws Exception {
        Biblioteca original = new Biblioteca();
        original.anadirRecurso(new Libro("L001", "El Quijote", 1605, "Miguel de Cervantes", 1376));
        original.anadirRecurso(new Revista("R001", "Muy Interesante", 2026, 540));
        original.anadirSocio(new Socio("12345678Z", "Marta López", TipoSocio.PREMIUM));
        original.prestar("R001", "12345678Z", LocalDate.of(2026, 10, 1));

        RepositorioCsv repo = new RepositorioCsv(carpeta);
        repo.guardar(original);
        Biblioteca cargada = repo.cargar();

        assertAll(
                () -> assertEquals(2, cargada.getRecursos().size()),
                () -> assertEquals("Miguel de Cervantes", ((Libro) cargada.buscarRecurso("L001")).getAutor()),
                () -> assertEquals("Marta López", cargada.buscarSocio("12345678Z").nombre()),
                () -> assertFalse(cargada.estaDisponible("R001")),
                () -> assertEquals(LocalDate.of(2026, 10, 8), cargada.prestamosActivos().getFirst().getFechaLimite()));
    }

    @Test
    void siNoHayCarpetaSeEmpiezaConUnaBibliotecaVacia() throws Exception {
        Biblioteca b = new RepositorioCsv(carpeta.resolve("no-existe")).cargar();
        assertTrue(b.getRecursos().isEmpty());
    }

    @Test
    void unFicheroCorruptoIndicaLaFilaQueFalla() throws Exception {
        Files.write(carpeta.resolve("recursos.csv"), List.of("cabecera", "LIBRO;L001;El Quijote;MIL;Cervantes;100"));
        PersistenciaException e = assertThrows(PersistenciaException.class, () -> new RepositorioCsv(carpeta).cargar());
        assertTrue(e.getMessage().startsWith("recursos.csv, fila [LIBRO;L001"));
    }
}

Con mvn -q test no aparece ninguna salida (todo correcto). Este es el resumen de mvn test (JDK 25, JUnit 6.1.3):

[INFO] Running es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.864 s -- in es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Running Servicio Biblioteca
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.280 s -- in Servicio Biblioteca
[INFO] Running es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.995 s -- in es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

10.2. Empaquetar y ejecutar

mvn -q package
java -jar target/biblioteca-1.0.jar datos 2026-10-05

10.3. Una sesión real

Esta es una sesión real del programa con la entrada pasada por tubería (cat entrada.txt | java -jar target/biblioteca-1.0.jar datos 2026-10-05). Para no repetirlo, el menú se muestra completo solo la primera vez; las demás apariciones se han sustituido por [... menú ...]:

===== BIBLIOTECA (2026-10-05) =====
1. Listar catálogo        6. Listar socios
2. Buscar por título      7. Prestar
3. Alta de libro          8. Devolver
4. Alta de revista        9. Préstamos activos
5. Alta de socio          0. Guardar y salir
Opción: 1
  L001  LIBRO    El Quijote                   1605  Miguel de Cervantes, 1376 págs.  [PRESTADO]
  L002  LIBRO    Cien años de soledad         1967  Gabriel García Márquez, 471 págs.
  L003  LIBRO    El nombre del viento         2007  Patrick Rothfuss, 880 págs.
  R001  REVISTA  Muy Interesante              2026  nº 540  [PRESTADO]
  R002  REVISTA  National Geographic          2026  nº 312
[... menú ...]
Opción: 2
Texto a buscar: nombre
  L003  LIBRO    El nombre del viento         2007  Patrick Rothfuss, 880 págs.
[... menú ...]
Opción: siete
  [!] "siete" no es un número entero.
Opción: 12
  [!] Debe estar entre 0 y 9.
Opción: 5
DNI: 33333333P
Nombre: Iván Gómez
  1. ESTANDAR (hasta 2 préstamos)
  2. PREMIUM (hasta 5 préstamos)
Tipo: 1
  [OK] Socio dado de alta.
[... menú ...]
Opción: 7
Código del recurso: L002
DNI del socio: 33333333P
  [OK] Prestado hasta el 2026-10-20.
[... menú ...]
Opción: 7
Código del recurso: L001
DNI del socio: 87654321X
  [ERROR] "El Quijote" ya está prestado
[... menú ...]
Opción: 7
Código del recurso: R002
DNI del socio: 87654321X
  [OK] Prestado hasta el 2026-10-12.
[... menú ...]
Opción: 7
Código del recurso: L003
DNI del socio: 87654321X
  [ERROR] Jorge Sanz ya tiene 2 préstamos (máximo 2)
[... menú ...]
Opción: 9
  Activos: 4
  L001  -> 12345678Z  del 2026-09-28 al 2026-10-13
  R001  -> 87654321X  del 2026-09-15 al 2026-09-22  VENCIDO hace 13 días (6.50 €)
  L002  -> 33333333P  del 2026-10-05 al 2026-10-20
  R002  -> 87654321X  del 2026-10-05 al 2026-10-12
[... menú ...]
Opción: 8
Código del recurso: R001
  [OK] Devuelto con retraso. Multa: 6.50 €
[... menú ...]
Opción: 0
Datos guardados. ¡Hasta pronto!

La sesión recorre los requisitos: listado con recursos prestados (RF1), búsqueda (RF2), entradas no válidas que no rompen nada (RNF3), alta de socio (RF4), un préstamo correcto, uno rechazado porque el recurso no está disponible y otro porque el socio ha llegado a su límite (RF5), préstamos activos con un vencido (RF7) y una devolución con multa: del 22 de septiembre al 5 de octubre son 13 días, 6,50 € (RF6).

Al salir, los ficheros quedan así (RF8): el préstamo de R001 tiene fecha de devolución, hay dos préstamos nuevos y un socio nuevo.

--- datos/prestamos.csv
codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion
L003;12345678Z;2026-09-01;2026-09-16;2026-09-14
L001;12345678Z;2026-09-28;2026-10-13;
R001;87654321X;2026-09-15;2026-09-22;2026-10-05
L002;33333333P;2026-10-05;2026-10-20;
R002;87654321X;2026-10-05;2026-10-12;
--- datos/recursos.csv
tipo;codigo;titulo;anio;autor_o_numero;paginas
LIBRO;L001;El Quijote;1605;Miguel de Cervantes;1376
LIBRO;L002;Cien años de soledad;1967;Gabriel García Márquez;471
LIBRO;L003;El nombre del viento;2007;Patrick Rothfuss;880
REVISTA;R001;Muy Interesante;2026;540;
REVISTA;R002;National Geographic;2026;312;
--- datos/socios.csv
dni;nombre;tipo
12345678Z;Marta López;PREMIUM
87654321X;Jorge Sanz;ESTANDAR
33333333P;Iván Gómez;ESTANDAR

11. Lista de comprobación de calidad

Antes de entregar un proyecto, revísalo con esta lista (el proyecto de este módulo la cumple):

Aspecto Comprobación Módulo
Requisitos Cada requisito funcional está implementado y se ha probado 21
Diseño Diagrama de clases actualizado; paquetes por responsabilidad; el modelo no imprime ni lee 18
Encapsulación Atributos privados; colecciones devueltas como copias inmutables 11, 16
Herencia y polimorfismo Jerarquía con sentido «es un»; sin switch sobre tipos que se podría evitar 12, 13
Tipos modernos enum para conjuntos cerrados, records para datos inmutables, sealed cuando los subtipos son conocidos 14
Excepciones Jerarquía propia; mensajes claros; nunca un catch vacío; recursos cerrados 15
Colecciones Map para búsquedas por clave, List para secuencias; genéricos siempre 16
Ficheros UTF-8 explícito; errores con contexto; datos de prueba separados de los reales 17
Pruebas Pruebas de la lógica en verde; valores límite; independientes y repetibles 19
Entrada Ninguna entrada del usuario provoca una excepción no controlada 4, 15
Estilo Convenciones de nombres; sin números mágicos; métodos cortos; formato del IDE (Ctrl+Alt+L) 20
Documentación Javadoc en las clases y métodos públicos; README con instrucciones de uso 20
Versiones Proyecto en Git con commits pequeños y .gitignore; mvn clean package funciona desde cero 20

12. Ejercicios: amplía el proyecto

Las soluciones de los seis primeros ejercicios se han implementado sobre una copia del proyecto y comprobado con 5 pruebas JUnit nuevas: en total, 29 pruebas en verde.

Ejercicio 1: Un nuevo tipo de recurso

Añade DVD (código, título, año y duración en minutos) que se prestan 3 días. Comprueba qué ocurre al compilar tras añadirlo a permits y corrige lo necesario para que se guarden y carguen en el CSV.

Ver solución
package es.curso.biblioteca.modelo;

public final class Dvd extends Recurso {

    public static final int DIAS_PRESTAMO = 3;

    private final int minutos;

    public Dvd(String codigo, String titulo, int anio, int minutos) {
        super(codigo, titulo, anio);
        this.minutos = Validacion.rango(minutos, 1, 1_000, "minutos");
    }

    public int getMinutos() { return minutos; }

    @Override public int diasPrestamo() { return DIAS_PRESTAMO; }
    @Override public String tipo() { return "DVD"; }
    @Override public String detalle() { return minutos + " min"; }
}

Al cambiar Recurso a permits Libro, Revista, Dvd, mvn compile falla exactamente donde hay que actuar:

[ERROR] /tmp/p/src/main/java/es/curso/biblioteca/persistencia/RepositorioCsv.java:[40,26] the switch expression does not cover all possible input values

Se añade un caso al switch de guardar y otro al de cargar (más el import de Dvd):

case Dvd d -> String.join(SEP, "DVD", d.getCodigo(), d.getTitulo(),
        String.valueOf(d.getAnio()), String.valueOf(d.getMinutos()), "");
case "DVD" -> new Dvd(c[1], c[2], Integer.parseInt(c[3]), Integer.parseInt(c[4]));

La prueba que lo comprueba (las del resto de ejercicios están en la misma clase AmpliacionesTest, con el mismo setUp que da de alta un libro, una revista, el DVD D001 y dos socios):

@Test
void ejercicio1UnDvdSePresta3DiasYSeGuardaEnCsv(@TempDir Path carpeta) throws Exception {
    assertEquals(HOY.plusDays(3), b.prestar("D001", "12345678Z", HOY).getFechaLimite());
    new RepositorioCsv(carpeta).guardar(b);
    Recurso cargado = new RepositorioCsv(carpeta).cargar().buscarRecurso("D001");
    assertInstanceOf(Dvd.class, cargado);
    assertEquals(125, ((Dvd) cargado).getMinutos());
}

Para completar la ampliación habría que añadir la opción «Alta de DVD» al menú, igual que las de libro y revista.

Ejercicio 2: Socios con deudas

Añade a Biblioteca un método Map<String, Double> deudaPorSocio(LocalDate hoy) que devuelva, para cada socio con préstamos vencidos, la suma de sus multas pendientes, ordenado por DNI.

Ver solución
/** Ejercicio 2: deuda acumulada por cada socio con préstamos vencidos (DNI -> euros). */
public Map<String, Double> deudaPorSocio(LocalDate hoy) {
    Map<String, Double> deudas = new TreeMap<>();
    for (Prestamo p : prestamosVencidos(hoy)) {
        deudas.merge(p.getDniSocio(), p.multa(hoy), Double::sum);
    }
    return deudas;
}

TreeMap mantiene las claves ordenadas. merge(clave, valor, Double::sum) inserta el valor si la clave no existe o lo suma al que había: equivale a deudas.put(dni, deudas.getOrDefault(dni, 0.0) + multa). La prueba:

@Test
void ejercicio2DeudaPorSocio() throws Exception {
    b.registrarPrestamo(new Prestamo("L001", "87654321X", HOY.minusDays(30), HOY.minusDays(15), null));
    b.registrarPrestamo(new Prestamo("R001", "87654321X", HOY.minusDays(10), HOY.minusDays(3), null));
    assertEquals(Map.of("87654321X", 9.0), b.deudaPorSocio(HOY));    // 15 días + 3 días = 18 × 0,50
}

Añade un método que devuelva el catálogo ordenado según un Comparator recibido como parámetro y úsalo para ordenar por año (y, en el menú, por título).

Ver solución
/** Ejercicio 3: catálogo ordenado según el criterio que se reciba. */
public List<Recurso> catalogoOrdenado(Comparator<Recurso> criterio) {
    List<Recurso> lista = new ArrayList<>(catalogo.values());
    lista.sort(criterio);
    return lista;
}

Uso: biblioteca.catalogoOrdenado(Comparator.comparingInt(Recurso::getAnio)) o catalogoOrdenado(Comparator.comparing(Recurso::getTitulo)). Como Recurso es Comparable por código, catalogoOrdenado(Comparator.naturalOrder()) lo ordena por código. La prueba comprueba el orden por año (1605, 2004, 2026):

@Test
void ejercicio3CatalogoOrdenadoPorAnio() {
    List<Recurso> porAnio = b.catalogoOrdenado(Comparator.comparingInt(Recurso::getAnio));
    assertEquals(List.of("L001", "D001", "R001"), porAnio.stream().map(Recurso::getCodigo).toList());
}

(La última línea usa un stream para extraer los códigos; los verás en el curso avanzado. Equivale a recorrer la lista con un for y añadir cada código a otra lista.)

Ejercicio 4: El recurso más prestado

Añade un método String recursoMasPrestado() que devuelva el código del recurso con más préstamos en total (activos o devueltos), o null si no hay ninguno.

Ver solución
/** Ejercicio 4: código del recurso con más préstamos (activos o devueltos), o null si no hay. */
public String recursoMasPrestado() {
    Map<String, Integer> veces = new HashMap<>();
    for (Prestamo p : prestamos) {
        veces.put(p.getCodigoRecurso(), veces.getOrDefault(p.getCodigoRecurso(), 0) + 1);
    }
    String mejor = null;
    for (Map.Entry<String, Integer> e : veces.entrySet()) {
        if (mejor == null || e.getValue() > veces.get(mejor)) {
            mejor = e.getKey();
        }
    }
    return mejor;
}

Es el patrón clásico de contar con un mapa (módulo 16): la clave es lo que se cuenta y el valor, el contador. La prueba presta dos veces el DVD y una el libro:

@Test
void ejercicio4RecursoMasPrestado() throws Exception {
    assertNull(b.recursoMasPrestado());
    b.prestar("D001", "12345678Z", HOY);
    b.devolver("D001", HOY.plusDays(1));
    b.prestar("D001", "87654321X", HOY.plusDays(2));
    b.prestar("L001", "12345678Z", HOY);
    assertEquals("D001", b.recursoMasPrestado());
}

Ejercicio 5: Pedir el DNI hasta que sea válido

Ahora, si el usuario escribe un DNI con la letra mal al dar de alta un socio, el alta falla y vuelve al menú. Añade a Consola un método leerDni que repita la pregunta hasta obtener un DNI válido, y úsalo en altaSocio.

Ver solución
/** Ejercicio 5: repite la pregunta hasta que se escriba un DNI con formato y letra correctos. */
public String leerDni(String pregunta) {
    while (true) {
        String dni = leerTexto(pregunta).toUpperCase();
        if (Socio.esDniValido(dni)) {
            return dni;
        }
        System.out.println("  [!] DNI no válido: deben ser 8 cifras y la letra correcta.");
    }
}

En MenuConsola.altaSocio basta cambiar consola.leerTexto("DNI: ") por consola.leerDni("DNI: "). Fíjate en que la regla de validación sigue estando en un solo sitio (Socio.esDniValido): la consola solo la reutiliza para dar una experiencia mejor al usuario.

Ejercicio 6: Informe de préstamos en un fichero de texto

Crea en el paquete persistencia una clase InformeTexto con un método estático que escriba en un fichero un informe legible de los préstamos activos: título del recurso, nombre del socio, fecha límite y la marca ¡VENCIDO! si procede.

Ver solución
package es.curso.biblioteca.persistencia;

import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.servicio.Biblioteca;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;

/** Ejercicio 6: informe legible de los préstamos activos, con título, socio y aviso de retraso. */
public class InformeTexto {

    public static void exportar(Biblioteca biblioteca, LocalDate hoy, Path destino) throws BibliotecaException {
        try (BufferedWriter out = Files.newBufferedWriter(destino, StandardCharsets.UTF_8)) {
            out.write("INFORME DE PRÉSTAMOS ACTIVOS - " + hoy);
            out.newLine();
            for (Prestamo p : biblioteca.prestamosActivos()) {
                String titulo = biblioteca.buscarRecurso(p.getCodigoRecurso()).getTitulo();
                String socio = biblioteca.buscarSocio(p.getDniSocio()).nombre();
                out.write(String.format("%-25s %-15s hasta %s%s", titulo, socio, p.getFechaLimite(),
                        p.estaVencido(hoy) ? "  ¡VENCIDO!" : ""));
                out.newLine();
            }
        } catch (IOException e) {
            throw new PersistenciaException("No se pudo escribir el informe: " + e.getMessage(), e);
        }
    }
}

try-with-resources (módulo 15) cierra el fichero aunque se produzca una excepción a mitad. La prueba:

@Test
void ejercicio6InformeDeTexto(@TempDir Path carpeta) throws Exception {
    b.registrarPrestamo(new Prestamo("L001", "87654321X", HOY.minusDays(30), HOY.minusDays(15), null));
    Path informe = carpeta.resolve("informe.txt");
    InformeTexto.exportar(b, HOY, informe);
    List<String> lineas = Files.readAllLines(informe);
    assertEquals(2, lineas.size());
    assertTrue(lineas.get(1).startsWith("El Quijote"));
    assertTrue(lineas.get(1).endsWith("¡VENCIDO!"));
}

Resultado de ejecutar todas las pruebas del proyecto ampliado:

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.257 s -- in es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.570 s -- in Servicio Biblioteca
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.383 s -- in es.curso.biblioteca.AmpliacionesTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.317 s -- in es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Ejercicio 7 (propuesto): Renovar un préstamo

Permite renovar un préstamo una sola vez, ampliando la fecha límite otros diasPrestamo() días, siempre que no esté vencido. Tendrás que hacer fechaLimite modificable en Prestamo, añadir un campo renovado (y su columna en el CSV, manteniendo la compatibilidad con los ficheros antiguos) y una opción nueva en el menú. Escribe primero las pruebas (TDD).

Ejercicio 8 (propuesto): Reservas

Si un recurso está prestado, permite que un socio lo reserve. Al devolverlo, el programa debe avisar de que hay una reserva y prestárselo automáticamente al primer socio de la cola. Pista: un Map<String, Deque<String>> (código del recurso → cola de DNI).

Ejercicio 9 (propuesto): Bajas y modificaciones

Añade opciones para dar de baja recursos y socios (solo si no tienen préstamos activos, lanzando una excepción propia en caso contrario) y para modificar el nombre de un socio (recuerda que Socio es un record inmutable: tendrás que sustituirlo por uno nuevo en el mapa).

Ejercicio 10 (propuesto): Historial del socio

Añade la opción «Historial de un socio», que pida un DNI y muestre todos sus préstamos (activos y devueltos) ordenados por fecha, el total de multas pagadas y el número de devoluciones con retraso.

Ejercicio 11 (propuesto): Copia de seguridad

Antes de guardar, copia los tres CSV a una carpeta datos/copia-AAAA-MM-DD/ con Files.copy (módulo 17). Si la carga falla al arrancar por un fichero corrupto, ofrece al usuario restaurar la última copia.

Ejercicio 12 (propuesto): Publica tu proyecto

Sube el proyecto a GitHub con un README.md que explique qué hace, cómo compilarlo (mvn package) y cómo ejecutarlo, el diagrama de clases (puedes escribirlo en PlantUML, módulo 18) y un .gitignore adecuado. Genera la documentación con javadoc y comprueba con JaCoCo (módulo 19) que la cobertura del paquete servicio supera el 90 %.


13. ¿Y ahora qué?

¡Enhorabuena! Has completado el curso de fundamentos: sabes programar en Java con orientación a objetos, diseñar con UML, probar, documentar y versionar tu código, y construir una aplicación completa. Este proyecto tiene limitaciones que son, precisamente, el temario del siguiente nivel:

Limitación del proyecto Se resuelve con…
Bucles para filtrar, contar y ordenar colecciones Programación funcional: lambdas, streams y collectors
Ficheros CSV escritos a mano Bases de datos relacionales con JDBC, y JPA/Hibernate
Interfaz de consola Aplicaciones web y API REST con Spring Boot
Un solo usuario a la vez Concurrencia e hilos
Pruebas solo de la lógica en memoria Pruebas de integración, mocks con Mockito, Testcontainers

💡 Consejo: Continúa con el Curso de Java Avanzado: retomarás este mismo tipo de aplicación para guardarla en MySQL con JDBC e Hibernate y convertirla en un servicio web con Spring Boot. Guarda tu proyecto de la biblioteca en GitHub: será un buen punto de partida.


Resumen

Fase Qué hiciste Herramientas
Requisitos Enunciado → requisitos funcionales y no funcionales numerados Tablas de requisitos
Análisis Sustantivos → clases, verbos → métodos; relaciones Diagrama de clases UML
Diseño Paquetes por capas: modelo, excepciones, servicio, persistencia, ui Arquitectura en capas
Modelo Clase abstracta sellada, interfaz con método por defecto, record con validación, enum con atributos Módulos 12-14
Errores Jerarquía de excepciones comprobadas del negocio Módulo 15
Servicio Reglas de negocio con Map y List; copias inmutables Módulo 16
Persistencia CSV con java.nio.file, UTF-8 y errores con contexto Módulo 17
Interfaz Menú con validación de la entrada y captura centralizada de errores Módulos 4 y 15
Calidad 24 pruebas JUnit, Maven, .jar ejecutable, lista de comprobación Módulos 19 y 20