La lección anterior terminó con una frase incómoda: BiblioTech tiene sus anotaciones puestas y no funciona nada. @CampoCsv marca los campos exportables y no exporta nada. @Auditable marca las operaciones auditables y no registra nada. Las etiquetas están, y no hay nadie leyéndolas.

Esta lección escribe al lector.

La reflexión es la capacidad de un programa para examinarse y manipularse a sí mismo en tiempo de ejecución: descubrir qué clases existen, qué campos y métodos tienen, leer sus anotaciones, crear instancias, invocar métodos y modificar campos, todo ello sin conocer nada de eso en tiempo de compilación.

Dicho de otro modo: hasta ahora, para llamar a libro.getTitulo() necesitabas que la clase Libro existiera cuando compilabas. Con reflexión puedes escribir código que reciba un Object cualquiera, descubra que tiene un método llamado getTitulo, lo invoque y devuelva el resultado — y ese código seguirá funcionando dentro de tres años con clases que hoy no existen.

Esa es exactamente la razón por la que Spring puede inyectar tus servicios, Hibernate puede mapear tus entidades, JUnit puede ejecutar tus pruebas y Jackson puede convertir tus objetos a JSON. Ninguno de esos frameworks conoce tus clases. Las descubren en ejecución, leen sus anotaciones y actúan. Al terminar esta lección habrás construido, con tus manos, versiones reducidas de los dos mecanismos que sostienen ese edificio: un motor que consume anotaciones y un proxy dinámico que intercepta llamadas.

Y con eso, los frameworks del módulo 11 dejarán de ser magia.

Contenido

  1. Qué es la reflexión y qué permite
  2. El objeto Class<?>: las tres formas de obtenerlo
  3. Inspección básica: nombres, jerarquía y modificadores
  4. isEnum, isRecord, isInterface y compañía
  5. Campos: getFields frente a getDeclaredFields
  6. Métodos y constructores: la misma distinción
  7. Crear instancias con newInstance
  8. Invocar métodos con invoke
  9. Excepciones en la reflexión: InvocationTargetException
  10. Acceder a lo privado: setAccessible(true)
  11. Lo que rompe y lo que el sistema de módulos ya no permite
  12. Leer anotaciones en ejecución
  13. BiblioTech: el ExportadorAnotado que consume @CampoCsv
  14. Genéricos y reflexión: qué sobrevive al borrado
  15. Proxies dinámicos: Proxy e InvocationHandler
  16. BiblioTech: el proxy que consume @Auditable
  17. El precio de la reflexión
  18. Cuándo usarla y cuándo no
  19. MethodHandles y VarHandle: la alternativa moderna
  20. Errores Comunes y Consejos
  21. Ejercicios

  1. Qué es la reflexión y qué permite

El nombre viene de la idea de que el programa se mira en un espejo. La JVM mantiene, para cada clase cargada, una descripción completa de su estructura: nombre, superclase, interfaces, campos, métodos, constructores, anotaciones. La API de reflexión (java.lang.reflect más java.lang.Class) es la puerta a esa descripción.

Compara los dos mundos:

// ACCESO NORMAL: todo se resuelve en COMPILACION
Libro libro = new Libro("Java Efectivo", "978-0000000001");
String titulo = libro.getTitulo();

El compilador comprueba que Libro existe, que tiene un constructor con esa firma y que tiene un método getTitulo() que devuelve String. Si algo falla, no compila.

// ACCESO POR REFLEXION: todo se resuelve en EJECUCION
Class<?> tipo = Class.forName("com.nexussoftware.bibliotech.dominio.Libro");
Object libro = tipo.getDeclaredConstructor(String.class, String.class)
                   .newInstance("Java Efectivo", "978-0000000001");
Method metodo = tipo.getMethod("getTitulo");
String titulo = (String) metodo.invoke(libro);

Aquí Libro es una cadena de texto. El compilador no comprueba nada: no sabe si esa clase existe, ni si tiene ese constructor, ni si tiene ese método. Todo eso se descubre —o falla— en ejecución.

Ese es el trato: ganas flexibilidad total y pierdes toda la ayuda del compilador. Merece la pena cuando escribes algo que debe funcionar con clases que no conoces; es una pésima idea en la lógica de negocio de tu aplicación.

Qué permite exactamente:

Capacidad Método clave
Descubrir la estructura de una clase getFields, getMethods, getConstructors
Leer anotaciones getAnnotation, isAnnotationPresent
Crear objetos sin conocer el tipo getDeclaredConstructor().newInstance()
Invocar métodos por su nombre Method.invoke
Leer y escribir campos, incluso privados Field.get, Field.set, setAccessible
Generar implementaciones de interfaces al vuelo Proxy.newProxyInstance

  1. El objeto Class<?>: las tres formas de obtenerlo

Todo empieza en Class<T>. Cada clase, interfaz, enum, record, array y primitivo cargado en la JVM tiene exactamente un objeto Class que lo describe.

// 1. LITERAL DE CLASE: .class sobre el nombre del tipo
Class<Libro> tipo1 = Libro.class;

// 2. DESDE UNA INSTANCIA: getClass()
Libro libro = new Libro("Java Efectivo", "978-0000000001");
Class<? extends Libro> tipo2 = libro.getClass();

// 3. DESDE SU NOMBRE COMPLETO: Class.forName
Class<?> tipo3 = Class.forName("com.nexussoftware.bibliotech.dominio.Libro");
Forma Sintaxis Requiere la clase en compilación Tipo devuelto Carga la clase Excepción
Literal Libro.class Class<Libro> (tipado) No la inicializa Ninguna
Instancia objeto.getClass() No (basta un Object) Class<? extends X> Ya está cargada Ninguna
Por nombre Class.forName("...") No Class<?> (sin tipar) Sí, y la inicializa ClassNotFoundException

Tres detalles que importan:

El literal está tipado; forName no. Libro.class es un Class<Libro>, y por eso tipo.cast(x) devuelve un Libro sin cast manual (el token de tipo de 10-01). Class.forName(...) devuelve Class<?>, y todo lo que salga de él será Object.

getClass() devuelve el tipo REAL, no el declarado. Esta distinción es el polimorfismo de 03-06 visto desde la reflexión:

Material m = new Libro("Java Efectivo", "978-0000000001");
System.out.println(m.getClass().getSimpleName());   // Libro, no Material

Class.forName inicializa la clase. Ejecuta sus bloques static y sus inicializadores de campos estáticos. Es el mecanismo por el que los antiguos drivers JDBC se registraban solos con Class.forName("com.mysql.jdbc.Driver"). Si no quieres esa inicialización, existe la variante de tres argumentos:

Class<?> sinInicializar = Class.forName(
        "com.nexussoftware.bibliotech.dominio.Libro",
        false,                                  // no inicializar
        Thread.currentThread().getContextClassLoader());

Casos especiales que conviene conocer:

System.out.println(int.class.getName());          // int
System.out.println(Integer.class.getName());      // java.lang.Integer  (distintos)
System.out.println(String[].class.getName());     // [Ljava.lang.String;
System.out.println(int[][].class.getName());      // [[I
System.out.println(void.class.getName());         // void

