Java Avanzado
Objetivo : Dominar las herramientas del Java moderno (Java 8–21) para escribir código más expresivo, seguro y mantenible.
1. Lambdas y referencias a métodos
¿Qué es una lambda?
Una lambda es una función anónima: un bloque de código que puede ser tratado como un valor, pasado como argumento, o retornado desde un método.
Sintaxis:
(parámetros) -> expresión
(parámetros) -> { bloque de código }Antes vs ahora
// Java <7
Comparator<String> comparador = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
};
// Java > 8
Comparator<String> comparador = (a, b) -> a.compareTo(b);
Comparator<String> comparador = String::compareTo;Estructura de una lambda
// Lambda con cero paraetros
Runnable tarea = () -> System.out.println("Hola mundo");
// Lambda con un parametro
Consumer<String> imprimir = nombre -> System.out.println("Hola, " + nombre);
// Lambda con dos parametros
BiFunction<Integer, Integer, Integer> sumar = (a, b) -> a + b;
// Lambda con bloque de codigo (necesita return explícito)
Function<Integer, String> clasificar = numero -> {
if (numero > 0) return "positivo";
if (numero < 0) return "negativo";
return "cero";
};Referencias a métodos
Son atajos para lambdas que solo llaman a un método existente.
// Tipos de referencias a métodos:
// 1. Referencia a método estático: Clase::métodoEstático
Function<String, Integer> parsear = Integer::parseInt;
// Equivale a: s -> Integer.parseInt(s)
// 2. Referencia a método de instancia de un objeto específico: instancia::método
String prefijo = "Hola, ";
Function<String, String> saludar = prefijo::concat;
// Equivale a: s -> prefijo.concat(s)
// 3. Referencia a método de instancia de un tipo arbitrario: Clase::método
Function<String, String> aUpperCase = String::toUpperCase;
// Equivale a: s -> s.toUpperCase()
// 4. Referencia a constructor: Clase::new
Supplier<ArrayList<String>> crearLista = ArrayList::new;
// Equivale a: () -> new ArrayList<>()Ejemplos de las referencias a métodos:
List<String> nombres = Arrays.asList("Ana", "Carlos", "Beatriz", "David");
// Imprimir cada elemento
nombres.forEach(System.out::println);
// Convertir a mayúsculas
List<String> mayusculas = nombres.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// Parsear números
List<String> numerosStr = Arrays.asList("1", "2", "3", "4", "5");
List<Integer> numeros = numerosStr.stream()
.map(Integer::parseInt)
.collect(Collectors.toList());
// Constructor reference para crear objetos
record Persona(String nombre) {}
List<Persona> personas = nombres.stream()
.map(Persona::new)
.collect(Collectors.toList());2. Interfaces funcionales
Una interface funcional es aquella que tiene exactamente un método abstracto. Se pueden anotar con @FunctionalInterface para validación en tiempo de compilación, pero sin la anotación son igualmente validas
Ejemplo:
@FunctionalInterface
public interface MiInterfazFuncional {
int operar(int a, int b); // único método abstracto
// Puede tener métodos default y static, ya que no cuenta como abstractos
default void describir() {
System.out.println("Soy una interfaz funcional");
}
}Java por defecto trae 4 interfaces funcionales que son suficientes para trabajar con programación funcional y streams
1. Function<T,R> - Transforma un valor
// T = tipo de entrada, R = tipo de salida
// Forma de uso: R apply(T t)
Function<String, Integer> longitud = String::length;
Function<Integer, String> aString = Object::toString;
System.out.println(longitud.apply("Hola")); // 4
System.out.println(aString.apply(42)); // "42"
// Composición de funciones
Function<String, String> primerCaracter = s -> String.valueOf(s.charAt(0));
Function<String, String> enMayusculas = String::toUpperCase;
// andThen: primero f, luego g
Function<String, String> primeraEnMayus = primerCaracter.andThen(enMayusculas);
System.out.println(primeraEnMayus.apply("java")); // "J"
// compose: primero g, luego f (orden inverso)
Function<String, String> mayusLuegoPrimera = primerCaracter.compose(enMayusculas);
System.out.println(mayusLuegoPrimera.apply("java")); // "J"
// Variantes importantes:
// BiFunction<T, U, R> — dos entradas, una salida
BiFunction<String, Integer, String> repetir = (s, n) -> s.repeat(n);
System.out.println(repetir.apply("ab", 3)); // "ababab"
// UnaryOperator<T> — entrada y salida del mismo tipo
UnaryOperator<String> doblar = s -> s + s;
System.out.println(doblar.apply("hey")); // "heyhey"
// BinaryOperator<T> — dos entradas del mismo tipo, misma salida
BinaryOperator<Integer> max = (a, b) -> a > b ? a : b;
System.out.println(max.apply(5, 3)); // 52. Predicate → Evalúa una condición
// T = tipo a evaluar
// Método: boolean test(T t)
Predicate<String> esVacio = String::isEmpty;
Predicate<Integer> esPar = n -> n % 2 == 0;
Predicate<String> empiezaConA = s -> s.startsWith("A");
System.out.println(esVacio.test("")); // true
System.out.println(esPar.test(4)); // true
System.out.println(empiezaConA.test("Ana")); // true
// Combinación lógica de predicados
Predicate<String> tieneContenido = Predicate.not(String::isEmpty);
Predicate<Integer> esImpar = esPar.negate();
Predicate<Integer> esParYPositivo = esPar.and(n -> n > 0);
Predicate<Integer> esParONegativo = esPar.or(n -> n < 0);
System.out.println(esImpar.test(3)); // true
System.out.println(esParYPositivo.test(4)); // true
System.out.println(esParONegativo.test(-3)); // true
// Uso con colecciones
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Predicate<Integer> esMayorQue5 = n -> n > 5;
List<Integer> mayoresQue5 = numeros.stream()
.filter(esPar.and(esMayorQue5))
.collect(Collectors.toList());
// [6, 8, 10]
// BiPredicate<T, U> — dos argumentos
BiPredicate<String, String> contiene = String::contains;
System.out.println(contiene.test("Hola mundo", "mundo")); // true3. Consumer → Consume un valor sin retorno
// T = tipo a consumir
// Método: void accept(T t)
Consumer<String> imprimir = System.out::println;
Consumer<List<Integer>> imprimirLista = lista -> lista.forEach(System.out::println);
imprimir.accept("Hola");
// Encadenar consumers
Consumer<String> enMayus = s -> System.out.print(s.toUpperCase());
Consumer<String> conSalto = s -> System.out.println();
Consumer<String> imprimirConSalto = enMayus.andThen(conSalto);
imprimirConSalto.accept("java"); // imprime "JAVA\n"
// Ejemplo práctico: procesar una lista de pedidos
record Pedido(String id, double total, boolean procesado) {}
Consumer<Pedido> registrar = p -> System.out.println("Registrado: " + p.id());
Consumer<Pedido> notificar = p -> System.out.println("Notificado: " + p.id());
Consumer<Pedido> procesar = registrar.andThen(notificar);
List<Pedido> pedidos = List.of(
new Pedido("P001", 150.0, false),
new Pedido("P002", 200.0, false)
);
pedidos.forEach(procesar);
// BiConsumer<T, U> — dos argumentos
BiConsumer<String, Integer> imprimirN = (s, n) -> System.out.println(s.repeat(n));
imprimirN.accept("Java! ", 3); // "Java! Java! Java! "4. Supplier → Provee un valor, sin entrada
// T = tipo a proveer
// Método: T get()
Supplier<String> saludo = () -> "Hola mundo";
Supplier<List<String>> nuevaLista = ArrayList::new;
Supplier<LocalDateTime> ahora = LocalDateTime::now;
System.out.println(saludo.get()); // "Hola mundo"
// Uso con lazy evaluation (evaluación diferida)
// El valor NO se calcula hasta que se llame .get()
Supplier<Double> calculoLento = () -> {
// Simula un cálculo costoso
return Math.random() * 1000;
};
// Uso clásico con Optional
Optional<String> nombre = Optional.empty();
String resultado = nombre.orElseGet(() -> "Nombre por defecto");
// La lambda solo se evalúa si Optional está vacío
// Ejemplo práctico: Factory pattern con Supplier
Map<String, Supplier<Animal>> fabrica = new HashMap<>();
fabrica.put("perro", Perro::new);
fabrica.put("gato", Gato::new);
Animal animal = fabrica.get("perro").get();
// Supplier para valores costosos (memoización simple)
class CacheSupplier<T> {
private T valor;
private final Supplier<T> fuente;
CacheSupplier(Supplier<T> fuente) {
this.fuente = fuente;
}
T get() {
if (valor == null) {
valor = fuente.get(); // solo se calcula una vez
}
return valor;
}
}Resumen
| Interfaz | Entrada | Salida | Método | Uso típico |
|---|---|---|---|---|
Function<T,R> | T | R | apply() | Transformar/mapear |
Predicate<T> | T | boolean | test() | Filtrar/validar |
Consumer<T> | T | void | accept() | Efectos secundarios |
Supplier<T> | ninguna | T | get() | Crear/proveer valores |
3. Programación funcional
Principios clave:
- Inmutabilidad
// Mutable — estado cambiante, difícil de razonar
class Contador {
private int valor = 0;
public void incrementar() { valor++; }
public int getValor() { return valor; }
}
// Inmutable — el estado no cambia, retorna nuevos objetos
record Contador(int valor) {
Contador incrementar() {
return new Contador(valor + 1);
}
}
Contador c = new Contador(0);
Contador c2 = c.incrementar(); // c sigue siendo 0- Funciones puras
// Función impura — depende de estado externo, tiene efectos secundarios
private int multiplicador = 2;
public int duplicar(int n) {
System.out.println("duplicando"); // efecto secundario
return n * multiplicador; // depende de estado externo
}
// Función pura — mismo input = mismo output, sin efectos secundarios
public static int duplicar(int n) {
return n * 2;
}
// Las funciones puras son:
// - Fáciles de testear
// - Fáciles de razonar
// - Seguras para concurrencia
// - Fáciles de cachear- Composición de funciones
// Construir comportamientos complejos combinando funciones simples
Function<String, String> limpiar = String::trim;
Function<String, String> normalizar = String::toLowerCase;
Function<String, Boolean> esValido = s -> s.length() >= 3;
// Componer: limpiar, luego normalizar, luego verificar
Function<String, Boolean> validarNombre = limpiar
.andThen(normalizar)
.andThen(esValido::apply);
// Patrón común: pipeline de transformaciones
String input = " JAVA ";
String resultado = limpiar.andThen(normalizar).apply(input);
// "java"
// Composición de predicados para reglas de negocio
Predicate<String> noEsNulo = Objects::nonNull;
Predicate<String> noEsVacio = Predicate.not(String::isBlank);
Predicate<String> tieneEspacio = s -> s.contains(" ");
Predicate<String> esNombreCompleto = noEsNulo
.and(noEsVacio)
.and(tieneEspacio);
System.out.println(esNombreCompleto.test("Ana García")); // true
System.out.println(esNombreCompleto.test("Ana")); // false- Funciones de orden superior (Funciones que retornan funciones)
// Funciones que reciben o retornan otras funciones
// Recibe una función como argumento
public static <T, R> List<R> transformar(List<T> lista, Function<T, R> f) {
return lista.stream().map(f).collect(Collectors.toList());
}
// Retorna una función
public static Predicate<Integer> mayorQue(int umbral) {
return n -> n > umbral;
}
public static Function<Integer, Integer> multiplicadorDe(int factor) {
return n -> n * factor;
}
// Uso:
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
List<Integer> longitudes = transformar(nombres, String::length);
// [3, 6, 7]
Predicate<Integer> mayorQue10 = mayorQue(10);
System.out.println(mayorQue10.test(15)); // true
Function<Integer, Integer> triple = multiplicadorDe(3);
System.out.println(triple.apply(5)); // 154. API Streams
¿Qué es un Stream?
Un Stream es una secuencia de elementos que soporta operaciones funcionales en pipeline. No almacena datos, no modifica la fuente, y es evaluado de forma lazy (perezosa).
Fuente → [Operaciones intermedias] → Operación terminal
Características:
- No almacena datos — fluye sobre la fuente
- Lazy — las operaciones intermedias no se ejecutan hasta que hay una terminal
- Consumible — solo se puede recorrer una vez
- Puede ser infinito
// Desde colección
List<String> lista = List.of("a", "b", "c");
Stream<String> s1 = lista.stream();
Stream<String> s2 = lista.parallelStream(); // stream paralelo, util para operaciones que requieren de procesador
// Desde array
int[] array = {1, 2, 3, 4, 5};
IntStream s3 = Arrays.stream(array);
// Desde valores directos
Stream<String> s4 = Stream.of("x", "y", "z");
// Stream vacío
Stream<String> vacio = Stream.empty();
// Stream infinito con iterate
Stream<Integer> naturales = Stream.iterate(0, n -> n + 1);
// [0, 1, 2, 3, 4, ...]
Stream<Integer> pares = Stream.iterate(0, n -> n + 2).limit(5);
// [0, 2, 4, 6, 8]
// Stream infinito con generate
Stream<Double> aleatorios = Stream.generate(Math::random).limit(3);
// Desde rango
IntStream rango = IntStream.range(1, 6); // [1, 2, 3, 4, 5]
IntStream rangoCerrado = IntStream.rangeClosed(1, 5); // [1, 2, 3, 4, 5]
// Desde fichero (líneas)
try (Stream<String> lineas = Files.lines(Path.of("archivo.txt"))) {
lineas.forEach(System.out::println);
}
// Stream de un Optional
Optional<String> opt = Optional.of("valor");
Stream<String> fromOptional = opt.stream(); // Java 9+Las operaciones intermedias en los streams por lo general retornan otro stream, para seguir operando:
List<String> nombres = Arrays.asList(
"Ana", "Carlos", "Beatriz", "David", "Ana", "Elena"
);
// --- filter(Predicate) — filtrar elementos ---
Stream<String> conB = nombres.stream()
.filter(n -> n.startsWith("B"));
// ["Beatriz"]
// --- map(Function) — transformar elementos ---
Stream<Integer> longitudes = nombres.stream()
.map(String::length);
// [3, 6, 7, 5, 3, 5]
// --- flatMap(Function) — aplanar streams anidados ---
List<List<Integer>> matrizNumeros = List.of(
List.of(1, 2, 3),
List.of(4, 5, 6),
List.of(7, 8, 9)
);
List<Integer> aplanado = matrizNumeros.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
// --- distinct() — eliminar duplicados ---
Stream<String> sinDuplicados = nombres.stream().distinct();
// ["Ana", "Carlos", "Beatriz", "David", "Elena"]
// --- sorted() / sorted(Comparator) — ordenar ---
Stream<String> ordenados = nombres.stream().sorted();
// ["Ana", "Ana", "Beatriz", "Carlos", "David", "Elena"]
Stream<String> porLongitud = nombres.stream()
.sorted(Comparator.comparingInt(String::length).reversed());
// ["Beatriz", "Carlos", "David", "Elena", "Ana", "Ana"]
// --- limit(n) — tomar primeros n elementos ---
Stream<String> primerosTres = nombres.stream().limit(3);
// ["Ana", "Carlos", "Beatriz"]
// --- skip(n) — saltar primeros n elementos ---
Stream<String> sinPrimerosDos = nombres.stream().skip(2);
// ["Beatriz", "David", "Ana", "Elena"]
// --- peek(Consumer) — para depuración (no modifica) ---
Stream<String> debug = nombres.stream()
.filter(n -> n.length() > 3)
.peek(n -> System.out.println("Pasó filtro: " + n))
.map(String::toUpperCase);
// --- mapToInt / mapToLong / mapToDouble — streams primitivos ---
IntStream longitudesInt = nombres.stream()
.mapToInt(String::length);
// --- takeWhile(Predicate) — tomar mientras se cumple
List<Integer> nums = List.of(2, 4, 6, 7, 8, 10);
List<Integer> hastaImpar = nums.stream()
.takeWhile(n -> n % 2 == 0)
.collect(Collectors.toList());
// [2, 4, 6]
// --- dropWhile(Predicate) — saltar mientras se cumple
List<Integer> desdeImpar = nums.stream()
.dropWhile(n -> n % 2 == 0)
.collect(Collectors.toList());
// [7, 8, 10]Y luego están las operaciones que consumen un stream, y por lo tanto, lo terminan.
List<Integer> numeros = Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6, 5);
// --- collect()
List<Integer> lista = numeros.stream().collect(Collectors.toList());
Set<Integer> conjunto = numeros.stream().collect(Collectors.toSet());
String unido = Stream.of("a","b","c").collect(Collectors.joining(", ", "[", "]"));
// "[a, b, c]"
// --- forEach(Consumer) ---
numeros.stream().forEach(System.out::println);
// --- count() ---
long cantidad = numeros.stream().filter(n -> n > 3).count(); // 5
// --- findFirst() / findAny() — retornan Optional ---
Optional<Integer> primero = numeros.stream().filter(n -> n > 5).findFirst();
primero.ifPresent(System.out::println); // 9
// --- anyMatch / allMatch / noneMatch ---
boolean alguno = numeros.stream().anyMatch(n -> n > 8); // true (9)
boolean todos = numeros.stream().allMatch(n -> n > 0); // true
boolean ninguno = numeros.stream().noneMatch(n -> n < 0); // true
// --- min / max — retornan Optional ---
Optional<Integer> minimo = numeros.stream().min(Integer::compareTo); // 1
Optional<Integer> maximo = numeros.stream().max(Integer::compareTo); // 9
// --- sum / average / statistics (en IntStream) ---
int suma = numeros.stream().mapToInt(Integer::intValue).sum(); // 36
OptionalDouble promedio = numeros.stream().mapToInt(i -> i).average(); // 4.0
IntSummaryStatistics stats = numeros.stream().mapToInt(i -> i).summaryStatistics();
System.out.println(stats.getMin()); // 1
System.out.println(stats.getMax()); // 9
System.out.println(stats.getSum()); // 36
System.out.println(stats.getAverage()); // 4.0
// --- reduce() — reducir a un solo valor ---
int producto = numeros.stream()
.reduce(1, (acc, n) -> acc * n); // con identidad
Optional<Integer> sumaOpt = numeros.stream()
.reduce(Integer::sum); // sin identidad, retorna Optional
// reduce más explícito:
int sumaManual = numeros.stream()
.reduce(0, (acumulador, elemento) -> acumulador + elemento);
// --- toArray() ---
Integer[] array = numeros.stream().toArray(Integer[]::new);
// --- iterator() / spliterator() ---
Iterator<Integer> it = numeros.stream().iterator();También es posible convertir una colección a otro tipo de colección usando streams
List<Persona> personas = List.of(
new Persona("Ana", "Ingeniería", 28),
new Persona("Carlos", "Marketing", 35),
new Persona("Beatriz", "Ingeniería", 31),
new Persona("David", "Marketing", 27),
new Persona("Elena", "Ingeniería", 24)
);
// --- groupingBy — agrupar ---
Map<String, List<Persona>> porDepartamento = personas.stream()
.collect(Collectors.groupingBy(Persona::departamento));
// {"Ingeniería": [...], "Marketing": [...]}
// groupingBy con downstream collector
Map<String, Long> cantidadPorDepto = personas.stream()
.collect(Collectors.groupingBy(
Persona::departamento,
Collectors.counting()
));
// {"Ingeniería": 3, "Marketing": 2}
Map<String, Double> promedioPorDepto = personas.stream()
.collect(Collectors.groupingBy(
Persona::departamento,
Collectors.averagingInt(Persona::edad)
));
Map<String, Optional<Persona>> masJovenPorDepto = personas.stream()
.collect(Collectors.groupingBy(
Persona::departamento,
Collectors.minBy(Comparator.comparingInt(Persona::edad))
));
// --- partitioningBy — dividir en dos grupos ---
Map<Boolean, List<Persona>> mayoresMenores = personas.stream()
.collect(Collectors.partitioningBy(p -> p.edad() >= 30));
// {true: [Carlos, Beatriz], false: [Ana, David, Elena]}
// --- toMap ---
Map<String, Integer> nombreEdad = personas.stream()
.collect(Collectors.toMap(
Persona::nombre,
Persona::edad
));
// toMap con manejo de claves duplicadas
Map<String, String> deptoNombre = personas.stream()
.collect(Collectors.toMap(
Persona::departamento,
Persona::nombre,
(existente, nuevo) -> existente + ", " + nuevo // resolver duplicados
));
// --- joining ---
String nombresStr = personas.stream()
.map(Persona::nombre)
.collect(Collectors.joining(", "));
// "Ana, Carlos, Beatriz, David, Elena"
// --- summarizingInt ---
IntSummaryStatistics edadStats = personas.stream()
.collect(Collectors.summarizingInt(Persona::edad));
// --- Collectors.teeing (Java 12) — aplicar dos collectors a la vez ---
record Resultado(long count, double avg) {}
Resultado res = personas.stream()
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.averagingInt(Persona::edad),
Resultado::new
));Ahora, los streams paralelos ayudan a optimizar el rendimiento en operaciones que requieren de procesador.
// Un stream paralelo divide la carga entre múltiples hilos
List<Integer> numerosGrandes = IntStream.rangeClosed(1, 1_000_000)
.boxed()
.collect(Collectors.toList());
// Stream secuencial
long sumaSecuencial = numerosGrandes.stream()
.mapToLong(Integer::longValue)
.sum();
// Stream paralelo — puede ser más rápido con grandes volúmenes
long sumaParalela = numerosGrandes.parallelStream()
.mapToLong(Integer::longValue)
.sum();
// CUIDADO con el orden en paralelos:
List<Integer> listaOrdenada = numerosGrandes.parallelStream()
.filter(n -> n % 2 == 0)
.sorted()
.limit(10)
.collect(Collectors.toList()); // el sorted() anula ventaja del paralelo
// Usar paralelos cuando:
// - La operación es CPU-intensiva
// - El volumen de datos es grande (>10.000 elementos típicamente)
// - Las operaciones son independientes entre sí
// - El orden no importa
// Evitar paralelos cuando:
// - El volumen es pequeño
// - Hay efectos secundarios (escritura a BD, logs, etc.)
// - El orden es importante
// - Hay operaciones de I/OEjercicio
// Sistema de Biblioteca — implementar lo siguiente:
/*
Funcionalidades a implementar en BibliotecaService
1. Libro más reciente por autor
Dado un autor, retorna el Optional<Libro> con el año más alto. Usar filter + max.
2. Libros por género con filtro de rango de años
Retorna un Map<Genero, List<Libro>> con los libros ordenados por año, filtrando solo los publicados entre anioDesde y anioHasta.
3. Estadísticas por usuario
Retorna un Map<String, EstadisticaUsuario> que incluya: total de préstamos, préstamos activos, préstamos vencidos, promedio de días (solo devueltos) e ISBNs distintos prestados.
El límite de días para considerar un préstamo vencido debe variar según la membresía: 14 días para BASICO, 30 para PREMIUM, 60 para INVESTIGADOR.
4. Préstamos vencidos con detalle
Retorna una List<PrestamoVencido> (record anidado con usuarioId, libroIsbn y diasVencido), ordenada de mayor a menor antigüedad.
5. Reporte enriquecido por género
Retorna un Map<Genero, ReporteGenero> donde cada entrada incluye: cantidad de libros, lista de títulos ordenada por año descendente, año más antiguo y año más reciente del género.
6. Disponibilidad de copias en tiempo real
Retorna un Map<String, Integer> de ISBN → copias disponibles, calculado como copiasTotales - préstamos activos. Usa groupingBy + counting.
7. Top N autores más prestados
Dado un número N, retorna los N autores con más préstamos históricos (incluyendo devueltos). Requiere hacer un join entre la lista de libros y la de préstamos mediante un Map intermedio.
// MODELOS
*/
enum Genero { FICCION, NO_FICCION, CIENCIA, HISTORIA, TECNOLOGIA }
record Libro(String isbn, String titulo, String autor, int anio, Genero genero, int copiasTotales) {
}
record Usuario(String id, String nombre, String email, NivelMembresia nivel) {}
enum NivelMembresia { BASICO, PREMIUM, INVESTIGADOR }
record Prestamo(String libroIsbn, String usuarioId, LocalDate fechaInicio, LocalDate fechaDevolucion, boolean devuelto) {
}
// ESTADÍSTICAS (Value Objects inmutables)
record EstadisticaUsuario(
String usuarioId,
long totalPrestamos,
long prestamosActivos,
long prestamosVencidos,
OptionalDouble promedioDias,
List<String> isbnsPrestados
) {}
record ReporteGenero(
Genero genero,
long cantidadLibros,
List<String> titulos,
int anioMasAntiguo,
int anioMasReciente
) {}
// EXCEPCIONES DE DOMINIO
class LibroNoDisponibleException extends RuntimeException {
LibroNoDisponibleException(String isbn) {
super("No hay copias disponibles del libro: " + isbn);
}
}
class UsuarioNoEncontradoException extends RuntimeException {
UsuarioNoEncontradoException(String id) {
super("Usuario no encontrado: " + id);
}
}
// SERVICIO PRINCIPAL
// Acá empiezan a desarrollar su solución:
public class BibliotecaService{
/**/
}