Esa notación con corchetes es la interna de la JVM para los arrays: [ por cada dimensión, luego L<nombre>; para objetos o una letra para primitivos (I int, D double, Z boolean, J long...). Aparece en trazas de pila y en mensajes de error, y ahora la reconocerás.

  1. Inspección básica: nombres, jerarquía y modificadores

Con el Class en la mano, se puede preguntar de todo.

package com.nexussoftware.bibliotech;

import java.lang.reflect.Modifier;
import java.util.Arrays;

public class InspectorDeClases {

    public static void describir(Class<?> tipo) {

        System.out.println("=== " + tipo.getSimpleName() + " ===");

        // --- NOMBRES ---
        System.out.println("getName():          " + tipo.getName());
        System.out.println("getSimpleName():    " + tipo.getSimpleName());
        System.out.println("getCanonicalName(): " + tipo.getCanonicalName());
        System.out.println("getPackageName():   " + tipo.getPackageName());

        // --- JERARQUIA ---
        Class<?> padre = tipo.getSuperclass();
        System.out.println("superclase:         " + (padre == null ? "ninguna" : padre.getSimpleName()));

        System.out.println("interfaces:         " + Arrays.toString(
                Arrays.stream(tipo.getInterfaces()).map(Class::getSimpleName).toArray()));

        // Cadena completa de herencia hasta Object
        StringBuilder cadena = new StringBuilder(tipo.getSimpleName());
        for (Class<?> c = tipo.getSuperclass(); c != null; c = c.getSuperclass()) {
            cadena.append(" -> ").append(c.getSimpleName());
        }
        System.out.println("cadena:             " + cadena);

        // --- MODIFICADORES ---
        int mods = tipo.getModifiers();
        System.out.println("modificadores:      " + Modifier.toString(mods));
        System.out.println("  es public:        " + Modifier.isPublic(mods));
        System.out.println("  es abstract:      " + Modifier.isAbstract(mods));
        System.out.println("  es final:         " + Modifier.isFinal(mods));
        System.out.println();
    }

    public static void main(String[] args) {
        describir(Libro.class);
        describir(Material.class);
        describir(Gravedad.class);
    }
}
=== Libro ===
getName():          com.nexussoftware.bibliotech.dominio.Libro
getSimpleName():    Libro
getCanonicalName(): com.nexussoftware.bibliotech.dominio.Libro
getPackageName():   com.nexussoftware.bibliotech.dominio
superclase:         Material
interfaces:         []
cadena:             Libro -> Material -> Object
modificadores:      public
  es public:        true
  es abstract:      false
  es final:         false

=== Material ===
getName():          com.nexussoftware.bibliotech.dominio.Material
getSimpleName():    Material
getCanonicalName(): com.nexussoftware.bibliotech.dominio.Material
getPackageName():   com.nexussoftware.bibliotech.dominio
superclase:         Object
interfaces:         [Identificable]
cadena:             Material -> Object
modificadores:      public abstract
  es public:        true
  es abstract:      true
  es final:         false

Los tres nombres se diferencian sobre todo en casos raros, y esos casos raros son justo donde uno se equivoca:

Tipo getName() getSimpleName() getCanonicalName()
Libro com...Libro Libro com...Libro
Map.Entry (anidada) java.util.Map$Entry Entry java.util.Map.Entry
String[] [Ljava.lang.String; String[] java.lang.String[]
Clase anónima Ejemplo$1 "" (vacío) null
Clase local Ejemplo$1Local Local null

Regla práctica: usa getSimpleName() para mensajes al usuario, getName() para identificar la clase de forma única (es el que acepta Class.forName), y getCanonicalName() solo si quieres el nombre tal y como se escribiría en el código fuente.

Modifier es una máscara de bits. getModifiers() devuelve un int cuyos bits codifican public, private, static, final, abstract, synchronized, volatile, transient, native. Los métodos estáticos de Modifier los interpretan, y Modifier.toString(int) los imprime.

  1. isEnum, isRecord, isInterface y compañía

Un grupo de métodos booleanos permite clasificar el tipo:

public static String clasificar(Class<?> tipo) {
    if (tipo.isPrimitive())   return "primitivo";
    if (tipo.isArray())       return "array de " + tipo.getComponentType().getSimpleName();
    if (tipo.isEnum())        return "enum con " + tipo.getEnumConstants().length + " constantes";
    if (tipo.isRecord())      return "record con " + tipo.getRecordComponents().length + " componentes";
    if (tipo.isAnnotation())  return "anotación";
    if (tipo.isInterface())   return "interfaz";
    if (tipo.isSealed())      return "clase sellada";     // Java 17, ver 10-06
    if (Modifier.isAbstract(tipo.getModifiers())) return "clase abstracta";
    return "clase concreta";
}
System.out.println(Libro.class          + " -> " + clasificar(Libro.class));
System.out.println(Material.class       + " -> " + clasificar(Material.class));
System.out.println(Gravedad.class       + " -> " + clasificar(Gravedad.class));
System.out.println(Ficha.class          + " -> " + clasificar(Ficha.class));
System.out.println(Identificable.class  + " -> " + clasificar(Identificable.class));
System.out.println(int[].class          + " -> " + clasificar(int[].class));
System.out.println(CampoCsv.class       + " -> " + clasificar(CampoCsv.class));
class ...Libro -> clase concreta
class ...Material -> clase abstracta
class ...Gravedad -> enum con 3 constantes
class ...Ficha -> record con 3 componentes
interface ...Identificable -> interfaz
class [I -> array de int
interface ...CampoCsv -> anotación

Dos métodos especialmente útiles para BiblioTech:

getEnumConstants() devuelve un array con todas las constantes de un enum, en orden de declaración. Es lo que permite construir un menú desplegable sin conocer el enum:

for (Object constante : Gravedad.class.getEnumConstants()) {
    System.out.println("  - " + constante);
}

getRecordComponents() (Java 16) da los componentes de un record con su nombre, tipo, anotaciones y método de acceso. Es lo que usa Jackson para serializar records sin configuración:

for (RecordComponent comp : Ficha.class.getRecordComponents()) {
    System.out.printf("  %s %s (accesor: %s)%n",
            comp.getType().getSimpleName(), comp.getName(), comp.getAccessor().getName());
}
  String isbn (accesor: isbn)
  String titulo (accesor: titulo)
  boolean disponible (accesor: disponible)

Y también hay comprobaciones de relación entre tipos, que son el equivalente reflexivo de instanceof:

Material m = new Libro("Java Efectivo", "978-0000000001");

// isInstance: equivale a  (m instanceof Libro)
System.out.println(Libro.class.isInstance(m));                    // true

// isAssignableFrom: ¿se puede asignar un Libro a una variable Material?
System.out.println(Material.class.isAssignableFrom(Libro.class)); // true
System.out.println(Libro.class.isAssignableFrom(Material.class)); // false

El orden de isAssignableFrom se confunde siempre. Léelo así: Padre.class.isAssignableFrom(Hijo.class) es true, igual que Padre p = new Hijo() compila.

  1. Campos: getFields frente a getDeclaredFields

Aquí está la distinción que confunde a todo el mundo, y que conviene fijar de una vez porque se repite idéntica para métodos y constructores.

Método Devuelve Incluye heredados Incluye privados
getFields() Campos públicos, propios y heredados No
getDeclaredFields() Todos los campos declarados en esta clase No

Es decir: una lista mira hacia arriba (herencia) y la otra mira hacia dentro (visibilidad). Ninguna de las dos da "todos los campos de todo".

graph TD
    A["Campos de una clase"] --> B["getFields<br/>publicos<br/>+ heredados publicos"]
    A --> C["getDeclaredFields<br/>todos los declarados aqui<br/>private, protected, public, package"]
    B --> D["NO ve los privados"]
    C --> E["NO ve los heredados"]

Con las clases de BiblioTech:

package com.nexussoftware.bibliotech;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;

public class ComparadorDeCampos {

    public static void main(String[] args) {

        System.out.println("--- getFields() sobre Libro ---");
        for (Field f : Libro.class.getFields()) {
            System.out.printf("  %-12s %-10s declarado en %s%n",
                    f.getName(), Modifier.toString(f.getModifiers()),
                    f.getDeclaringClass().getSimpleName());
        }

        System.out.println("--- getDeclaredFields() sobre Libro ---");
        for (Field f : Libro.class.getDeclaredFields()) {
            System.out.printf("  %-12s %-20s %s%n",
                    f.getName(), Modifier.toString(f.getModifiers()),
                    f.getType().getSimpleName());
        }
    }
}

Suponiendo que Material tiene public static final String CATEGORIA_POR_DEFECTO y campos privados, y Libro tiene sus campos privados:

--- getFields() sobre Libro ---
  CATEGORIA_POR_DEFECTO public static final declarado en Material
--- getDeclaredFields() sobre Libro ---
  isbn         private final        String
  titulo       private final        String
  autor        private final        String
  paginas      private final        int
  valoracion   private final        double
  prestado     private              boolean

Conclusión práctica: como en Java bien escrito todos los campos son privados (encapsulamiento, 03-07), getFields() casi siempre devuelve un array vacío o solo constantes. El método que se usa de verdad es getDeclaredFields().

Y si necesitas todos los campos incluyendo los heredados, hay que recorrer la jerarquía a mano:

/**
 * Todos los campos declarados en la clase y en todas sus superclases,
 * de la mas derivada a la mas general.
 */
public static List<Field> todosLosCampos(Class<?> tipo) {
    List<Field> campos = new ArrayList<>();
    for (Class<?> c = tipo; c != null && c != Object.class; c = c.getSuperclass()) {
        campos.addAll(Arrays.asList(c.getDeclaredFields()));
    }
    return campos;
}

Este bucle es un patrón que verás en cualquier framework y que usarás en el ExportadorAnotado del apartado 13.

Leer y escribir un campo:

Libro libro = new Libro("Java Efectivo", "978-0000000001", "Joshua Bloch", 412, 4.8);

Field campoTitulo = Libro.class.getDeclaredField("titulo");
campoTitulo.setAccessible(true);                    // es private (apartado 10)

String valor = (String) campoTitulo.get(libro);     // LEER
System.out.println(valor);                          // Java Efectivo

campoTitulo.set(libro, "Java Efectivo 3ª ed.");     // ESCRIBIR
System.out.println(libro.getTitulo());              // Java Efectivo 3ª ed.

Para campos primitivos existen accesos tipados que evitan el autoboxing: getInt, getDouble, getBoolean, setInt...

Field campoPaginas = Libro.class.getDeclaredField("paginas");
campoPaginas.setAccessible(true);
int paginas = campoPaginas.getInt(libro);      // sin boxing

Para campos static, se pasa null como instancia:

Field constante = Material.class.getDeclaredField("CATEGORIA_POR_DEFECTO");
String valor = (String) constante.get(null);   // null: no hay instancia

  1. Métodos y constructores: la misma distinción

La simetría es exacta, así que basta con la tabla:

Método Devuelve
getMethods() Métodos públicos, propios y heredados (incluidos los de Object)
getDeclaredMethods() Todos los declarados en esta clase, de cualquier visibilidad
getMethod(nombre, tipos...) Un método público concreto, propio o heredado
getDeclaredMethod(nombre, tipos...) Un método concreto declarado aquí, de cualquier visibilidad
getConstructors() Constructores públicos
getDeclaredConstructors() Todos los constructores

Un detalle que sorprende: getMethods() sobre cualquier clase devuelve al menos nueve métodos heredados de Object (equals, hashCode, toString, getClass, notify, notifyAll y tres wait). Casi nunca es lo que quieres.

package com.nexussoftware.bibliotech;

import java.lang.reflect.*;
import java.util.Arrays;
import java.util.Comparator;

public class InspectorDeMetodos {

    public static void main(String[] args) {

        System.out.println("getMethods():         " + Libro.class.getMethods().length + " métodos");
        System.out.println("getDeclaredMethods(): " + Libro.class.getDeclaredMethods().length + " métodos");
        System.out.println();

        Method[] metodos = Libro.class.getDeclaredMethods();
        Arrays.sort(metodos, Comparator.comparing(Method::getName));   // orden estable

        for (Method m : metodos) {

            // Filtrar los metodos PUENTE y SINTETICOS generados por el compilador (10-01)
            if (m.isBridge() || m.isSynthetic()) {
                System.out.println("  [puente/sintético] " + m.getName() + " -- ignorado");
                continue;
            }

            String parametros = Arrays.stream(m.getParameterTypes())
                    .map(Class::getSimpleName)
                    .reduce((a, b) -> a + ", " + b)
                    .orElse("");

            System.out.printf("  %-10s %s %s(%s)%n",
                    Modifier.toString(m.getModifiers()),
                    m.getReturnType().getSimpleName(),
                    m.getName(),
                    parametros);
        }
    }
}
getMethods():         15 métodos
getDeclaredMethods(): 8 métodos

  public     boolean equals(Object)
  public     String getAutor()
  public     String getId()
  public     String getIsbn()
  public     int getPaginas()
  public     String getTitulo()
  public     int hashCode()
  public     String toString()

El filtro de isBridge() e isSynthetic() no es opcional. Recuerda de 10-01 que el compilador genera métodos puente al implementar interfaces genéricas. Si tu motor recorre métodos y no los filtra, procesará duplicados con parámetros Object y producirá resultados extraños.

Obtener un método concreto requiere dar los tipos de sus parámetros, porque puede haber sobrecargas:

// prestar(String, String)
Method prestar = GestorPrestamos.class.getMethod("prestar", String.class, String.class);

// Un metodo sin parametros
Method listar = GestorPrestamos.class.getMethod("listarActivos");

// Un metodo privado: getDeclaredMethod
Method interno = GestorPrestamos.class.getDeclaredMethod("siguienteReferencia");

Si el método no existe, salta NoSuchMethodExceptionen ejecución, no en compilación. Ese es el precio del que hablaremos en el apartado 17.

Información de parámetros:

for (Parameter p : prestar.getParameters()) {
    System.out.println(p.getType().getSimpleName() + " " + p.getName());
}
String arg0
String arg1

Los nombres reales de los parámetros no están en el .class por defecto: el compilador los descarta. Para conservarlos hay que compilar con -parameters:

javac -parameters com/nexussoftware/bibliotech/servicio/GestorPrestamos.java
String isbn
String empleado

Es una opción que los frameworks necesitan (Spring la usa para inyectar por nombre de parámetro) y que conviene activar en el proyecto. En 11-05 la verás configurada en Maven.

  1. Crear instancias con newInstance

La forma correcta es obtener el Constructor y llamar a newInstance:

// Constructor sin argumentos
Class<?> tipo = Class.forName("com.nexussoftware.bibliotech.dominio.Libro");
Object objeto = tipo.getDeclaredConstructor().newInstance();

// Constructor con argumentos: se dan los TIPOS al buscarlo
// y los VALORES al invocarlo
Constructor<?> ctor = tipo.getDeclaredConstructor(
        String.class, String.class, String.class, int.class, double.class);

Object libro = ctor.newInstance("Java Efectivo", "978-0000000001", "Joshua Bloch", 412, 4.8);

Fíjate en int.class y double.class, no Integer.class ni Double.class. La búsqueda del constructor compara tipos exactos, y un parámetro int se busca con int.class. Es un error frecuentísimo que produce un NoSuchMethodException desconcertante.

Con el token de tipo de 10-01, el resultado viene ya tipado:

Class<Libro> tipo = Libro.class;                       // Class<Libro>, no Class<?>
Constructor<Libro> ctor = tipo.getDeclaredConstructor(String.class, String.class);
Libro libro = ctor.newInstance("Java Efectivo", "978-0000000001");   // sin cast

El método obsoleto que verás en código antiguo:

Object o = tipo.newInstance();     // @Deprecated(since = "9")

Class.newInstance() está obsoleto desde Java 9 por una razón concreta: propaga las excepciones comprobadas del constructor sin declararlas, saltándose el sistema de excepciones comprobadas de 06-01. getDeclaredConstructor().newInstance() las envuelve correctamente en InvocationTargetException.

Un aviso importante sobre encapsulamiento: la reflexión puede invocar constructores privados.

// Un singleton "protegido" por constructor privado
Constructor<Configuracion> ctor = Configuracion.class.getDeclaredConstructor();
ctor.setAccessible(true);
Configuracion segunda = ctor.newInstance();   // ¡una segunda instancia!

El patrón singleton hecho con constructor privado no resiste la reflexión. La única forma que sí resiste es el singleton con enum, porque la JVM prohíbe explícitamente instanciar constantes de enum por reflexión (IllegalArgumentException: Cannot reflectively create enum objects). Es uno de los argumentos de Joshua Bloch para preferir enum, y lo retomarás en 12-02.

  1. Invocar métodos con invoke

Method metodo = Libro.class.getMethod("getTitulo");

// invoke(instancia, argumentos...)
String titulo = (String) metodo.invoke(libro);

La firma es Object invoke(Object obj, Object... args). Tres reglas:

  1. El primer argumento es la instancia sobre la que se invoca. Para un método static, se pasa null.
  2. Los siguientes son los argumentos, en orden. Los primitivos se autoboxean.
  3. Devuelve Object, siempre. Si el método devuelve void, devuelve null. Si devuelve int, devuelve un Integer.
// Metodo con argumentos
Method prestar = GestorPrestamos.class.getMethod("prestar", String.class, String.class);
Object resultado = prestar.invoke(gestor, "978-0000000001", "Marta Ruiz");

// Metodo estatico: instancia null
Method fabrica = Resultado.class.getMethod("exito", Object.class);
Object exito = fabrica.invoke(null, "valor");

// Metodo que devuelve primitivo: llega autoboxeado
Method paginas = Libro.class.getMethod("getPaginas");
int n = (Integer) paginas.invoke(libro);

Un ejemplo completo que demuestra la potencia: un pequeño "inspector genérico" que imprime el estado de cualquier objeto usando sus getters, sin conocer su clase.

package com.nexussoftware.bibliotech.servicio;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Comparator;

public final class InspectorDeEstado {

    private InspectorDeEstado() { }

    /** Imprime el resultado de todos los getters sin argumentos de cualquier objeto. */
    public static void volcar(Object objeto) {

        Class<?> tipo = objeto.getClass();
        System.out.println("Estado de " + tipo.getSimpleName() + ":");

        Method[] metodos = tipo.getMethods();
        Arrays.sort(metodos, Comparator.comparing(Method::getName));

        for (Method m : metodos) {

            boolean esGetter = (m.getName().startsWith("get") || m.getName().startsWith("is"))
                    && m.getParameterCount() == 0
                    && m.getReturnType() != void.class
                    && !m.getName().equals("getClass")          // heredado de Object
                    && !Modifier.isStatic(m.getModifiers())
                    && !m.isSynthetic();

            if (!esGetter) {
                continue;
            }

            try {
                Object valor = m.invoke(objeto);
                System.out.printf("  %-16s = %s%n", nombreDePropiedad(m.getName()), valor);
            } catch (ReflectiveOperationException e) {
                System.out.printf("  %-16s = <error: %s>%n",
                        nombreDePropiedad(m.getName()), e.getCause());
            }
        }
    }

    /** getTitulo -> titulo, isPrestado -> prestado */
    private static String nombreDePropiedad(String nombreMetodo) {
        String sinPrefijo = nombreMetodo.startsWith("is")
                ? nombreMetodo.substring(2)
                : nombreMetodo.substring(3);
        return Character.toLowerCase(sinPrefijo.charAt(0)) + sinPrefijo.substring(1);
    }
}
InspectorDeEstado.volcar(new Libro("Java Efectivo", "978-0000000001", "Joshua Bloch", 412, 4.8));
InspectorDeEstado.volcar(new Prestamo("PR-2026-0041", "978-0000000001", "Marta Ruiz"));
Estado de Libro:
  autor            = Joshua Bloch
  id               = 978-0000000001
  isbn             = 978-0000000001
  paginas          = 412
  prestado         = false
  titulo           = Java Efectivo
  valoracion       = 4.8
Estado de Prestamo:
  diaPrestamo      = 120
  empleado         = Marta Ruiz
  id               = PR-2026-0041
  isbn             = 978-0000000001
  referencia       = PR-2026-0041

Cuarenta líneas que funcionan con cualquier clase que se escriba en el futuro. Ese es el argumento de venta de la reflexión, y también su peligro: nada de esto lo comprueba el compilador.

  1. Excepciones en la reflexión: InvocationTargetException

La reflexión tiene su propia familia de excepciones, todas comprobadas, y una en particular merece atención especial.

Excepción Cuándo salta
ClassNotFoundException Class.forName con un nombre que no existe
NoSuchMethodException getMethod/getDeclaredMethod con nombre o tipos que no encajan
NoSuchFieldException getField/getDeclaredField con un nombre que no existe
IllegalAccessException Acceso a un miembro no accesible sin setAccessible(true)
IllegalArgumentException Argumentos del tipo o número equivocado en invoke
InstantiationException newInstance sobre una clase abstracta o una interfaz
InvocationTargetException El método invocado lanzó una excepción
InaccessibleObjectException setAccessible bloqueado por el sistema de módulos (10-06)

Todas menos las dos últimas heredan de ReflectiveOperationException, lo que permite capturarlas juntas:

try {
    Object o = tipo.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {      // captura las cinco de golpe
    throw new BiblioTechException("No se pudo instanciar " + tipo.getName(), e);
}

InvocationTargetException es la importante. Cuando el método que invocas lanza una excepción, la reflexión no la propaga: la envuelve. Esto retoma directamente el encadenamiento de causas de 06-03.

public class DemostracionInvocationTarget {

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

        GestorPrestamos gestor = new GestorPrestamos(catalogo, prestamos);
        Method devolver = GestorPrestamos.class.getMethod("devolver", String.class);

        try {
            devolver.invoke(gestor, "REFERENCIA-INEXISTENTE");

        } catch (InvocationTargetException e) {
            // e NO es la excepcion real: es el envoltorio
            System.out.println("Envoltorio: " + e.getClass().getSimpleName());

            // getCause() da la excepcion QUE DE VERDAD lanzo el metodo
            Throwable causa = e.getCause();
            System.out.println("Causa real: " + causa.getClass().getSimpleName()
                                              + ": " + causa.getMessage());

            // Lo correcto: relanzar la causa, no el envoltorio
            if (causa instanceof BiblioTechException bte) {
                throw bte;
            }
            throw new BiblioTechException("Fallo al devolver por reflexión", causa);
        }
    }
}
Envoltorio: InvocationTargetException
Causa real: BiblioTechException: No existe el préstamo REFERENCIA-INEXISTENTE

Por qué existe este envoltorio. Sin él sería imposible distinguir "la reflexión falló" de "el método falló". Si invoke propagara una IllegalArgumentException lanzada por el método, no sabrías si el problema es que pasaste mal los argumentos o que el método rechazó los datos.

La regla al escribir un motor: captura InvocationTargetException y relanza getCause(), no el envoltorio. Si no lo haces, tus usuarios ven trazas de pila llenas de sun.reflect.NativeMethodAccessorImpl y no encuentran su propia excepción. Esta es la razón de que las trazas de Spring y JUnit sean tan largas: cada capa de reflexión añade ruido.

  1. Acceder a lo privado: setAccessible(true)

Todos los miembros reflexivos (Field, Method, Constructor) heredan de AccessibleObject, que ofrece:

Field campo = Libro.class.getDeclaredField("titulo");
campo.setAccessible(true);       // desactiva la comprobacion de acceso
String titulo = (String) campo.get(libro);

Sin esa llamada:

Exception in thread "main" java.lang.IllegalAccessException:
    class Ejemplo cannot access a member of class Libro with modifiers "private final"

Qué hace exactamente setAccessible(true): no cambia el modificador del campo ni lo hace público para el resto del programa. Lo que hace es desactivar la comprobación de acceso para ese objeto reflexivo concreto. El campo sigue siendo private para el compilador y para cualquier otro código.

Qué rompe del encapsulamiento. Todo. El encapsulamiento de 03-07 se basa en que nadie de fuera puede tocar el estado interno; la reflexión con setAccessible lo atraviesa por completo:

public class RompiendoElEncapsulamiento {

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

        Libro libro = new Libro("Java Efectivo", "978-0000000001", "Joshua Bloch", 412, 4.8);
        System.out.println("Antes:   " + libro.getPaginas() + " páginas");

        // El campo es private FINAL: ni siquiera la clase puede cambiarlo
        Field paginas = Libro.class.getDeclaredField("paginas");
        paginas.setAccessible(true);
        paginas.setInt(libro, -999);

        System.out.println("Después: " + libro.getPaginas() + " páginas");
    }
}
Antes:   412 páginas
Después: -999 páginas

Un objeto con un estado que sus propios invariantes prohíben. El constructor de Libro valida que las páginas sean positivas; la reflexión se lo salta. Es exactamente el escenario que el encapsulamiento existe para impedir.

Con campos static final de tipo primitivo o String es aún peor: el compilador los inlinea como constantes, así que cambiar el campo por reflexión no cambia el código que ya los usaba. Y desde Java 17, modificar un static final por reflexión lanza directamente IllegalAccessException salvo en casos muy concretos.

Cuándo es legítimo:

  • Un framework de serialización que debe leer los campos de tus entidades (Jackson, Hibernate).
  • Un framework de inyección de dependencias que debe escribir en tus campos (Spring con @Autowired en campos).
  • Un framework de pruebas que debe invocar métodos privados o inyectar dobles.
  • Herramientas de diagnóstico.

Cuándo no lo es: cuando quieres saltarte una API que alguien diseñó a propósito. Si necesitas setAccessible para usar una biblioteca, o la estás usando mal, o la biblioteca tiene un defecto de diseño que hay que reportar.

  1. Lo que rompe y lo que el sistema de módulos ya no permite

Durante veinte años, setAccessible(true) funcionó sobre cualquier cosa, incluidas las clases internas del JDK. Bibliotecas enteras se construyeron accediendo a campos privados de String, ArrayList o sun.misc.Unsafe. Y cuando el equipo de Java quería cambiar esas clases internas, rompía medio ecosistema.

Java 9 introdujo el sistema de módulos (JPMS, que verás a fondo en 10-06) y con él el encapsulamiento fuerte: un módulo declara qué paquetes exporta y, sobre todo, cuáles abre a la reflexión. Lo que no está abierto, no se puede abrir con setAccessible.

public class ReflexionBloqueada {

    public static void main(String[] args) throws Exception {
        Field valor = String.class.getDeclaredField("value");
        valor.setAccessible(true);      // ya no funciona
    }
}
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
    Unable to make field private final byte[] java.lang.String.value accessible:
    module java.base does not "opens java.lang" to unnamed module @0x1b6d3586

InaccessibleObjectException es la excepción que verás cada vez que una biblioteca antigua intente hurgar en las tripas del JDK moderno.

La salida de emergencia es una opción de la JVM:

java --add-opens java.base/java.lang=ALL-UNNAMED -cp . MiPrograma

Que se lee: "abre el paquete java.lang del módulo java.base a todo el código sin módulo".

Y para tu propio código modularizado, la declaración correspondiente en module-info.java:

module com.nexussoftware.bibliotech {
    requires java.logging;
    requires java.net.http;

    exports com.nexussoftware.bibliotech.dominio;      // uso normal en compilacion
    opens com.nexussoftware.bibliotech.dominio;        // ademas, reflexion profunda
}

La diferencia entre exports y opens es exactamente esta: exports permite usar los tipos públicos normalmente; opens permite además que la reflexión acceda a lo privado. Un framework de persistencia necesita lo segundo.

Todo esto se desarrolla en 10-06. Aquí basta con reconocer el mensaje de error y saber que --add-opens existe, porque te lo vas a encontrar.

  1. Leer anotaciones en ejecución

Y llegamos al motivo por el que esta lección viene después de la anterior. Las anotaciones de 10-02 se leen así:

// ¿La tiene?
boolean tiene = campo.isAnnotationPresent(CampoCsv.class);

// Obtenerla (null si no la tiene)
CampoCsv anotacion = campo.getAnnotation(CampoCsv.class);
if (anotacion != null) {
    System.out.println(anotacion.nombre() + " en la posición " + anotacion.orden());
}

// Todas (incluidas las heredadas con @Inherited)
Annotation[] todas = tipo.getAnnotations();

// Solo las declaradas directamente
Annotation[] propias = tipo.getDeclaredAnnotations();

// Repetibles: funciona con 0, 1 o N
Responsable[] responsables = tipo.getAnnotationsByType(Responsable.class);

Estos métodos están disponibles en Class, Field, Method, Constructor, Parameter y RecordComponent, porque todos implementan AnnotatedElement.

Recordatorio que cuesta media tarde a quien lo olvida: todo esto solo funciona si la anotación tiene @Retention(RetentionPolicy.RUNTIME). Con la retención por defecto (CLASS), isAnnotationPresent devuelve false sin explicación.

También se pueden leer las anotaciones de los parámetros, aunque la API es incómoda porque devuelve un array de arrays:

Annotation[][] porParametro = metodo.getParameterAnnotations();
for (int i = 0; i < porParametro.length; i++) {
    System.out.println("Parámetro " + i + ": " + Arrays.toString(porParametro[i]));
}

// Alternativa mas legible desde Java 8
for (Parameter p : metodo.getParameters()) {
    if (p.isAnnotationPresent(NoNulo.class)) {
        System.out.println(p.getName() + " no admite null");
    }
}

  1. BiblioTech: el ExportadorAnotado que consume @CampoCsv

Este es el momento de la lección. Vamos a escribir un exportador que genera la línea CSV de cualquier entidad de BiblioTech —presente o futura— sin conocerla, leyendo sus @CampoCsv.

package com.nexussoftware.bibliotech.persistencia;

import com.nexussoftware.bibliotech.anotaciones.CampoCsv;

import java.lang.reflect.Field;
import java.util.*;
import java.util.logging.Logger;

/**
 * Exportador CSV universal basado en anotaciones.
 *
 * No conoce Libro, ni Prestamo, ni Ficha. Descubre en ejecucion
 * que campos llevan @CampoCsv, los ordena por 'orden' y construye
 * cabecera y linea.
 *
 * Sustituye a ExportadorCatalogoCsv (07-07) y a todos los
 * exportadores por-clase que habria que escribir sin esto.
 */
public class ExportadorAnotado {

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

    private static final char SEPARADOR = ';';
    private static final String ENMASCARADO = "***";

    /**
     * Cache de la introspeccion: getDeclaredFields() es caro y el
     * resultado no cambia nunca para una clase dada (apartado 17).
     */
    private final Map<Class<?>, List<Field>> cache = new HashMap<>();

    /** Si es true, los campos marcados sensible = true se enmascaran. */
    private final boolean enmascararSensibles;

    public ExportadorAnotado(boolean enmascararSensibles) {
        this.enmascararSensibles = enmascararSensibles;
    }

    // ------------------------------------------------------------------
    // API publica
    // ------------------------------------------------------------------

    /** Cabecera CSV de una clase, sin necesidad de tener una instancia. */
    public String cabecera(Class<?> tipo) {
        StringJoiner sj = new StringJoiner(String.valueOf(SEPARADOR));
        for (Field campo : camposExportables(tipo)) {
            CampoCsv meta = campo.getAnnotation(CampoCsv.class);
            // nombre() vacio significa "usa el nombre del campo" (10-02, apartado 10)
            String encabezado = meta.nombre().isEmpty() ? campo.getName() : meta.nombre();
            sj.add(escapar(encabezado));
        }
        return sj.toString();
    }

    /** Linea CSV de una entidad cualquiera. */
    public String linea(Object entidad) {
        Objects.requireNonNull(entidad, "entidad");

        StringJoiner sj = new StringJoiner(String.valueOf(SEPARADOR));

        for (Field campo : camposExportables(entidad.getClass())) {
            CampoCsv meta = campo.getAnnotation(CampoCsv.class);
            sj.add(escapar(valorDe(campo, meta, entidad)));
        }
        return sj.toString();
    }

    /** Exporta una coleccion completa: cabecera + una linea por elemento. */
    public List<String> exportar(Class<?> tipo, Collection<?> entidades) {
        List<String> lineas = new ArrayList<>(entidades.size() + 1);
        lineas.add(cabecera(tipo));
        for (Object entidad : entidades) {
            lineas.add(linea(entidad));
        }
        return lineas;
    }

    // ------------------------------------------------------------------
    // Introspeccion
    // ------------------------------------------------------------------

    /**
     * Campos anotados con @CampoCsv, de la clase y de TODAS sus superclases,
     * ordenados por el elemento 'orden'.
     */
    private List<Field> camposExportables(Class<?> tipo) {

        // computeIfAbsent: la introspeccion se hace UNA vez por clase (05-05)
        return cache.computeIfAbsent(tipo, t -> {

            List<Field> encontrados = new ArrayList<>();

            // Recorrido de la jerarquia: getDeclaredFields NO ve los heredados
            for (Class<?> c = t; c != null && c != Object.class; c = c.getSuperclass()) {
                for (Field campo : c.getDeclaredFields()) {

                    if (campo.isSynthetic()) {
                        continue;            // this$0 de clases internas, etc.
                    }
                    if (!campo.isAnnotationPresent(CampoCsv.class)) {
                        continue;            // sin anotacion: no se exporta
                    }

                    campo.setAccessible(true);   // son private: hay que abrirlos
                    encontrados.add(campo);
                }
            }

            // Orden declarado. getDeclaredFields NO garantiza ningun orden,
            // asi que sin esto la salida seria impredecible entre ejecuciones.
            encontrados.sort(Comparator.comparingInt(
                    f -> f.getAnnotation(CampoCsv.class).orden()));

            LOG.fine(() -> "Introspección de " + t.getSimpleName()
                    + ": " + encontrados.size() + " campos exportables");

            return List.copyOf(encontrados);
        });
    }

    /** Lee el campo y lo formatea segun la anotacion. */
    private String valorDe(Field campo, CampoCsv meta, Object entidad) {
        try {
            Object valor = campo.get(entidad);

            if (valor == null) {
                return "";
            }
            if (enmascararSensibles && meta.sensible()) {
                return ENMASCARADO;
            }
            if (!meta.formato().isEmpty()) {
                return String.format(meta.formato(), valor);
            }
            return String.valueOf(valor);

        } catch (IllegalAccessException e) {
            // Solo puede pasar si setAccessible fallo (modulos, apartado 11)
            throw new IllegalStateException(
                    "No se pudo leer el campo " + campo.getName()
                    + " de " + entidad.getClass().getSimpleName(), e);
        }
    }

    /** Escapado CSV segun RFC 4180 (07-07): comillas si hay separador, comillas o salto. */
    private String escapar(String valor) {
        boolean necesita = valor.indexOf(SEPARADOR) >= 0
                || valor.indexOf('"') >= 0
                || valor.indexOf('\n') >= 0
                || valor.indexOf('\r') >= 0;

        if (!necesita) {
            return valor;
        }
        return '"' + valor.replace("\"", "\"\"") + '"';
    }
}

Y el programa que lo usa:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.persistencia.ExportadorAnotado;

import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;

public class ExportacionUniversal {

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

        ExportadorAnotado exportador = new ExportadorAnotado(true);   // enmascarar sensibles

        // --- Libros ---
        List<Libro> libros = List.of(
                new Libro("978-0000000001", "Java Efectivo",      "Joshua Bloch", 412, 4.85),
                new Libro("978-0000000002", "Patrones de Diseño",  "GoF",          395, 4.60),
                new Libro("978-0000000003", "Refactorización",     "Martin Fowler", 448, 4.72));

        System.out.println("--- catalogo.csv ---");
        exportador.exportar(Libro.class, libros).forEach(System.out::println);

        // --- Prestamos: OTRA clase, MISMO exportador ---
        List<Prestamo> prestamos = List.of(
                new Prestamo("PR-2026-0041", "978-0000000001", "Marta Ruiz",   120),
                new Prestamo("PR-2026-0042", "978-0000000003", "Diego Alonso", 124));

        System.out.println();
        System.out.println("--- prestamos.csv ---");
        exportador.exportar(Prestamo.class, prestamos).forEach(System.out::println);

        // --- Un record: TAMBIEN funciona ---
        List<Ficha> fichas = List.of(
                new Ficha("978-0000000001", "Java Efectivo", false),
                new Ficha("978-0000000002", "Patrones de Diseño", true));

        System.out.println();
        System.out.println("--- fichas.csv ---");
        exportador.exportar(Ficha.class, fichas).forEach(System.out::println);

        // Y escribirlo a disco con NIO.2 y UTF-8 explicito (07-06)
        Files.write(Path.of("catalogo.csv"),
                    exportador.exportar(Libro.class, libros),
                    StandardCharsets.UTF_8);
    }
}
--- catalogo.csv ---
ISBN;Título;Autor;Páginas;Valoración
978-0000000001;Java Efectivo;Joshua Bloch;412;4,85
978-0000000002;Patrones de Diseño;GoF;395;4,60
978-0000000003;Refactorización;Martin Fowler;448;4,72

--- prestamos.csv ---
Referencia;ISBN;Empleado;Día préstamo
PR-2026-0041;978-0000000001;***;120
PR-2026-0042;978-0000000003;***;124

--- fichas.csv ---
ISBN;Título;Disponible
978-0000000001;Java Efectivo;false
978-0000000002;Patrones de Diseño;true

Detente aquí un momento, porque esto es importante.

ExportadorAnotado no tiene ni una sola referencia a Libro, Prestamo o Ficha. No los importa. No sabe que existen. Y exporta los tres correctamente, respetando el orden de las columnas, aplicando el formato %.2f a la valoración y enmascarando el nombre del empleado porque su campo está marcado sensible = true.

Compáralo con la situación de 07-07: un exportador por clase, la cabecera y los valores en dos métodos distintos que se pueden desincronizar, y un campo nuevo que había que recordar añadir en dos sitios. Ahora, añadir una columna a Libro es una línea:

@CampoCsv(nombre = "Editorial", orden = 6)
private final String editorial;

Y aparece automáticamente en la cabecera y en todas las líneas, en la posición correcta.

Esto es exactamente lo que hace Jackson cuando serializa tus objetos a JSON leyendo @JsonProperty, y lo que hace Hibernate cuando construye el INSERT leyendo @Column. La diferencia entre tu exportador de cien líneas y esos frameworks es cantidad de casos cubiertos, no naturaleza. El mecanismo es el mismo y lo acabas de escribir.

Tres decisiones del código que merecen comentario:

La caché no es una optimización prematura. getDeclaredFields() construye un array nuevo en cada llamada (para que nadie pueda modificar el original) y además ordenar cuesta. Exportar diez mil préstamos haría diez mil introspecciones idénticas. Con computeIfAbsent, se hace una por clase. Todos los frameworks reales cachean la introspección; es la primera optimización que aplican.

El orden explícito es obligatorio. La documentación de getDeclaredFields() dice literalmente que no garantiza ningún orden. En la práctica, HotSpot suele devolverlos en orden de declaración, pero eso no está especificado y ha cambiado entre versiones. Un CSV cuyas columnas cambian de orden al actualizar la JVM es un incidente de producción. Por eso @CampoCsv tiene orden.

El recorrido de la jerarquía importa. Si Libro heredara los campos isbn y titulo de Material en lugar de declararlos, getDeclaredFields() sobre Libro no los vería. El bucle for (Class<?> c = t; c != Object.class; c = c.getSuperclass()) es lo que lo resuelve.

  1. Genéricos y reflexión: qué sobrevive al borrado

Retomamos 10-01. Dijimos que el borrado de tipos elimina la información genérica en ejecución, pero con un matiz: las firmas declaradas sí se conservan en los metadatos del .class. La reflexión puede leerlas.

Lo que NO se puede saber:

List<Libro> libros = new ArrayList<>();
List<String> textos = new ArrayList<>();

System.out.println(libros.getClass());                        // class java.util.ArrayList
System.out.println(libros.getClass() == textos.getClass());   // true

Una instancia no sabe con qué tipo se parametrizó. Eso se perdió para siempre.

Lo que se puede saber:

package com.nexussoftware.bibliotech.servicio;

public class CatalogoService {
    private Repositorio<Material> catalogo;          // declaracion generica
    private Map<String, List<Prestamo>> porEmpleado;

    public List<Libro> buscarLibros(Predicate<Material> criterio) { ... }
}
package com.nexussoftware.bibliotech;

import java.lang.reflect.*;
import java.util.Arrays;

public class InspectorDeGenericos {

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

        // --- CAMPOS ---
        for (Field campo : CatalogoService.class.getDeclaredFields()) {

            System.out.println("campo: " + campo.getName());
            System.out.println("  getType():        " + campo.getType().getSimpleName());
            System.out.println("  getGenericType(): " + campo.getGenericType());

            Type generico = campo.getGenericType();
            if (generico instanceof ParameterizedType pt) {
                System.out.println("  tipo crudo:       "
                        + ((Class<?>) pt.getRawType()).getSimpleName());
                System.out.println("  argumentos:       "
                        + Arrays.toString(pt.getActualTypeArguments()));
            }
            System.out.println();
        }

        // --- METODOS ---
        Method buscar = CatalogoService.class.getMethod("buscarLibros", java.util.function.Predicate.class);
        System.out.println("método: " + buscar.getName());
        System.out.println("  getReturnType():         " + buscar.getReturnType().getSimpleName());
        System.out.println("  getGenericReturnType():  " + buscar.getGenericReturnType());
        System.out.println("  getGenericParameterTypes(): "
                + Arrays.toString(buscar.getGenericParameterTypes()));
    }
}
campo: catalogo
  getType():        Repositorio
  getGenericType(): com...Repositorio<com...Material>
  tipo crudo:       Repositorio
  argumentos:       [class com...Material]

campo: porEmpleado
  getType():        Map
  getGenericType(): java.util.Map<java.lang.String, java.util.List<com...Prestamo>>
  tipo crudo:       Map
  argumentos:       [class java.lang.String, java.util.List<com...Prestamo>]

método: buscarLibros
  getReturnType():         List
  getGenericReturnType():  java.util.List<com...Libro>
  getGenericParameterTypes(): [java.util.function.Predicate<com...Material>]
Método Devuelve Ve los genéricos
Field.getType() Class<?> No
Field.getGenericType() Type
Method.getReturnType() Class<?> No
Method.getGenericReturnType() Type
Method.getParameterTypes() Class<?>[] No
Method.getGenericParameterTypes() Type[]

La jerarquía de java.lang.reflect.Type tiene estos implementadores, y conviene reconocerlos:

graph TD
    T["Type (interfaz)"] --> C["Class<br/>String, Libro, int"]
    T --> P["ParameterizedType<br/>List de Libro"]
    T --> G["GenericArrayType<br/>T array"]
    T --> W["WildcardType<br/>? extends Material"]
    T --> V["TypeVariable<br/>el T de Repositorio de T"]

Por qué esto importa de verdad. Gracias a getGenericType(), un framework de persistencia puede ver que el campo es List<Prestamo> y saber que tiene que crear una relación uno-a-muchos con la tabla de préstamos. Y un deserializador puede ver que el parámetro es Map<String, List<Prestamo>> y construir la estructura correcta. Sin esto, Hibernate y Jackson no podrían funcionar.

Es también la explicación de la técnica del super type token: si creas una subclase anónima de una clase genérica, su firma declarada conserva el tipo:

// Clase anonima: la firma de su superclase SI conserva <List<Libro>>
var token = new TokenDeTipo<List<Libro>>() { };
System.out.println(token.tipo());
public abstract class TokenDeTipo<T> {

    public Type tipo() {
        // getGenericSuperclass() de la clase anonima da TokenDeTipo<List<Libro>>
        ParameterizedType superclase = (ParameterizedType) getClass().getGenericSuperclass();
        return superclase.getActualTypeArguments()[0];
    }
}
java.util.List<com.nexussoftware.bibliotech.dominio.Libro>

Eso es, exactamente, el TypeReference de Jackson que verás en 11-07 y que resuelve el problema de que List<Libro>.class no exista.

  1. Proxies dinámicos: Proxy e InvocationHandler

Y ahora el segundo mecanismo grande, el que explica de dónde salen @Transactional, los repositorios de Spring Data y la mitad de la "magia" de los frameworks.

Un proxy dinámico es una clase que la JVM genera en tiempo de ejecución y que implementa las interfaces que le indiques. No la escribes tú: se fabrica en memoria. Cada vez que alguien llama a cualquier método del proxy, la llamada se desvía a un único método tuyo: invoke.

graph LR
    A["Cliente"] -->|"gestor.prestar(...)"| B["PROXY<br/>generado en memoria"]
    B -->|"invoke(proxy, metodo, args)"| C["Tu InvocationHandler"]
    C -->|"antes: auditar, medir, abrir transaccion"| C
    C -->|"metodo.invoke(objetoReal, args)"| D["Objeto REAL<br/>GestorPrestamos"]
    D -->|"resultado"| C
    C -->|"despues: registrar, cerrar transaccion"| C
    C -->|"resultado"| A

Las dos piezas:

// 1. La interfaz que se implementa
public interface ServicioPrestamos {
    Resultado<Prestamo> prestar(String isbn, String empleado);
    Resultado<Prestamo> devolver(String referencia);
    List<Prestamo> listarActivos();
}

// 2. El manejador que intercepta
InvocationHandler manejador = (proxy, metodo, args) -> {
    System.out.println("Interceptado: " + metodo.getName());
    return metodo.invoke(objetoReal, args);
};

// 3. La fabricacion del proxy
ServicioPrestamos proxy = (ServicioPrestamos) Proxy.newProxyInstance(
        ServicioPrestamos.class.getClassLoader(),      // quien carga la clase generada
        new Class<?>[] { ServicioPrestamos.class },    // interfaces a implementar
        manejador);                                    // quien atiende las llamadas

Un ejemplo mínimo y completo, un proxy que mide tiempos:

package com.nexussoftware.bibliotech.servicio;

import java.lang.reflect.*;

public final class ProxyCronometro {

    private ProxyCronometro() { }

    @SuppressWarnings("unchecked")
    public static <T> T envolver(T objeto, Class<T> interfaz) {
        return (T) Proxy.newProxyInstance(
                interfaz.getClassLoader(),
                new Class<?>[] { interfaz },
                (proxy, metodo, args) -> {

                    long inicio = System.nanoTime();
                    try {
                        return metodo.invoke(objeto, args);

                    } catch (InvocationTargetException e) {
                        throw e.getCause();      // relanzar la causa, no el envoltorio
                    } finally {
                        long ms = (System.nanoTime() - inicio) / 1_000_000;
                        System.out.printf("[TIEMPO] %s() -> %d ms%n", metodo.getName(), ms);
                    }
                });
    }
}
ServicioPrestamos real = new GestorPrestamos(catalogo, prestamos);
ServicioPrestamos medido = ProxyCronometro.envolver(real, ServicioPrestamos.class);

medido.prestar("978-0000000001", "Marta Ruiz");
medido.listarActivos();
[TIEMPO] prestar() -> 3 ms
[TIEMPO] listarActivos() -> 0 ms

El cliente no se entera de nada. medido es un ServicioPrestamos como cualquier otro; se puede pasar a cualquier método que espere esa interfaz. Y GestorPrestamos no sabe que está siendo medido.

La limitación fundamental de Proxy: solo funciona con interfaces. No se puede hacer un proxy dinámico de una clase concreta con esta API. Por eso Spring, cuando la clase no implementa ninguna interfaz, recurre a CGLIB o ByteBuddy, que generan una subclase en bytecode. Y por eso @Transactional sobre un método final no funciona: la subclase no puede sobrescribirlo.

Tres reglas al escribir un InvocationHandler:

  1. Relanza siempre e.getCause() al capturar InvocationTargetException. Si no, el cliente ve un envoltorio inútil en lugar de su excepción.
  2. Cuidado con equals, hashCode y toString: también pasan por el manejador. Si delegas equals al objeto real, comparar el proxy con el real dará true en un sentido y false en el otro.
  3. Los métodos default de la interfaz también se interceptan. Si quieres que se ejecute la implementación por defecto, hay que usar InvocationHandler.invokeDefault (Java 16+).

  1. BiblioTech: el proxy que consume @Auditable

Juntamos todo: el proxy dinámico lee la @Auditable de 10-02 y registra las operaciones sin que GestorPrestamos se entere.

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.anotaciones.Auditable;
import com.nexussoftware.bibliotech.dominio.Gravedad;

import java.lang.reflect.*;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Proxy de auditoria dirigido por anotaciones.
 *
 * Intercepta cada llamada, busca @Auditable en el metodo y, si no
 * la encuentra, en la clase (herencia de configuracion, como el
 * @Transactional de Spring). Registra inicio, fin, duracion y fallo.
 *
 * GestorPrestamos NO contiene ni una linea de auditoria.
 */
public final class ProxyAuditoria implements InvocationHandler {

    private static final Logger LOG = Logger.getLogger("bibliotech.auditoria");

    private final Object objetivo;
    private final Class<?> claseObjetivo;

    private ProxyAuditoria(Object objetivo) {
        this.objetivo = objetivo;
        this.claseObjetivo = objetivo.getClass();
    }

    /** Fabrica tipada: devuelve algo que se puede usar como la interfaz original. */
    @SuppressWarnings("unchecked")
    public static <T> T auditar(T objetivo, Class<T> interfaz) {
        if (!interfaz.isInterface()) {
            throw new IllegalArgumentException(
                    interfaz.getSimpleName() + " no es una interfaz; Proxy solo admite interfaces");
        }
        return (T) Proxy.newProxyInstance(
                interfaz.getClassLoader(),
                new Class<?>[] { interfaz },
                new ProxyAuditoria(objetivo));
    }

    @Override
    public Object invoke(Object proxy, Method metodo, Object[] args) throws Throwable {

        Auditable politica = resolverPolitica(metodo);

        if (politica == null) {
            // Sin anotacion: pasar la llamada sin hacer nada mas
            return invocar(metodo, args);
        }

        String operacion = politica.value().isEmpty() ? metodo.getName() : politica.value();
        Level nivel = aNivelDeLog(politica.nivel());

        String descripcionArgs = politica.registrarArgumentos()
                ? describirArgumentos(args, politica.argumentosSensibles())
                : "(no registrados)";

        LOG.log(nivel, () -> String.format("INICIO %s %s", operacion, descripcionArgs));

        long inicio = System.nanoTime();
        try {
            Object resultado = invocar(metodo, args);

            long ms = (System.nanoTime() - inicio) / 1_000_000;
            LOG.log(nivel, () -> String.format("FIN    %s en %d ms -> %s", operacion, ms, resultado));
            return resultado;

        } catch (Throwable fallo) {
            long ms = (System.nanoTime() - inicio) / 1_000_000;
            // Los fallos SIEMPRE se registran como SEVERE, sea cual sea el nivel
            LOG.log(Level.SEVERE, fallo,
                    () -> String.format("ERROR  %s tras %d ms: %s", operacion, ms, fallo));
            throw fallo;
        }
    }

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

    /**
     * Busca la anotacion en el metodo de la CLASE REAL (no en la interfaz,
     * donde podria no estar) y, si no la hay, en la clase.
     */
    private Auditable resolverPolitica(Method metodoDeInterfaz) {
        try {
            Method metodoReal = claseObjetivo.getMethod(
                    metodoDeInterfaz.getName(), metodoDeInterfaz.getParameterTypes());

            Auditable delMetodo = metodoReal.getAnnotation(Auditable.class);
            if (delMetodo != null) {
                return delMetodo;
            }
        } catch (NoSuchMethodException e) {
            // No deberia ocurrir: el proxy implementa la interfaz que la clase implementa
        }
        return claseObjetivo.getAnnotation(Auditable.class);   // valor por defecto de clase
    }

    /** Invoca el metodo real desenvolviendo la excepcion. */
    private Object invocar(Method metodo, Object[] args) throws Throwable {
        try {
            return metodo.invoke(objetivo, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();          // REGLA: relanzar la causa, no el envoltorio
        }
    }

    /** Enmascara los argumentos marcados como sensibles (09-06: nunca registrar credenciales). */
    private String describirArgumentos(Object[] args, int[] sensibles) {
        if (args == null || args.length == 0) {
            return "()";
        }
        Object[] copia = Arrays.copyOf(args, args.length);
        for (int indice : sensibles) {
            if (indice >= 0 && indice < copia.length) {
                copia[indice] = "***";
            }
        }
        return Arrays.toString(copia);
    }

    private Level aNivelDeLog(Gravedad gravedad) {
        return switch (gravedad) {
            case ALTA  -> Level.WARNING;
            case MEDIA -> Level.INFO;
            case BAJA  -> Level.FINE;
        };
    }
}

El programa:

package com.nexussoftware.bibliotech;

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

public class AuditoriaTransparente {

    public static void main(String[] args) {

        ConfiguracionLog.inicializar();     // del modulo 6

        Repositorio<Material> catalogo  = new Repositorio<>("catálogo");
        Repositorio<Prestamo> prestamos = new Repositorio<>("préstamos");
        catalogo.guardar(new Libro("978-0000000001", "Java Efectivo"));
        catalogo.guardar(new Libro("978-0000000002", "Patrones de Diseño"));

        // El objeto REAL, sin nada de auditoria dentro
        ServicioPrestamos real = new GestorPrestamos(catalogo, prestamos);

        // El objeto que usa la aplicacion
        ServicioPrestamos servicio = ProxyAuditoria.auditar(real, ServicioPrestamos.class);

        servicio.prestar("978-0000000001", "Marta Ruiz");
        servicio.prestar("978-0000000001", "Diego Alonso");   // ya prestado: devuelve fallo
        servicio.listarActivos();                             // sin @Auditable: no se registra
        servicio.cambiarClaveEmpleado("Nuria Vidal", "clave-secreta-real");

        try {
            servicio.devolver("PR-INEXISTENTE");
        } catch (Exception e) {
            System.out.println("Excepción recibida por el cliente: "
                    + e.getClass().getSimpleName() + " -- " + e.getMessage());
        }
    }
}
[WARNING] bibliotech.auditoria: INICIO prestar [978-0000000001, Marta Ruiz]
[WARNING] bibliotech.auditoria: FIN    prestar en 4 ms -> Éxito[Prestamo[PR-2026-0001]]
[WARNING] bibliotech.auditoria: INICIO prestar [978-0000000001, Diego Alonso]
[WARNING] bibliotech.auditoria: FIN    prestar en 0 ms -> Fallo[El material 'Java Efectivo' ya está prestado]
[INFO]    bibliotech.auditoria: INICIO cambiarClave [Nuria Vidal, ***]
[INFO]    bibliotech.auditoria: FIN    cambiarClave en 1 ms -> Éxito[null]
[WARNING] bibliotech.auditoria: INICIO devolver [PR-INEXISTENTE]
[SEVERE]  bibliotech.auditoria: ERROR  devolver tras 0 ms: BiblioTechException: No existe el préstamo PR-INEXISTENTE
    com.nexussoftware.bibliotech.BiblioTechException: No existe el préstamo PR-INEXISTENTE
        at com.nexussoftware.bibliotech.servicio.GestorPrestamos.devolver(GestorPrestamos.java:78)
        ...
Excepción recibida por el cliente: BiblioTechException -- No existe el préstamo PR-INEXISTENTE

Cinco observaciones que resumen toda la lección:

GestorPrestamos no contiene ni una línea de auditoría. Las catorce llamadas repetidas a RegistroOperaciones de 06-07 han desaparecido. La lógica de préstamos habla de préstamos.

listarActivos() no aparece en la salida. No lleva @Auditable, y el manejador la deja pasar sin registrar. La decisión de auditar o no está donde debe estar: junto al método.

La clave de Nuria Vidal sale como ***. argumentosSensibles = {1} hizo su trabajo. La regla de 09-06 —nunca registrar credenciales— ha pasado de ser un comentario que alguien debe recordar a ser una declaración que el motor aplica.

El cliente recibe su BiblioTechException, no una InvocationTargetException. El throw e.getCause() del método invocar es lo que lo consigue. Sin esa línea, el catch (BiblioTechException e) del cliente no funcionaría y la aplicación se comportaría de forma inexplicable.

Y esto es, literalmente, cómo funciona @Transactional. Spring crea un proxy de tu servicio, intercepta cada llamada, ve la anotación, abre una transacción, invoca el método real, y confirma o deshace según haya excepción. Cambia LOG.log(...) por transaccion.begin() y transaccion.commit() y tienes el mecanismo completo. También explica sus dos limitaciones famosas: no funciona en métodos privados (el proxy no los ve) ni en llamadas internas de la clase a sí misma (no pasan por el proxy). Cuando lo veas en 11-02, ya sabrás por qué.

  1. El precio de la reflexión

La reflexión no es gratis, y conviene tener las magnitudes claras.

Coste en rendimiento

Estas cifras son ilustrativas y varían enormemente según JVM, versión y calentamiento, pero el orden de magnitud es representativo:

Operación Coste relativo aproximado
Llamada directa a un método 1× (referencia)
Llamada por Method.invoke cacheado y con setAccessible(true) 2× – 5×
Llamada por Method.invoke sin cachear el Method 20× – 100×
Class.forName + getMethod + invoke en cada llamada 100× – 1000×
Acceso directo a un campo
Field.get cacheado 2× – 10×

La conclusión práctica es una sola: lo caro es la búsqueda, no la invocación. getMethod, getDeclaredFields y Class.forName recorren estructuras internas, construyen arrays defensivos nuevos en cada llamada y comprueban accesos. La invocación en sí, con el Method ya en la mano y accesible, la JIT la optimiza bastante bien.

Por eso el patrón universal en frameworks es introspeccionar una vez y cachear, exactamente como hace el ExportadorAnotado con su Map<Class<?>, List<Field>>. Spring construye su metadatos al arrancar; Hibernate mapea las entidades al iniciar la sesión; Jackson cachea sus serializadores por clase.

Y la medición honesta: medir esto con un bucle casero da resultados falsos, por el calentamiento de la JIT y la eliminación de código muerto. Eso lo verás en 10-07 con JMH.

Los otros costes, que suelen ser peores

Pérdida de comprobación en compilación. Es el coste más caro y no aparece en ningún perfil de rendimiento:

// Si alguien renombra getTitulo() a titulo(), esto compila igual
// y falla en ejecucion, quiza meses despues, quiza en produccion.
Method m = tipo.getMethod("getTitulo");

Código frágil ante refactorizaciones. El "Renombrar" del IDE cambia todas las llamadas... excepto las cadenas de texto. Un getDeclaredField("titulo") sobrevive intacto a un renombrado y falla en ejecución. Es el mismo problema del XML del apartado 19 de 10-02, y por eso los frameworks modernos prefieren anotaciones (que sí se renombran con la clase) a nombres en cadenas.

Problemas con la compilación nativa. GraalVM Native Image compila a binario y necesita saber en compilación qué clases se van a usar. La reflexión rompe ese análisis: si el nombre de la clase se construye en ejecución, el compilador no puede saber que hace falta incluirla, y la elimina. Hay que declararla en un reflect-config.json. Esta es una de las grandes razones por las que Spring Boot 3 y Quarkus mueven trabajo de la reflexión en ejecución al procesamiento de anotaciones en compilación.

Seguridad. setAccessible(true) atraviesa el encapsulamiento. Un plugin cargado dinámicamente puede leer campos privados con credenciales.

Trazas de pila ilegibles. Cada capa de reflexión añade líneas de jdk.internal.reflect. Las trazas kilométricas de Spring vienen de aquí.

  1. Cuándo usarla y cuándo no

La regla, en una frase: la reflexión la usan las bibliotecas, no la lógica de negocio.

Si estás escribiendo código que resuelve un problema de Nexus Software —prestar libros, calcular multas, avisar de vencimientos— y aparece un Class.forName, algo va mal. Si estás escribiendo una infraestructura que debe funcionar con clases que aún no existen, la reflexión es la herramienta correcta.

Usa reflexión cuando... No la uses cuando...
Escribes un framework o una biblioteca genérica Escribes lógica de negocio
Debes trabajar con clases desconocidas en compilación Conoces el tipo: usa polimorfismo (03-06)
Implementas serialización, mapeo o inyección Quieres "ahorrarte" un switch o una interfaz
Cargas plugins descubiertos en ejecución Quieres saltarte una API pública ajena
Escribes herramientas de diagnóstico Está en un bucle caliente
Implementas un framework de pruebas Hay una alternativa con comprobación del compilador

Antes de recurrir a la reflexión, comprueba estas alternativas:

  1. Polimorfismo. Si tienes tres tipos y quieres comportamiento distinto, una interfaz con tres implementaciones es mejor que descubrir métodos por reflexión.
  2. ServiceLoader. Para descubrir implementaciones de una interfaz en el classpath, el JDK trae un mecanismo estándar que es más limpio que buscar clases a mano.
  3. Supplier<T> o Function<T, R>. Si solo necesitas "crear un objeto de un tipo que decide quien me llama", un Supplier<T> (10-01) da lo mismo con comprobación de tipos.
  4. Procesador de anotaciones. Si la información se conoce en compilación, generar código cuesta cero en ejecución (10-02, apartado 16).

  1. MethodHandles y VarHandle: la alternativa moderna

Java 7 introdujo java.lang.invoke con una alternativa más rápida a la reflexión clásica, y Java 9 la completó con VarHandle.

Un MethodHandle es una referencia ejecutable y tipada a un método. La diferencia clave con Method.invoke es que la comprobación de acceso se hace una vez, al obtenerlo, y no en cada invocación, y que la JIT puede optimizarlo casi como una llamada directa cuando el handle es una constante static final.

package com.nexussoftware.bibliotech.servicio;

import java.lang.invoke.*;

public class EjemploMethodHandle {

    public static void main(String[] args) throws Throwable {

        MethodHandles.Lookup lookup = MethodHandles.lookup();

        // El TIPO del metodo se declara explicitamente: (retorno, parametros...)
        MethodType tipo = MethodType.methodType(String.class);
        MethodHandle getTitulo = lookup.findVirtual(Libro.class, "getTitulo", tipo);

        Libro libro = new Libro("978-0000000001", "Java Efectivo");

        // invokeExact exige que los tipos coincidan EXACTAMENTE
        String titulo = (String) getTitulo.invokeExact(libro);
        System.out.println(titulo);

        // VarHandle para campos, con semantica de memoria explicita (retoma 08-04)
        VarHandle campoPrestado = MethodHandles
                .privateLookupIn(Libro.class, lookup)
                .findVarHandle(Libro.class, "prestado", boolean.class);

        System.out.println("prestado: " + campoPrestado.get(libro));
        campoPrestado.setVolatile(libro, true);              // escritura volatil
        System.out.println("prestado: " + campoPrestado.get(libro));

        // Y operaciones atomicas sin AtomicBoolean (08-06)
        boolean cambiado = campoPrestado.compareAndSet(libro, true, false);
        System.out.println("compareAndSet: " + cambiado);
    }
}
Java Efectivo
prestado: false
prestado: true
compareAndSet: true
Aspecto Reflexión clásica MethodHandle / VarHandle
Comprobación de acceso En cada invoke (salvo setAccessible) Una vez, al obtener el handle
Optimización de la JIT Limitada Buena, como llamada directa si es static final
Tipado de la invocación Object invoke(Object, Object...) MethodType explícito, invokeExact estricto
Excepciones Comprobadas, con envoltorio Throwable, sin envoltorio
Semántica de memoria No la controla VarHandle: volatile, acquire, release, CAS
Facilidad de uso Mayor Menor: más ceremonial
Introspección (listar miembros) No: solo invocación

Cuándo usar cuál:

  • Para descubrir la estructura (listar campos, leer anotaciones): reflexión clásica, que es la única que lo hace.
  • Para invocar repetidamente algo ya descubierto en un camino crítico: MethodHandle.
  • Para acceso atómico o con semántica de memoria a campos: VarHandle, que sustituye a los antiguos AtomicReferenceFieldUpdater y al sun.misc.Unsafe de 08-06.

Los VarHandle son, de hecho, la base de las clases atómicas modernas del JDK. Y las lambdas de 04-05 se implementan con invokedynamic y MethodHandles por debajo: cuando escribes libro -> libro.getTitulo(), el compilador genera una llamada invokedynamic que en el primer uso construye la implementación con LambdaMetafactory, usando handles. Por eso las lambdas son casi tan rápidas como una llamada directa y no tienen el coste de la reflexión.

Errores Comunes y Consejos

1. Olvidar @Retention(RetentionPolicy.RUNTIME) en la anotación. El error se manifiesta aquí, en la reflexión, aunque su causa esté en 10-02: isAnnotationPresent devuelve false sin ninguna explicación. Es lo primero que hay que comprobar cuando un motor de anotaciones "no ve" nada.

2. Usar getFields() esperando ver los privados. getFields() da solo los públicos (propios y heredados); getDeclaredFields() da todos los de esta clase pero ninguno heredado. Para todos los campos de la jerarquía hay que recorrer las superclases a mano.

3. No llamar a setAccessible(true) antes de tocar un miembro privado. Salta IllegalAccessException con un mensaje que menciona los modificadores.

4. Capturar InvocationTargetException y no relanzar getCause(). El cliente ve un envoltorio inútil en lugar de su propia excepción, y sus catch dejan de funcionar. Es el bug más frecuente al escribir un InvocationHandler.

5. Buscar constructores o métodos con Integer.class donde el parámetro es int. La búsqueda compara tipos exactos. getDeclaredConstructor(int.class) y getDeclaredConstructor(Integer.class) son búsquedas distintas, y la equivocada produce un NoSuchMethodException desconcertante.

6. No cachear la introspección. getDeclaredFields() construye un array nuevo cada vez. Llamarlo dentro de un bucle sobre diez mil entidades multiplica el coste por diez mil sin ninguna necesidad.

7. Confiar en el orden de getDeclaredFields() o getDeclaredMethods(). La especificación dice explícitamente que no hay orden garantizado. Ordena tú, por eso @CampoCsv tiene orden.

8. No filtrar métodos puente y sintéticos. Los genéricos (10-01) generan métodos que tú no escribiste. Sin isBridge() e isSynthetic(), tu motor los procesa como si fueran reales.

9. Usar Class.newInstance(). Obsoleto desde Java 9 porque propaga excepciones comprobadas sin declararlas. Usa getDeclaredConstructor().newInstance().

10. Esperar que un proxy dinámico funcione sobre una clase. java.lang.reflect.Proxy solo implementa interfaces. Si tu servicio no tiene interfaz, este mecanismo no sirve, y de ahí vienen la mitad de las preguntas sobre por qué @Transactional "no hace nada".

Consejo 1: encapsula la reflexión en una capa y no la dejes salir. Que ExportadorAnotado sea el único sitio del proyecto donde aparece getDeclaredFields. El resto de BiblioTech no debe saber que existe la reflexión.

Consejo 2: falla pronto y con mensajes claros. Si tu motor no encuentra un método o una anotación, lanza una excepción con el nombre de la clase, el del miembro y qué esperabas. Los errores de reflexión son opacos por naturaleza; compénsalo con mensajes generosos.

Consejo 3: cachea siempre, desde el primer día. Un Map<Class<?>, X> con computeIfAbsent es diez líneas y elimina el 90 % del coste. Si el motor se usa desde varios hilos, ConcurrentHashMap (08-06).

Consejo 4: prefiere anotaciones a cadenas. @CampoCsv sobre el campo se renombra con él; getDeclaredField("titulo") no. Cada cadena con un nombre de miembro es una refactorización futura que fallará en silencio.

Consejo 5: compila con -parameters. Sin esa opción, los nombres de los parámetros son arg0, arg1, y cualquier motor que quiera usarlos queda inutilizado.

Ejercicios

Ejercicio 1: el validador que consume @Validar

En el ejercicio 1 de 10-02 definiste la anotación @Validar con obligatorio, longitudMinima, longitudMaxima, patron y mensaje. Escribe ahora el motor.

  • Clase ValidadorAnotado con un método Resultado<T> validar(T entidad) (usa el Resultado<T> genérico de 10-01).
  • Debe recorrer todos los campos anotados de la clase y de sus superclases, cachear la introspección, y acumular todos los fallos.
  • Debe respetar cada elemento de la anotación: obligatoriedad, longitudes, patrón (con Pattern precompilado y cacheado) y mensaje personalizado.
  • Los mensajes generados por defecto deben incluir el nombre del campo y el valor recibido.
  • Añade un método describir(Class<?>) que imprima las reglas de una clase sin necesidad de instanciarla.

Pruébalo con tres Libro: uno válido, uno con ISBN mal formado y uno con título vacío y autor demasiado largo.

Ejercicio 2: mini contenedor de inyección de dependencias

Construye un contenedor de dependencias mínimo para BiblioTech, con el que entenderás qué hace Spring al arrancar.

  • Define @Componente (sobre clases, RUNTIME) y @Inyectar (sobre constructores y campos, RUNTIME).
  • Escribe ContenedorSimple con:
    • registrar(Class<?>... clases): registra los tipos disponibles.
    • <T> T obtener(Class<T> tipo): devuelve la instancia, creándola si hace falta.
    • Resolución por constructor: si hay un constructor con @Inyectar, resuelve recursivamente cada parámetro por su tipo.
    • Resolución por campo: tras crear la instancia, inyecta los campos con @Inyectar.
    • Singleton: cada tipo se instancia una sola vez.
    • Detección de dependencias circulares con un mensaje útil que muestre el ciclo.
  • Demuéstralo montando GestorPrestamos con sus dos Repositorio y un CalculadoraMultas, sin escribir ni un new.

Ejercicio 3: proxy de reintentos que consume @Reintentable

En el ejercicio 3 de 10-02 definiste @Reintentable(intentos, esperaInicialMs, factorDeCrecimiento, excepciones, soloIdempotentes). Escribe el proxy que la aplica.

  • ProxyReintentos implementando InvocationHandler.
  • Debe leer la anotación del método real, reintentar solo ante las excepciones declaradas, esperar con crecimiento exponencial y aleatorización (jitter, retoma 09-06) y respetar la interrupción del hilo (08-02).
  • Si se agotan los intentos, debe lanzar la última excepción con las anteriores como excepciones suprimidas (addSuppressed, retoma 06-06).
  • Debe registrar cada intento con su espera.
  • Pruébalo con un ClienteMetadatos falso que falle las dos primeras veces y funcione a la tercera, y con otro que falle siempre.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.anotaciones.Validar;

import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

/**
 * Motor de validacion dirigido por @Validar.
 * No conoce Libro, Prestamo ni ninguna entidad concreta.
 */
public class ValidadorAnotado {

    /** Introspeccion cacheada por clase (ConcurrentHashMap: puede usarse desde varios hilos). */
    private final Map<Class<?>, List<Field>> camposPorClase = new ConcurrentHashMap<>();

    /** Patrones compilados: Pattern.compile es caro y el patron no cambia. */
    private final Map<String, Pattern> patrones = new ConcurrentHashMap<>();

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

    public <T> Resultado<T> validar(T entidad) {
        if (entidad == null) {
            return Resultado.fallo("La entidad es nula");
        }

        List<String> fallos = new ArrayList<>();

        for (Field campo : camposValidables(entidad.getClass())) {
            Validar regla = campo.getAnnotation(Validar.class);
            Object valor = leer(campo, entidad);
            comprobar(campo, regla, valor, fallos);
        }

        if (fallos.isEmpty()) {
            return Resultado.exito(entidad);
        }
        return Resultado.fallo(entidad.getClass().getSimpleName()
                + " no válido: " + String.join("; ", fallos));
    }

    /** Imprime las reglas de una clase sin instanciarla. */
    public void describir(Class<?> tipo) {
        System.out.println("Reglas de " + tipo.getSimpleName() + ":");
        for (Field campo : camposValidables(tipo)) {
            Validar r = campo.getAnnotation(Validar.class);
            System.out.printf("  %-12s obligatorio=%-5s long=[%d..%s] patrón=%s%n",
                    campo.getName(),
                    r.obligatorio(),
                    r.longitudMinima(),
                    r.longitudMaxima() == Integer.MAX_VALUE ? "∞" : r.longitudMaxima(),
                    r.patron().isEmpty() ? "-" : r.patron());
        }
    }

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

    /** Campos anotados de la clase y de todas sus superclases. */
    private List<Field> camposValidables(Class<?> tipo) {
        return camposPorClase.computeIfAbsent(tipo, t -> {
            List<Field> encontrados = new ArrayList<>();
            for (Class<?> c = t; c != null && c != Object.class; c = c.getSuperclass()) {
                for (Field campo : c.getDeclaredFields()) {
                    if (campo.isSynthetic() || !campo.isAnnotationPresent(Validar.class)) {
                        continue;
                    }
                    campo.setAccessible(true);
                    encontrados.add(campo);
                }
            }
            // Orden estable por nombre: getDeclaredFields no lo garantiza
            encontrados.sort(Comparator.comparing(Field::getName));
            return List.copyOf(encontrados);
        });
    }

    private Object leer(Field campo, Object entidad) {
        try {
            return campo.get(entidad);
        } catch (IllegalAccessException e) {
            throw new IllegalStateException("No se pudo leer " + campo.getName(), e);
        }
    }

    private void comprobar(Field campo, Validar regla, Object valor, List<String> fallos) {

        String nombre = campo.getName();

        // 1. Obligatoriedad
        if (valor == null) {
            if (regla.obligatorio()) {
                fallos.add(mensaje(regla, "'" + nombre + "' es obligatorio y está vacío"));
            }
            return;                                  // sin valor no hay mas que comprobar
        }

        // Las demas reglas solo aplican a texto
        if (!(valor instanceof String texto)) {
            return;
        }

        if (regla.obligatorio() && texto.isBlank()) {
            fallos.add(mensaje(regla, "'" + nombre + "' es obligatorio y está en blanco"));
            return;
        }

        // 2. Longitudes
        if (texto.length() < regla.longitudMinima()) {
            fallos.add(mensaje(regla, String.format(
                    "'%s' necesita al menos %d caracteres y tiene %d",
                    nombre, regla.longitudMinima(), texto.length())));
        }
        if (texto.length() > regla.longitudMaxima()) {
            fallos.add(mensaje(regla, String.format(
                    "'%s' admite como máximo %d caracteres y tiene %d",
                    nombre, regla.longitudMaxima(), texto.length())));
        }

        // 3. Patron
        if (!regla.patron().isEmpty()) {
            Pattern patron = patrones.computeIfAbsent(regla.patron(), Pattern::compile);
            if (!patron.matcher(texto).matches()) {
                fallos.add(mensaje(regla, String.format(
                        "'%s' no cumple el formato %s (valor: \"%s\")",
                        nombre, regla.patron(), texto)));
            }
        }
    }

    /** El mensaje personalizado de la anotacion tiene prioridad sobre el generado. */
    private String mensaje(Validar regla, String generado) {
        return regla.mensaje().isEmpty() ? generado : regla.mensaje();
    }
}

Prueba:

package com.nexussoftware.bibliotech;

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

import java.util.List;

public class PruebaValidadorAnotado {

    public static void main(String[] args) {

        ValidadorAnotado validador = new ValidadorAnotado();

        validador.describir(Libro.class);
        System.out.println();

        List<Libro> candidatos = List.of(
                new Libro("978-0000000001", "Java Efectivo",  "Joshua Bloch"),
                new Libro("978-1",          "Refactorización", "Martin Fowler"),
                new Libro("978-0000000003", "",                "A".repeat(150)));

        for (Libro libro : candidatos) {
            Resultado<Libro> r = validador.validar(libro);
            System.out.println(r.esExito()
                    ? "OK      -> " + r.valor().getTitulo()
                    : "RECHAZO -> " + r.error());
        }
    }
}
Reglas de Libro:
  autor        obligatorio=false long=[0..120] patrón=-
  isbn         obligatorio=true  long=[0..∞] patrón=978-\d{10}
  titulo       obligatorio=true  long=[1..200] patrón=-

OK      -> Java Efectivo
RECHAZO -> Libro no válido: El ISBN debe seguir el formato 978-XXXXXXXXXX
RECHAZO -> Libro no válido: 'autor' admite como máximo 120 caracteres y tiene 150; 'titulo' es obligatorio y está en blanco

Comentarios.

Los dos niveles de caché son el patrón real. Uno para la introspección por clase y otro para los Pattern compilados. Pattern.compile construye un autómata: hacerlo en cada validación de cada campo de cada entidad es un desperdicio enorme y una fuente clásica de lentitud.

El mensaje personalizado gana al generado. El ISBN mal formado produce el mensaje que puso el desarrollador en la anotación, no el técnico. Es la diferencia entre un mensaje para el usuario y uno para el log.

ConcurrentHashMap y no HashMap. Este validador es un objeto que se comparte —en Spring sería un @Component singleton— y varios hilos lo llamarán a la vez. Un HashMap sin sincronizar en esa situación puede corromperse (08-06).

Y fíjate en lo que este validador tiene en común con ExportadorAnotado: recorrer la jerarquía, filtrar por anotación, setAccessible, ordenar, cachear. Es el mismo esqueleto. Cuando lo hayas escrito dos veces, reconocerás ese esqueleto dentro de cualquier framework.

Solución 2

package com.nexussoftware.bibliotech.anotaciones;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Componente {
    String nombre() default "";
}
package com.nexussoftware.bibliotech.anotaciones;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.CONSTRUCTOR, ElementType.FIELD})
public @interface Inyectar {
}
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.anotaciones.*;

import java.lang.reflect.*;
import java.util.*;

/**
 * Contenedor de inyeccion de dependencias minimo.
 * Es, en esencia, lo que hace el ApplicationContext de Spring
 * reducido a lo imprescindible.
 */
public class ContenedorSimple {

    /** Tipos registrados como disponibles. */
    private final Set<Class<?>> registrados = new LinkedHashSet<>();

    /** Instancias ya creadas: cada componente es un singleton. */
    private final Map<Class<?>, Object> instancias = new LinkedHashMap<>();

    /** Tipos que se estan creando ahora mismo: sirve para detectar ciclos. */
    private final Deque<Class<?>> enConstruccion = new ArrayDeque<>();

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

    public ContenedorSimple registrar(Class<?>... clases) {
        for (Class<?> clase : clases) {
            if (!clase.isAnnotationPresent(Componente.class)) {
                throw new IllegalArgumentException(
                        clase.getSimpleName() + " no está anotada con @Componente");
            }
            registrados.add(clase);
        }
        return this;
    }

    /** Registra una instancia ya construida (para configuracion, valores, etc.). */
    public <T> ContenedorSimple registrarInstancia(Class<T> tipo, T instancia) {
        instancias.put(tipo, instancia);
        registrados.add(tipo);
        return this;
    }

    @SuppressWarnings("unchecked")
    public <T> T obtener(Class<T> tipo) {
        // Cast seguro: solo guardamos instancias del tipo con el que se indexan
        return (T) resolver(tipo);
    }

    public void listar() {
        System.out.println("Componentes instanciados (" + instancias.size() + "):");
        instancias.forEach((tipo, obj) ->
                System.out.printf("  %-24s -> %s%n", tipo.getSimpleName(), obj));
    }

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

    private Object resolver(Class<?> tipo) {

        // 1. ¿Ya existe? Singleton.
        Object existente = instancias.get(tipo);
        if (existente != null) {
            return existente;
        }

        // 2. Buscar una implementacion registrada compatible
        Class<?> implementacion = buscarImplementacion(tipo);

        // 3. Detectar ciclos ANTES de entrar en recursion
        if (enConstruccion.contains(implementacion)) {
            List<String> ciclo = new ArrayList<>();
            for (Class<?> c : enConstruccion) {
                ciclo.add(0, c.getSimpleName());
            }
            ciclo.add(implementacion.getSimpleName());
            throw new IllegalStateException("Dependencia circular: " + String.join(" -> ", ciclo));
        }

        enConstruccion.push(implementacion);
        try {
            Object instancia = construir(implementacion);
            inyectarCampos(instancia);
            instancias.put(tipo, instancia);
            if (tipo != implementacion) {
                instancias.put(implementacion, instancia);
            }
            return instancia;
        } finally {
            enConstruccion.pop();
        }
    }

    /** Si piden una interfaz, busca la unica implementacion registrada. */
    private Class<?> buscarImplementacion(Class<?> tipo) {
        if (registrados.contains(tipo) && !tipo.isInterface()) {
            return tipo;
        }
        List<Class<?>> candidatos = new ArrayList<>();
        for (Class<?> c : registrados) {
            if (tipo.isAssignableFrom(c) && !c.isInterface()
                    && !Modifier.isAbstract(c.getModifiers())) {
                candidatos.add(c);
            }
        }
        if (candidatos.isEmpty()) {
            throw new IllegalStateException(
                    "No hay ningún componente registrado que satisfaga " + tipo.getSimpleName());
        }
        if (candidatos.size() > 1) {
            throw new IllegalStateException("Hay " + candidatos.size()
                    + " candidatos para " + tipo.getSimpleName() + ": " + candidatos);
        }
        return candidatos.get(0);
    }

    /** Instancia resolviendo recursivamente los parametros del constructor. */
    private Object construir(Class<?> clase) {

        Constructor<?> elegido = elegirConstructor(clase);
        elegido.setAccessible(true);

        Class<?>[] tiposParametro = elegido.getParameterTypes();
        Object[] argumentos = new Object[tiposParametro.length];

        for (int i = 0; i < tiposParametro.length; i++) {
            argumentos[i] = resolver(tiposParametro[i]);      // RECURSION
        }

        try {
            return elegido.newInstance(argumentos);
        } catch (InvocationTargetException e) {
            throw new IllegalStateException(
                    "El constructor de " + clase.getSimpleName() + " lanzó una excepción",
                    e.getCause());                            // la CAUSA, no el envoltorio
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("No se pudo instanciar " + clase.getSimpleName(), e);
        }
    }

    private Constructor<?> elegirConstructor(Class<?> clase) {
        Constructor<?> sinArgumentos = null;

        for (Constructor<?> c : clase.getDeclaredConstructors()) {
            if (c.isAnnotationPresent(Inyectar.class)) {
                return c;                                     // prioridad al anotado
            }
            if (c.getParameterCount() == 0) {
                sinArgumentos = c;
            }
        }
        if (sinArgumentos != null) {
            return sinArgumentos;
        }
        // Convencion: si solo hay uno, se usa (como hace Spring desde 4.3)
        Constructor<?>[] todos = clase.getDeclaredConstructors();
        if (todos.length == 1) {
            return todos[0];
        }
        throw new IllegalStateException(clase.getSimpleName()
                + " tiene varios constructores y ninguno lleva @Inyectar");
    }

    /** Inyeccion por campo, DESPUES de construir. */
    private void inyectarCampos(Object instancia) {
        for (Class<?> c = instancia.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
            for (Field campo : c.getDeclaredFields()) {
                if (!campo.isAnnotationPresent(Inyectar.class)) {
                    continue;
                }
                campo.setAccessible(true);
                try {
                    campo.set(instancia, resolver(campo.getType()));
                } catch (IllegalAccessException e) {
                    throw new IllegalStateException(
                            "No se pudo inyectar el campo " + campo.getName(), e);
                }
            }
        }
    }
}

Los componentes:

@Componente
public class CalculadoraMultas {
    public double calcular(int diasDeRetraso) { return diasDeRetraso * 0.25; }
    @Override public String toString() { return "[email protected]€/día"; }
}

@Componente
public class RepositorioMateriales extends Repositorio<Material> {
    public RepositorioMateriales() { super("catálogo"); }
}

@Componente
public class RepositorioPrestamos extends Repositorio<Prestamo> {
    public RepositorioPrestamos() { super("préstamos"); }
}

@Componente
public class GestorPrestamos implements ServicioPrestamos {

    private final RepositorioMateriales catalogo;
    private final RepositorioPrestamos prestamos;

    @Inyectar                                  // el contenedor usara ESTE constructor
    public GestorPrestamos(RepositorioMateriales catalogo, RepositorioPrestamos prestamos) {
        this.catalogo = catalogo;
        this.prestamos = prestamos;
    }

    @Inyectar                                  // inyeccion por campo
    private CalculadoraMultas calculadora;

    @Override public String toString() {
        return "GestorPrestamos[catálogo=" + catalogo + ", multas=" + calculadora + "]";
    }
}

El arranque:

public class ArranqueConContenedor {

    public static void main(String[] args) {

        ContenedorSimple contenedor = new ContenedorSimple()
                .registrar(GestorPrestamos.class,
                           RepositorioMateriales.class,
                           RepositorioPrestamos.class,
                           CalculadoraMultas.class);

        // NI UN SOLO new: el contenedor resuelve todo el grafo
        ServicioPrestamos servicio = contenedor.obtener(ServicioPrestamos.class);

        System.out.println("Obtenido: " + servicio);
        System.out.println();
        contenedor.listar();

        // Singleton: dos peticiones dan el MISMO objeto
        System.out.println();
        System.out.println("¿Singleton? "
                + (contenedor.obtener(CalculadoraMultas.class)
                   == contenedor.obtener(CalculadoraMultas.class)));
    }
}
Obtenido: GestorPrestamos[catálogo=Repositorio[catálogo, 0 entidades], [email protected]€/día]

Componentes instanciados (5):
  RepositorioMateriales    -> Repositorio[catálogo, 0 entidades]
  RepositorioPrestamos     -> Repositorio[préstamos, 0 entidades]
  CalculadoraMultas        -> [email protected]€/día
  GestorPrestamos          -> GestorPrestamos[...]
  ServicioPrestamos        -> GestorPrestamos[...]

¿Singleton? true

Y la detección de ciclos, con dos componentes que se necesitan mutuamente:

Exception in thread "main" java.lang.IllegalStateException:
    Dependencia circular: ServicioA -> ServicioB -> ServicioA

Comentarios. Este ejercicio es el más revelador de la lección.

Ciento cincuenta líneas y ya tienes inyección de dependencias. Resolución recursiva por constructor, inyección por campo, singletons, resolución de interfaz a implementación y detección de ciclos. Spring hace lo mismo, con muchísimos más casos: ámbitos, ciclos de vida, configuración externa, proxies, perfiles, eventos. Pero el núcleo es este, y ya no es un misterio.

La detección de ciclos usa una pila. enConstruccion guarda qué se está creando ahora; si la recursión vuelve a pedir algo que ya está en la pila, hay ciclo. Sin esa comprobación, el programa entraría en recursión infinita y moriría con StackOverflowError sin decir por qué — que es exactamente lo que hacían los contenedores primitivos.

La inyección por campo va después de construir, y no puede ser de otra manera: no se puede escribir en un campo de un objeto que aún no existe. Esto explica una limitación real de Spring: un campo @Autowired no está disponible dentro del constructor. Es la razón principal por la que se recomienda la inyección por constructor: garantiza que el objeto nace completo y permite campos final.

El throw new IllegalStateException(..., e.getCause()) del constructor es la regla del apartado 9 aplicada. Si el constructor de tu componente lanza una excepción, quieres verla a ella, no una InvocationTargetException.

Solución 3

package com.nexussoftware.bibliotech.red;

import com.nexussoftware.bibliotech.anotaciones.Reintentable;

import java.lang.reflect.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Proxy que aplica la politica de @Reintentable.
 * Sustituye a la logica de reintentos codificada a mano
 * dentro de ClienteHttpResistente (09-06).
 */
public final class ProxyReintentos implements InvocationHandler {

    private static final Logger LOG = Logger.getLogger("bibliotech.reintentos");

    private final Object objetivo;

    private ProxyReintentos(Object objetivo) {
        this.objetivo = objetivo;
    }

    @SuppressWarnings("unchecked")
    public static <T> T conReintentos(T objetivo, Class<T> interfaz) {
        return (T) Proxy.newProxyInstance(
                interfaz.getClassLoader(),
                new Class<?>[] { interfaz },
                new ProxyReintentos(objetivo));
    }

    @Override
    public Object invoke(Object proxy, Method metodo, Object[] args) throws Throwable {

        Reintentable politica = resolverPolitica(metodo);

        if (politica == null || politica.intentos() <= 1) {
            return invocar(metodo, args);          // sin politica: una sola vez
        }

        List<Throwable> fallosPrevios = new ArrayList<>();
        long espera = politica.esperaInicialMs();

        for (int intento = 1; intento <= politica.intentos(); intento++) {
            try {
                Object resultado = invocar(metodo, args);

                if (intento > 1) {
                    int n = intento;
                    LOG.info(() -> String.format("%s() tuvo éxito en el intento %d",
                            metodo.getName(), n));
                }
                return resultado;

            } catch (Throwable fallo) {

                if (!esReintentable(fallo, politica)) {
                    LOG.fine(() -> "No se reintenta " + fallo.getClass().getSimpleName()
                            + ": no está en la política");
                    throw fallo;                    // error permanente: no insistir
                }

                fallosPrevios.add(fallo);

                if (intento == politica.intentos()) {
                    // Ultimo intento: adjuntar los anteriores como suprimidos (06-06)
                    for (int i = 0; i < fallosPrevios.size() - 1; i++) {
                        fallo.addSuppressed(fallosPrevios.get(i));
                    }
                    LOG.log(Level.SEVERE, () -> String.format(
                            "%s() agotó sus %d intentos", metodo.getName(), politica.intentos()));
                    throw fallo;
                }

                // Aleatorizacion (jitter): evita que N clientes reintenten a la vez (09-06)
                long conJitter = espera + ThreadLocalRandom.current().nextLong(espera / 2 + 1);

                int n = intento;
                LOG.warning(() -> String.format("%s() falló en el intento %d (%s); espera %d ms",
                        metodo.getName(), n, fallo.getClass().getSimpleName(), conJitter));

                dormir(conJitter);

                espera = (long) (espera * politica.factorDeCrecimiento());
            }
        }
        throw new IllegalStateException("inalcanzable");
    }

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

    private Reintentable resolverPolitica(Method metodoDeInterfaz) {
        try {
            Method real = objetivo.getClass().getMethod(
                    metodoDeInterfaz.getName(), metodoDeInterfaz.getParameterTypes());
            return real.getAnnotation(Reintentable.class);
        } catch (NoSuchMethodException e) {
            return null;
        }
    }

    private boolean esReintentable(Throwable fallo, Reintentable politica) {
        for (Class<? extends Exception> tipo : politica.excepciones()) {
            if (tipo.isInstance(fallo)) {       // isInstance: el instanceof reflexivo
                return true;
            }
        }
        return false;
    }

    private Object invocar(Method metodo, Object[] args) throws Throwable {
        try {
            return metodo.invoke(objetivo, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        }
    }

    /** Respeta la interrupcion: restaura la bandera y aborta (08-02). */
    private void dormir(long ms) throws InterruptedException {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw e;
        }
    }
}

Prueba con un cliente falso:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.anotaciones.Reintentable;
import com.nexussoftware.bibliotech.red.ProxyReintentos;

import java.io.IOException;
import java.net.SocketTimeoutException;

public class PruebaProxyReintentos {

    interface ServicioMetadatos {
        String consultarPorIsbn(String isbn) throws IOException;
        String siempreFalla(String isbn) throws IOException;
    }

    /** Doble de prueba: falla las dos primeras veces y luego funciona. */
    static class ClienteInestable implements ServicioMetadatos {

        private int llamadas = 0;

        @Override
        @Reintentable(intentos = 4, esperaInicialMs = 100, factorDeCrecimiento = 2.0,
                      excepciones = { SocketTimeoutException.class })
        public String consultarPorIsbn(String isbn) throws IOException {
            llamadas++;
            if (llamadas < 3) {
                throw new SocketTimeoutException("tiempo agotado (llamada " + llamadas + ")");
            }
            return "{\"isbn\":\"" + isbn + "\",\"titulo\":\"Java Efectivo\"}";
        }

        @Override
        @Reintentable(intentos = 3, esperaInicialMs = 50, factorDeCrecimiento = 3.0,
                      excepciones = { IOException.class })
        public String siempreFalla(String isbn) throws IOException {
            throw new IOException("el servicio de metadatos no responde");
        }
    }

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

        ConfiguracionLog.inicializar();

        ServicioMetadatos servicio =
                ProxyReintentos.conReintentos(new ClienteInestable(), ServicioMetadatos.class);

        System.out.println("--- caso 1: se recupera al tercer intento ---");
        System.out.println("Resultado: " + servicio.consultarPorIsbn("978-0000000001"));

        System.out.println();
        System.out.println("--- caso 2: agota los intentos ---");
        try {
            servicio.siempreFalla("978-0000000002");
        } catch (IOException e) {
            System.out.println("Excepción final: " + e.getMessage());
            System.out.println("Fallos suprimidos: " + e.getSuppressed().length);
            for (Throwable s : e.getSuppressed()) {
                System.out.println("  suprimido: " + s.getMessage());
            }
        }
    }
}
--- caso 1: se recupera al tercer intento ---
[WARNING] consultarPorIsbn() falló en el intento 1 (SocketTimeoutException); espera 137 ms
[WARNING] consultarPorIsbn() falló en el intento 2 (SocketTimeoutException); espera 265 ms
[INFO]    consultarPorIsbn() tuvo éxito en el intento 3
Resultado: {"isbn":"978-0000000001","titulo":"Java Efectivo"}

--- caso 2: agota los intentos ---
[WARNING] siempreFalla() falló en el intento 1 (IOException); espera 71 ms
[WARNING] siempreFalla() falló en el intento 2 (IOException); espera 194 ms
[SEVERE]  siempreFalla() agotó sus 3 intentos
Excepción final: el servicio de metadatos no responde
Fallos suprimidos: 2
  suprimido: el servicio de metadatos no responde
  suprimido: el servicio de metadatos no responde

Comentarios.

La política ya no está en el método: está declarada sobre él. consultarPorIsbn no contiene ni un bucle de reintentos, ni un Thread.sleep, ni un contador. Compara con la versión de 09-06, donde esas veinte líneas estaban dentro de cada método de red y había que copiarlas al añadir el siguiente.

Las excepciones suprimidas conservan el historial completo. Con addSuppressed (06-06), quien recibe el fallo final puede ver todos los intentos, no solo el último. En un incidente real, saber que los tres fallos fueron el mismo IOException o que el primero fue un timeout y el segundo un ConnectException cambia el diagnóstico.

tipo.isInstance(fallo) es el instanceof reflexivo, y es la única forma de comprobar contra un Class<?> que viene de una anotación. Un fallo instanceof tipo no compilaría: tipo es una variable, no un tipo.

La interrupción se respeta. Si alguien interrumpe el hilo durante la espera, el proxy restaura la bandera y propaga la InterruptedException en lugar de tragársela y seguir reintentando. Es la regla de 08-02, y en un proxy que duerme es especialmente importante: sin ella, un ExecutorService no podría apagarse mientras alguien reintenta.

Y con esto, BiblioTech tiene sus tres motores. Uno lee anotaciones de campos y exporta; otro lee anotaciones de clases y monta el grafo de objetos; otro lee anotaciones de métodos e intercepta llamadas. Los tres desconocen por completo el dominio de la biblioteca. Eso es un framework.

Conclusión

Has escrito el lector que faltaba, y con él has visto por dentro cómo funcionan los frameworks.

Sabes qué es la reflexión: inspeccionar y manipular clases, campos y métodos en tiempo de ejecución sin conocerlos en compilación. Y sabes cuál es el trato: ganas flexibilidad total y pierdes toda la ayuda del compilador.

Obtienes el objeto Class<?> de tres formas —el literal .class tipado, getClass() que da el tipo real y no el declarado, y Class.forName que acepta una cadena, no requiere la clase en compilación e inicializa la clase—. Lo inspeccionas con getName/getSimpleName/getCanonicalName (y sabes en qué se diferencian con clases anidadas, arrays y anónimas), recorres su jerarquía con getSuperclass y getInterfaces, y lees sus modificadores con la máscara de bits que interpreta Modifier. Lo clasificas con isEnum, isRecord, isInterface, isArray, y extraes sus constantes con getEnumConstants o sus componentes con getRecordComponents.

Dominas la distinción que confunde a todo el mundo: getFields da los públicos, propios y heredados; getDeclaredFields da todos los de esta clase pero ninguno heredado. Una mira hacia arriba y la otra hacia dentro, y ninguna da todo, por lo que hay que recorrer la jerarquía con un bucle sobre getSuperclass(). Lo mismo, exactamente igual, para métodos y constructores. Y filtras los puentes y sintéticos que el borrado de genéricos de 10-01 generó a tus espaldas.

Creas instancias con getDeclaredConstructor(tipos).newInstance(valores) —recordando que un parámetro int se busca con int.class y no con Integer.class, y que Class.newInstance() está obsoleto porque se salta las excepciones comprobadas—. Invocas métodos con invoke(instancia, args...), pasando null para los estáticos y recibiendo siempre Object. Y sabes que las excepciones del método invocado llegan envueltas en InvocationTargetException, con la regla de oro correspondiente: captura y relanza getCause(), nunca el envoltorio, o los catch de tus usuarios dejarán de funcionar.

Accedes a lo privado con setAccessible(true), entiendes que atraviesa por completo el encapsulamiento de 03-07 —hasta el punto de poder poner -999 páginas en un private final que el constructor valida—, y sabes que desde Java 9 el sistema de módulos puede bloquearlo con InaccessibleObjectException, que --add-opens es la salida de emergencia y que opens es distinto de exports. Todo eso se desarrolla en 10-06.

Y sobre todo: escribiste los motores. El ExportadorAnotado recorre los campos con @CampoCsv de cualquier entidad —presente o futura—, los ordena, aplica formatos, enmascara los sensibles y genera cabecera y líneas sin conocer Libro, Prestamo ni Ficha. El ProxyAuditoria, construido sobre Proxy.newProxyInstance e InvocationHandler, intercepta cada llamada a GestorPrestamos, lee su @Auditable —del método, y si no, de la clase—, registra inicio, fin, duración y fallo, y enmascara los argumentos marcados como sensibles, mientras GestorPrestamos no contiene ni una línea de auditoría. Y en los ejercicios has escrito un validador dirigido por anotaciones, un contenedor de inyección de dependencias completo con singletons, resolución recursiva y detección de ciclos, y un proxy de reintentos con espera creciente y excepciones suprimidas.

Eso ya no es aprender reflexión: es haber escrito un framework en miniatura. Cuando en 11-02 veas que Spring instancia tus servicios y los inyecta, sabrás que recorre el classpath, busca @Component, resuelve constructores y llama a newInstance. Cuando @Transactional abra transacciones, sabrás que hay un proxy interceptando — y sabrás también por qué no funciona en métodos privados ni en llamadas internas. Cuando Hibernate mapee tus entidades leyendo @Column, y Jackson serialice tu record leyendo sus componentes, y JUnit ejecute tus @Test, reconocerás el mismo esqueleto que has escrito tres veces: recorrer, filtrar por anotación, setAccessible, ordenar, cachear, invocar.

Sabes también el precio. Lo caro no es invocar, es buscar: getMethod y getDeclaredFields construyen arrays nuevos en cada llamada, y por eso todos los frameworks —y tu exportador— cachean la introspección. Pero los costes peores no son de rendimiento: pierdes la comprobación del compilador, el código se vuelve frágil ante refactorizaciones porque el IDE no renombra las cadenas de texto, y la compilación nativa deja de poder analizar tu programa, que es la razón real por la que Spring Boot 3 y Quarkus están moviendo trabajo de la reflexión hacia el procesamiento de anotaciones en compilación. De ahí la regla: la reflexión la usan las bibliotecas, no la lógica de negocio. Y conoces la alternativa moderna: MethodHandles para invocar rápido lo ya descubierto, y VarHandle para acceso con semántica de memoria y operaciones atómicas — la maquinaria sobre la que se implementan las lambdas y las clases atómicas del propio JDK.

BiblioTech ha cambiado de categoría. Ya no es un programa con clases: es un programa con infraestructura propia. Un exportador universal, un motor de validación, un contenedor de dependencias y dos proxies que añaden auditoría y reintentos sin tocar la lógica. GestorPrestamos habla solo de préstamos; todo lo demás lo declara con etiquetas.

Y sin embargo, el código de dentro sigue igual de verboso. Para saber cuántos libros de más de 400 páginas están prestados y ordenarlos por título, EstadisticasBiblioTech sigue haciendo lo mismo que en el módulo 5: declarar una lista vacía, recorrer con un for, comprobar con un if, acumular, ordenar al final. Para agrupar los préstamos por empleado hay bucles anidados con un HashMap y un computeIfAbsent. Y cuando buscarPorMateria no encuentra nada devuelve null, y hay dieciséis sitios donde alguien tiene que acordarse de comprobarlo — y uno de ellos se olvidará.

En 10-04, Características de Java 8: Streams y Optional, se acaban las dos cosas. Verás la API de Streams, que convierte esos bucles en una descripción declarativa de lo que quieres —filter, map, flatMap, sorted, collect, groupingBy— y hace que el informe por tipo de material y empleado que ocupaba treinta líneas de bucles anidados ocupe tres. Verás la evaluación perezosa, que hace que el orden real de ejecución no sea el que esperas. Verás los streams paralelos y cuándo ayudan de verdad y cuándo perjudican. Y verás Optional, que hace imposible olvidarse de comprobar la ausencia, con sus antipatrones incluidos.

Es la lección que el curso lleva esperando desde el módulo 4, cuando aprendiste las lambdas y te dijimos que su verdadero destino estaba más adelante. Ha llegado.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

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

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

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

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

© Copyright 2026. Todos los derechos reservados