The previous lesson ended with an uncomfortable sentence: BiblioTech has its annotations in place and nothing works. @CsvField marks the exportable fields and exports nothing. @Auditable marks the auditable operations and records nothing. The labels are there, and there is nobody reading them.

This lesson writes the reader.

Reflection is a program's ability to examine and manipulate itself at run time: to discover what classes exist, what fields and methods they have, to read their annotations, create instances, invoke methods and modify fields, all of it without knowing any of that at compile time.

Put another way: until now, to call book.getTitle() you needed the Book class to exist when you compiled. With reflection you can write code that receives any Object, discovers that it has a method called getTitle, invokes it and returns the result — and that code will still work three years from now with classes that do not exist today.

That is exactly why Spring can inject your services, Hibernate can map your entities, JUnit can run your tests and Jackson can convert your objects to JSON. None of those frameworks knows your classes. They discover them at run time, read their annotations and act. By the end of this lesson you will have built, with your own hands, reduced versions of the two mechanisms that hold that building up: an engine that consumes annotations and a dynamic proxy that intercepts calls.

And with that, module 11's frameworks will stop being magic.

Contents

  1. What reflection is and what it allows
  2. The Class<?> object: the three ways of obtaining it
  3. Basic inspection: names, hierarchy and modifiers
  4. isEnum, isRecord, isInterface and company
  5. Fields: getFields versus getDeclaredFields
  6. Methods and constructors: the same distinction
  7. Creating instances with newInstance
  8. Invoking methods with invoke
  9. Exceptions in reflection: InvocationTargetException
  10. Getting at what is private: setAccessible(true)
  11. What it breaks and what the module system no longer allows
  12. Reading annotations at run time
  13. BiblioTech: the AnnotatedExporter that consumes @CsvField
  14. Generics and reflection: what survives erasure
  15. Dynamic proxies: Proxy and InvocationHandler
  16. BiblioTech: the proxy that consumes @Auditable
  17. The price of reflection
  18. When to use it and when not to
  19. MethodHandles and VarHandle: the modern alternative
  20. Common Mistakes and Tips
  21. Exercises

  1. What reflection is and what it allows

The name comes from the idea that the program looks at itself in a mirror. The JVM keeps, for every loaded class, a complete description of its structure: name, superclass, interfaces, fields, methods, constructors, annotations. The reflection API (java.lang.reflect plus java.lang.Class) is the door to that description.

Compare the two worlds:

// NORMAL ACCESS: everything is resolved at COMPILE TIME
Book book = new Book("Effective Java", "978-0000000001");
String title = book.getTitle();

The compiler checks that Book exists, that it has a constructor with that signature and that it has a getTitle() method returning String. If anything fails, it does not compile.

// ACCESS BY REFLECTION: everything is resolved at RUN TIME
Class<?> type = Class.forName("com.nexussoftware.bibliotech.domain.Book");
Object book = type.getDeclaredConstructor(String.class, String.class)
                  .newInstance("Effective Java", "978-0000000001");
Method method = type.getMethod("getTitle");
String title = (String) method.invoke(book);

Here Book is a text string. The compiler checks nothing: it does not know whether that class exists, nor whether it has that constructor, nor whether it has that method. All of it is discovered —or fails— at run time.

That is the deal: you gain total flexibility and lose every bit of help from the compiler. It is worth it when you are writing something that must work with classes you do not know; it is a terrible idea in your application's business logic.

What it allows exactly:

Capability Key method
Discover a class's structure getFields, getMethods, getConstructors
Read annotations getAnnotation, isAnnotationPresent
Create objects without knowing the type getDeclaredConstructor().newInstance()
Invoke methods by name Method.invoke
Read and write fields, even private ones Field.get, Field.set, setAccessible
Generate implementations of interfaces on the fly Proxy.newProxyInstance

  1. The Class<?> object: the three ways of obtaining it

Everything starts at Class<T>. Every class, interface, enum, record, array and primitive loaded into the JVM has exactly one Class object describing it.

// 1. CLASS LITERAL: .class on the type's name
Class<Book> type1 = Book.class;

// 2. FROM AN INSTANCE: getClass()
Book book = new Book("Effective Java", "978-0000000001");
Class<? extends Book> type2 = book.getClass();

// 3. FROM ITS FULL NAME: Class.forName
Class<?> type3 = Class.forName("com.nexussoftware.bibliotech.domain.Book");
Form Syntax Needs the class at compile time Type returned Loads the class Exception
Literal Book.class Yes Class<Book> (typed) Does not initialise it None
Instance object.getClass() No (an Object is enough) Class<? extends X> It is already loaded None
By name Class.forName("...") No Class<?> (untyped) Yes, and it initialises it ClassNotFoundException

Three details that matter:

The literal is typed; forName is not. Book.class is a Class<Book>, and that is why type.cast(x) returns a Book with no manual cast (10-01's type token). Class.forName(...) returns Class<?>, and everything coming out of it will be Object.

getClass() returns the REAL type, not the declared one. This distinction is 03-06's polymorphism seen from reflection:

Material m = new Book("Effective Java", "978-0000000001");
System.out.println(m.getClass().getSimpleName());   // Book, not Material

Class.forName initialises the class. It runs its static blocks and its static field initialisers. It is the mechanism by which the old JDBC drivers registered themselves with Class.forName("com.mysql.jdbc.Driver"). If you do not want that initialisation, there is a three-argument variant:

Class<?> uninitialised = Class.forName(
        "com.nexussoftware.bibliotech.domain.Book",
        false,                                  // do not initialise
        Thread.currentThread().getContextClassLoader());

Special cases worth knowing:

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

That bracket notation is the JVM's internal one for arrays: a [ per dimension, then L<name>; for objects or a letter for primitives (I int, D double, Z boolean, J long...). It turns up in stack traces and in error messages, and now you will recognise it.

  1. Basic inspection: names, hierarchy and modifiers

With the Class in hand, you can ask it anything.

package com.nexussoftware.bibliotech;

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

public class ClassInspector {

    public static void describe(Class<?> type) {

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

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

        // --- HIERARCHY ---
        Class<?> parent = type.getSuperclass();
        System.out.println("superclass:         " + (parent == null ? "none" : parent.getSimpleName()));

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

        // Full inheritance chain up to Object
        StringBuilder chain = new StringBuilder(type.getSimpleName());
        for (Class<?> c = type.getSuperclass(); c != null; c = c.getSuperclass()) {
            chain.append(" -> ").append(c.getSimpleName());
        }
        System.out.println("chain:              " + chain);

        // --- MODIFIERS ---
        int mods = type.getModifiers();
        System.out.println("modifiers:          " + Modifier.toString(mods));
        System.out.println("  is public:        " + Modifier.isPublic(mods));
        System.out.println("  is abstract:      " + Modifier.isAbstract(mods));
        System.out.println("  is final:         " + Modifier.isFinal(mods));
        System.out.println();
    }

    public static void main(String[] args) {
        describe(Book.class);
        describe(Material.class);
        describe(Severity.class);
    }
}
=== Book ===
getName():          com.nexussoftware.bibliotech.domain.Book
getSimpleName():    Book
getCanonicalName(): com.nexussoftware.bibliotech.domain.Book
getPackageName():   com.nexussoftware.bibliotech.domain
superclass:         Material
interfaces:         []
chain:              Book -> Material -> Object
modifiers:          public
  is public:        true
  is abstract:      false
  is final:         false

=== Material ===
getName():          com.nexussoftware.bibliotech.domain.Material
getSimpleName():    Material
getCanonicalName(): com.nexussoftware.bibliotech.domain.Material
getPackageName():   com.nexussoftware.bibliotech.domain
superclass:         Object
interfaces:         [Identifiable]
chain:              Material -> Object
modifiers:          public abstract
  is public:        true
  is abstract:      true
  is final:         false

The three names differ mostly in odd cases, and those odd cases are exactly where people get it wrong:

Type getName() getSimpleName() getCanonicalName()
Book com...Book Book com...Book
Map.Entry (nested) java.util.Map$Entry Entry java.util.Map.Entry
String[] [Ljava.lang.String; String[] java.lang.String[]
Anonymous class Example$1 "" (empty) null
Local class Example$1Local Local null

Practical rule: use getSimpleName() for messages to the user, getName() to identify the class uniquely (it is the one Class.forName accepts), and getCanonicalName() only if you want the name as it would be written in source code.

Modifier is a bit mask. getModifiers() returns an int whose bits encode public, private, static, final, abstract, synchronized, volatile, transient, native. Modifier's static methods interpret them, and Modifier.toString(int) prints them.

  1. isEnum, isRecord, isInterface and company

A group of boolean methods lets you classify the type:

public static String classify(Class<?> type) {
    if (type.isPrimitive())   return "primitive";
    if (type.isArray())       return "array of " + type.getComponentType().getSimpleName();
    if (type.isEnum())        return "enum with " + type.getEnumConstants().length + " constants";
    if (type.isRecord())      return "record with " + type.getRecordComponents().length + " components";
    if (type.isAnnotation())  return "annotation";
    if (type.isInterface())   return "interface";
    if (type.isSealed())      return "sealed class";      // Java 17, see 10-06
    if (Modifier.isAbstract(type.getModifiers())) return "abstract class";
    return "concrete class";
}
System.out.println(Book.class          + " -> " + classify(Book.class));
System.out.println(Material.class      + " -> " + classify(Material.class));
System.out.println(Severity.class      + " -> " + classify(Severity.class));
System.out.println(Card.class          + " -> " + classify(Card.class));
System.out.println(Identifiable.class  + " -> " + classify(Identifiable.class));
System.out.println(int[].class         + " -> " + classify(int[].class));
System.out.println(CsvField.class      + " -> " + classify(CsvField.class));
class ...Book -> concrete class
class ...Material -> abstract class
class ...Severity -> enum with 3 constants
class ...Card -> record with 3 components
interface ...Identifiable -> interface
class [I -> array of int
interface ...CsvField -> annotation

Two methods that are especially useful for BiblioTech:

getEnumConstants() returns an array with all the constants of an enum, in declaration order. It is what lets you build a dropdown menu without knowing the enum:

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

getRecordComponents() (Java 16) gives a record's components with their name, type, annotations and accessor method. It is what Jackson uses to serialise records with no configuration:

for (RecordComponent comp : Card.class.getRecordComponents()) {
    System.out.printf("  %s %s (accessor: %s)%n",
            comp.getType().getSimpleName(), comp.getName(), comp.getAccessor().getName());
}
  String isbn (accessor: isbn)
  String title (accessor: title)
  boolean available (accessor: available)

And there are also relationship checks between types, which are the reflective equivalent of instanceof:

Material m = new Book("Effective Java", "978-0000000001");

// isInstance: equivalent to  (m instanceof Book)
System.out.println(Book.class.isInstance(m));                    // true

// isAssignableFrom: can a Book be assigned to a Material variable?
System.out.println(Material.class.isAssignableFrom(Book.class)); // true
System.out.println(Book.class.isAssignableFrom(Material.class)); // false

The direction of isAssignableFrom is always confusing. Read it like this: Parent.class.isAssignableFrom(Child.class) is true, just as Parent p = new Child() compiles.

  1. Fields: getFields versus getDeclaredFields

Here is the distinction that confuses everybody, and which is worth pinning down once and for all because it repeats identically for methods and constructors.

Method Returns Includes inherited Includes private
getFields() Public fields, own and inherited Yes No
getDeclaredFields() All the fields declared in this class No Yes

That is: one list looks upwards (inheritance) and the other looks inwards (visibility). Neither of them gives you "every field of everything".

graph TD
    A["Fields of a class"] --> B["getFields<br/>public ones<br/>+ inherited public ones"]
    A --> C["getDeclaredFields<br/>everything declared here<br/>private, protected, public, package"]
    B --> D["Does NOT see the private ones"]
    C --> E["Does NOT see the inherited ones"]

With BiblioTech's classes:

package com.nexussoftware.bibliotech;

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

public class FieldComparison {

    public static void main(String[] args) {

        System.out.println("--- getFields() on Book ---");
        for (Field f : Book.class.getFields()) {
            System.out.printf("  %-12s %-10s declared in %s%n",
                    f.getName(), Modifier.toString(f.getModifiers()),
                    f.getDeclaringClass().getSimpleName());
        }

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

Assuming that Material has a public static final String DEFAULT_CATEGORY and private fields, and that Book has its own private fields:

--- getFields() on Book ---
  DEFAULT_CATEGORY public static final declared in Material
--- getDeclaredFields() on Book ---
  isbn         private final        String
  title        private final        String
  author       private final        String
  pages        private final        int
  rating       private final        double
  onLoan       private              boolean

Practical conclusion: since in well-written Java every field is private (encapsulation, 03-07), getFields() almost always returns an empty array or just constants. The method you really use is getDeclaredFields().

And if you need all the fields including the inherited ones, you have to walk the hierarchy by hand:

/**
 * All the fields declared in the class and in all its superclasses,
 * from the most derived to the most general.
 */
public static List<Field> allFields(Class<?> type) {
    List<Field> fields = new ArrayList<>();
    for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
        fields.addAll(Arrays.asList(c.getDeclaredFields()));
    }
    return fields;
}

This loop is a pattern you will see in any framework and that you will use in section 13's AnnotatedExporter.

Reading and writing a field:

Book book = new Book("Effective Java", "978-0000000001", "Joshua Bloch", 412, 4.8);

Field titleField = Book.class.getDeclaredField("title");
titleField.setAccessible(true);                     // it is private (section 10)

String value = (String) titleField.get(book);       // READ
System.out.println(value);                          // Effective Java

titleField.set(book, "Effective Java 3rd ed.");     // WRITE
System.out.println(book.getTitle());                // Effective Java 3rd ed.

For primitive fields there are typed accessors that avoid autoboxing: getInt, getDouble, getBoolean, setInt...

Field pagesField = Book.class.getDeclaredField("pages");
pagesField.setAccessible(true);
int pages = pagesField.getInt(book);           // no boxing

For static fields, you pass null as the instance:

Field constant = Material.class.getDeclaredField("DEFAULT_CATEGORY");
String value = (String) constant.get(null);    // null: there is no instance

  1. Methods and constructors: the same distinction

The symmetry is exact, so the table is enough:

Method Returns
getMethods() Public methods, own and inherited (including Object's)
getDeclaredMethods() All those declared in this class, of any visibility
getMethod(name, types...) One specific public method, own or inherited
getDeclaredMethod(name, types...) One specific method declared here, of any visibility
getConstructors() Public constructors
getDeclaredConstructors() All the constructors

One surprising detail: getMethods() on any class returns at least nine methods inherited from Object (equals, hashCode, toString, getClass, notify, notifyAll and three wait). It is almost never what you want.

package com.nexussoftware.bibliotech;

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

public class MethodInspector {

    public static void main(String[] args) {

        System.out.println("getMethods():         " + Book.class.getMethods().length + " methods");
        System.out.println("getDeclaredMethods(): " + Book.class.getDeclaredMethods().length + " methods");
        System.out.println();

        Method[] methods = Book.class.getDeclaredMethods();
        Arrays.sort(methods, Comparator.comparing(Method::getName));   // stable order

        for (Method m : methods) {

            // Filter out the BRIDGE and SYNTHETIC methods generated by the compiler (10-01)
            if (m.isBridge() || m.isSynthetic()) {
                System.out.println("  [bridge/synthetic] " + m.getName() + " -- ignored");
                continue;
            }

            String parameters = 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(),
                    parameters);
        }
    }
}
getMethods():         15 methods
getDeclaredMethods(): 8 methods

  public     boolean equals(Object)
  public     String getAuthor()
  public     String getId()
  public     String getIsbn()
  public     int getPages()
  public     String getTitle()
  public     int hashCode()
  public     String toString()

The isBridge() and isSynthetic() filter is not optional. Remember from 10-01 that the compiler generates bridge methods when you implement generic interfaces. If your engine walks methods and does not filter them, it will process duplicates with Object parameters and produce strange results.

Getting a specific method requires giving its parameter types, because there may be overloads:

// lend(String, String)
Method lend = LoanManager.class.getMethod("lend", String.class, String.class);

// A method with no parameters
Method list = LoanManager.class.getMethod("listActive");

// A private method: getDeclaredMethod
Method internal = LoanManager.class.getDeclaredMethod("nextReference");

If the method does not exist, NoSuchMethodException fires — at run time, not at compile time. That is the price we will talk about in section 17.

Parameter information:

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

The real parameter names are not in the .class by default: the compiler discards them. To keep them you have to compile with -parameters:

javac -parameters com/nexussoftware/bibliotech/service/LoanManager.java
String isbn
String employee

It is an option frameworks need (Spring uses it to inject by parameter name) and that is worth turning on in the project. In 11-05 you will see it configured in Maven.

  1. Creating instances with newInstance

The correct way is to get the Constructor and call newInstance:

// No-argument constructor
Class<?> type = Class.forName("com.nexussoftware.bibliotech.domain.Book");
Object object = type.getDeclaredConstructor().newInstance();

// Constructor with arguments: you give the TYPES when looking it up
// and the VALUES when invoking it
Constructor<?> ctor = type.getDeclaredConstructor(
        String.class, String.class, String.class, int.class, double.class);

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

Notice int.class and double.class, not Integer.class or Double.class. The constructor lookup compares exact types, and an int parameter is looked up with int.class. It is an extremely frequent mistake that produces a baffling NoSuchMethodException.

With 10-01's type token, the result comes back already typed:

Class<Book> type = Book.class;                         // Class<Book>, not Class<?>
Constructor<Book> ctor = type.getDeclaredConstructor(String.class, String.class);
Book book = ctor.newInstance("Effective Java", "978-0000000001");    // no cast

The deprecated method you will see in old code:

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

Class.newInstance() has been deprecated since Java 9 for a specific reason: it propagates the constructor's checked exceptions without declaring them, bypassing 06-01's checked exception system. getDeclaredConstructor().newInstance() wraps them properly in InvocationTargetException.

An important warning about encapsulation: reflection can invoke private constructors.

// A singleton "protected" by a private constructor
Constructor<Configuration> ctor = Configuration.class.getDeclaredConstructor();
ctor.setAccessible(true);
Configuration second = ctor.newInstance();    // a second instance!

The singleton pattern built with a private constructor does not survive reflection. The only form that does survive is the enum singleton, because the JVM explicitly forbids instantiating enum constants by reflection (IllegalArgumentException: Cannot reflectively create enum objects). It is one of Joshua Bloch's arguments for preferring enum, and you will pick it up again in 12-02.

  1. Invoking methods with invoke

Method method = Book.class.getMethod("getTitle");

// invoke(instance, arguments...)
String title = (String) method.invoke(book);

The signature is Object invoke(Object obj, Object... args). Three rules:

  1. The first argument is the instance on which it is invoked. For a static method, you pass null.
  2. The following ones are the arguments, in order. Primitives are autoboxed.
  3. It returns Object, always. If the method returns void, it returns null. If it returns int, it returns an Integer.
// Method with arguments
Method lend = LoanManager.class.getMethod("lend", String.class, String.class);
Object result = lend.invoke(manager, "978-0000000001", "Marta Ruiz");

// Static method: null instance
Method factory = Result.class.getMethod("success", Object.class);
Object success = factory.invoke(null, "value");

// Method returning a primitive: it arrives autoboxed
Method pages = Book.class.getMethod("getPages");
int n = (Integer) pages.invoke(book);

A complete example that shows the power: a small "generic inspector" that prints the state of any object using its getters, without knowing its class.

package com.nexussoftware.bibliotech.service;

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

public final class StateInspector {

    private StateInspector() { }

    /** Prints the result of every no-argument getter of any object. */
    public static void dump(Object object) {

        Class<?> type = object.getClass();
        System.out.println("State of " + type.getSimpleName() + ":");

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

        for (Method m : methods) {

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

            if (!isGetter) {
                continue;
            }

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

    /** getTitle -> title, isOnLoan -> onLoan */
    private static String propertyName(String methodName) {
        String withoutPrefix = methodName.startsWith("is")
                ? methodName.substring(2)
                : methodName.substring(3);
        return Character.toLowerCase(withoutPrefix.charAt(0)) + withoutPrefix.substring(1);
    }
}
StateInspector.dump(new Book("Effective Java", "978-0000000001", "Joshua Bloch", 412, 4.8));
StateInspector.dump(new Loan("LN-2026-0041", "978-0000000001", "Marta Ruiz"));
State of Book:
  author           = Joshua Bloch
  id               = 978-0000000001
  isbn             = 978-0000000001
  pages            = 412
  rating           = 4.8
  title            = Effective Java
  onLoan           = false
State of Loan:
  employee         = Marta Ruiz
  id               = LN-2026-0041
  isbn             = 978-0000000001
  loanDay          = 120
  reference        = LN-2026-0041

Forty lines that work with any class written in the future. That is reflection's sales pitch, and also its danger: none of this is checked by the compiler.

  1. Exceptions in reflection: InvocationTargetException

Reflection has its own family of exceptions, all checked, and one in particular deserves special attention.

Exception When it fires
ClassNotFoundException Class.forName with a name that does not exist
NoSuchMethodException getMethod/getDeclaredMethod with a name or types that do not match
NoSuchFieldException getField/getDeclaredField with a name that does not exist
IllegalAccessException Access to a member that is not accessible without setAccessible(true)
IllegalArgumentException Arguments of the wrong type or number in invoke
InstantiationException newInstance on an abstract class or an interface
InvocationTargetException The invoked method threw an exception
InaccessibleObjectException setAccessible blocked by the module system (10-06)

All but the last two inherit from ReflectiveOperationException, which lets you catch them together:

try {
    Object o = type.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {      // catches all five at once
    throw new BiblioTechException("Could not instantiate " + type.getName(), e);
}

InvocationTargetException is the important one. When the method you invoke throws an exception, reflection does not propagate it: it wraps it. This picks up directly from 06-03's chaining of causes.

public class InvocationTargetDemo {

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

        LoanManager manager = new LoanManager(catalog, loans);
        Method returnItem = LoanManager.class.getMethod("returnItem", String.class);

        try {
            returnItem.invoke(manager, "REFERENCE-DOES-NOT-EXIST");

        } catch (InvocationTargetException e) {
            // e is NOT the real exception: it is the wrapper
            System.out.println("Wrapper:    " + e.getClass().getSimpleName());

            // getCause() gives the exception the method REALLY threw
            Throwable cause = e.getCause();
            System.out.println("Real cause: " + cause.getClass().getSimpleName()
                                              + ": " + cause.getMessage());

            // The right thing: rethrow the cause, not the wrapper
            if (cause instanceof BiblioTechException bte) {
                throw bte;
            }
            throw new BiblioTechException("Failure returning by reflection", cause);
        }
    }
}
Wrapper:    InvocationTargetException
Real cause: BiblioTechException: There is no loan REFERENCE-DOES-NOT-EXIST

Why this wrapper exists. Without it, it would be impossible to tell "reflection failed" from "the method failed". If invoke propagated an IllegalArgumentException thrown by the method, you would not know whether the problem is that you passed the arguments wrongly or that the method rejected the data.

The rule when writing an engine: catch InvocationTargetException and rethrow getCause(), not the wrapper. If you do not, your users see stack traces full of sun.reflect.NativeMethodAccessorImpl and cannot find their own exception. This is why Spring's and JUnit's traces are so long: every layer of reflection adds noise.

  1. Getting at what is private: setAccessible(true)

Every reflective member (Field, Method, Constructor) inherits from AccessibleObject, which offers:

Field field = Book.class.getDeclaredField("title");
field.setAccessible(true);       // switches off the access check
String title = (String) field.get(book);

Without that call:

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

What setAccessible(true) does exactly: it does not change the field's modifier, nor does it make it public for the rest of the program. What it does is switch off the access check for that one specific reflective object. The field is still private for the compiler and for any other code.

What it breaks about encapsulation. Everything. 03-07's encapsulation rests on nobody from outside being able to touch the internal state; reflection with setAccessible goes straight through it:

public class BreakingEncapsulation {

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

        Book book = new Book("Effective Java", "978-0000000001", "Joshua Bloch", 412, 4.8);
        System.out.println("Before: " + book.getPages() + " pages");

        // The field is private FINAL: not even the class can change it
        Field pages = Book.class.getDeclaredField("pages");
        pages.setAccessible(true);
        pages.setInt(book, -999);

        System.out.println("After:  " + book.getPages() + " pages");
    }
}
Before: 412 pages
After:  -999 pages

An object in a state its own invariants forbid. Book's constructor validates that the pages are positive; reflection skips it. It is exactly the scenario encapsulation exists to prevent.

With static final fields of a primitive or String type it is even worse: the compiler inlines them as constants, so changing the field by reflection does not change the code that was already using them. And since Java 17, modifying a static final by reflection throws IllegalAccessException outright except in very specific cases.

When it is legitimate:

  • A serialisation framework that has to read your entities' fields (Jackson, Hibernate).
  • A dependency injection framework that has to write into your fields (Spring with @Autowired on fields).
  • A testing framework that has to invoke private methods or inject test doubles.
  • Diagnostic tools.

When it is not: when you want to bypass an API somebody designed deliberately. If you need setAccessible to use a library, either you are using it wrongly or the library has a design flaw that should be reported.

  1. What it breaks and what the module system no longer allows

For twenty years, setAccessible(true) worked on anything, including the JDK's internal classes. Entire libraries were built on accessing private fields of String, ArrayList or sun.misc.Unsafe. And when the Java team wanted to change those internal classes, it broke half the ecosystem.

Java 9 introduced the module system (JPMS, which you will see in depth in 10-06) and with it strong encapsulation: a module declares which packages it exports and, above all, which it opens to reflection. What is not open cannot be opened with setAccessible.

public class BlockedReflection {

    public static void main(String[] args) throws Exception {
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);      // no longer works
    }
}
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 is the exception you will see every time an old library tries to poke around in the modern JDK's guts.

The emergency exit is a JVM option:

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

Which reads: "open the java.lang package of the java.base module to all code with no module".

And for your own modularised code, the corresponding declaration in module-info.java:

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

    exports com.nexussoftware.bibliotech.domain;       // normal use at compile time
    opens com.nexussoftware.bibliotech.domain;         // plus deep reflection
}

The difference between exports and opens is exactly this: exports allows the public types to be used normally; opens additionally allows reflection to reach what is private. A persistence framework needs the second.

All of this is developed in 10-06. Here it is enough to recognise the error message and to know that --add-opens exists, because you are going to run into it.

  1. Reading annotations at run time

And we arrive at the reason why this lesson comes after the previous one. 10-02's annotations are read like this:

// Does it have it?
boolean has = field.isAnnotationPresent(CsvField.class);

// Get it (null if it does not have it)
CsvField annotation = field.getAnnotation(CsvField.class);
if (annotation != null) {
    System.out.println(annotation.name() + " at position " + annotation.order());
}

// All of them (including those inherited with @Inherited)
Annotation[] all = type.getAnnotations();

// Only the directly declared ones
Annotation[] own = type.getDeclaredAnnotations();

// Repeatable: works with 0, 1 or N
Owner[] owners = type.getAnnotationsByType(Owner.class);

These methods are available on Class, Field, Method, Constructor, Parameter and RecordComponent, because they all implement AnnotatedElement.

A reminder that costs half an afternoon to whoever forgets it: all of this only works if the annotation has @Retention(RetentionPolicy.RUNTIME). With the default retention (CLASS), isAnnotationPresent returns false with no explanation.

You can also read the annotations of parameters, although the API is awkward because it returns an array of arrays:

Annotation[][] perParameter = method.getParameterAnnotations();
for (int i = 0; i < perParameter.length; i++) {
    System.out.println("Parameter " + i + ": " + Arrays.toString(perParameter[i]));
}

// A more readable alternative since Java 8
for (Parameter p : method.getParameters()) {
    if (p.isAnnotationPresent(NonNull.class)) {
        System.out.println(p.getName() + " does not accept null");
    }
}

  1. BiblioTech: the AnnotatedExporter that consumes @CsvField

This is the lesson's moment. We are going to write an exporter that generates the CSV line of any BiblioTech entity —present or future— without knowing it, by reading its @CsvField.

package com.nexussoftware.bibliotech.persistence;

import com.nexussoftware.bibliotech.annotations.CsvField;

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

/**
 * Universal CSV exporter based on annotations.
 *
 * It does not know Book, nor Loan, nor Card. It discovers at run time
 * which fields carry @CsvField, sorts them by 'order' and builds
 * the header and the line.
 *
 * It replaces CsvCatalogExporter (07-07) and all the
 * per-class exporters that would have to be written without it.
 */
public class AnnotatedExporter {

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

    private static final char SEPARATOR = ';';
    private static final String MASKED = "***";

    /**
     * Cache of the introspection: getDeclaredFields() is expensive and the
     * result never changes for a given class (section 17).
     */
    private final Map<Class<?>, List<Field>> cache = new HashMap<>();

    /** If true, the fields marked sensitive = true are masked. */
    private final boolean maskSensitive;

    public AnnotatedExporter(boolean maskSensitive) {
        this.maskSensitive = maskSensitive;
    }

    // ------------------------------------------------------------------
    // Public API
    // ------------------------------------------------------------------

    /** CSV header of a class, with no need to have an instance. */
    public String header(Class<?> type) {
        StringJoiner sj = new StringJoiner(String.valueOf(SEPARATOR));
        for (Field field : exportableFields(type)) {
            CsvField meta = field.getAnnotation(CsvField.class);
            // an empty name() means "use the field's name" (10-02, section 10)
            String heading = meta.name().isEmpty() ? field.getName() : meta.name();
            sj.add(escape(heading));
        }
        return sj.toString();
    }

    /** CSV line of any entity. */
    public String line(Object entity) {
        Objects.requireNonNull(entity, "entity");

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

        for (Field field : exportableFields(entity.getClass())) {
            CsvField meta = field.getAnnotation(CsvField.class);
            sj.add(escape(valueOf(field, meta, entity)));
        }
        return sj.toString();
    }

    /** Exports a whole collection: header + one line per element. */
    public List<String> export(Class<?> type, Collection<?> entities) {
        List<String> lines = new ArrayList<>(entities.size() + 1);
        lines.add(header(type));
        for (Object entity : entities) {
            lines.add(line(entity));
        }
        return lines;
    }

    // ------------------------------------------------------------------
    // Introspection
    // ------------------------------------------------------------------

    /**
     * Fields annotated with @CsvField, from the class and from ALL its superclasses,
     * sorted by the 'order' element.
     */
    private List<Field> exportableFields(Class<?> type) {

        // computeIfAbsent: the introspection is done ONCE per class (05-05)
        return cache.computeIfAbsent(type, t -> {

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

            // Walking the hierarchy: getDeclaredFields does NOT see the inherited ones
            for (Class<?> c = t; c != null && c != Object.class; c = c.getSuperclass()) {
                for (Field field : c.getDeclaredFields()) {

                    if (field.isSynthetic()) {
                        continue;            // this$0 of inner classes, etc.
                    }
                    if (!field.isAnnotationPresent(CsvField.class)) {
                        continue;            // no annotation: not exported
                    }

                    field.setAccessible(true);   // they are private: they must be opened
                    found.add(field);
                }
            }

            // Declared order. getDeclaredFields does NOT guarantee any order,
            // so without this the output would be unpredictable between runs.
            found.sort(Comparator.comparingInt(
                    f -> f.getAnnotation(CsvField.class).order()));

            LOG.fine(() -> "Introspection of " + t.getSimpleName()
                    + ": " + found.size() + " exportable fields");

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

    /** Reads the field and formats it according to the annotation. */
    private String valueOf(Field field, CsvField meta, Object entity) {
        try {
            Object value = field.get(entity);

            if (value == null) {
                return "";
            }
            if (maskSensitive && meta.sensitive()) {
                return MASKED;
            }
            if (!meta.format().isEmpty()) {
                return String.format(meta.format(), value);
            }
            return String.valueOf(value);

        } catch (IllegalAccessException e) {
            // It can only happen if setAccessible failed (modules, section 11)
            throw new IllegalStateException(
                    "Could not read the field " + field.getName()
                    + " of " + entity.getClass().getSimpleName(), e);
        }
    }

    /** CSV escaping per RFC 4180 (07-07): quotes if there is a separator, a quote or a line break. */
    private String escape(String value) {
        boolean needed = value.indexOf(SEPARATOR) >= 0
                || value.indexOf('"') >= 0
                || value.indexOf('\n') >= 0
                || value.indexOf('\r') >= 0;

        if (!needed) {
            return value;
        }
        return '"' + value.replace("\"", "\"\"") + '"';
    }
}

And the program that uses it:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.persistence.AnnotatedExporter;

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

public class UniversalExport {

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

        AnnotatedExporter exporter = new AnnotatedExporter(true);     // mask sensitive ones

        // --- Books ---
        List<Book> books = List.of(
                new Book("978-0000000001", "Effective Java",  "Joshua Bloch",  412, 4.85),
                new Book("978-0000000002", "Design Patterns", "GoF",           395, 4.60),
                new Book("978-0000000003", "Refactoring",     "Martin Fowler", 448, 4.72));

        System.out.println("--- catalog.csv ---");
        exporter.export(Book.class, books).forEach(System.out::println);

        // --- Loans: ANOTHER class, SAME exporter ---
        List<Loan> loans = List.of(
                new Loan("LN-2026-0041", "978-0000000001", "Marta Ruiz",   120),
                new Loan("LN-2026-0042", "978-0000000003", "Diego Alonso", 124));

        System.out.println();
        System.out.println("--- loans.csv ---");
        exporter.export(Loan.class, loans).forEach(System.out::println);

        // --- A record: it ALSO works ---
        List<Card> cards = List.of(
                new Card("978-0000000001", "Effective Java", false),
                new Card("978-0000000002", "Design Patterns", true));

        System.out.println();
        System.out.println("--- cards.csv ---");
        exporter.export(Card.class, cards).forEach(System.out::println);

        // And writing it to disk with NIO.2 and explicit UTF-8 (07-06)
        Files.write(Path.of("catalog.csv"),
                    exporter.export(Book.class, books),
                    StandardCharsets.UTF_8);
    }
}
--- catalog.csv ---
ISBN;Title;Author;Pages;Rating
978-0000000001;Effective Java;Joshua Bloch;412;4.85
978-0000000002;Design Patterns;GoF;395;4.60
978-0000000003;Refactoring;Martin Fowler;448;4.72

--- loans.csv ---
Reference;ISBN;Employee;Loan day
LN-2026-0041;978-0000000001;***;120
LN-2026-0042;978-0000000003;***;124

--- cards.csv ---
ISBN;Title;Available
978-0000000001;Effective Java;false
978-0000000002;Design Patterns;true

Stop here for a moment, because this is important.

AnnotatedExporter has not a single reference to Book, Loan or Card. It does not import them. It does not know they exist. And it exports all three correctly, respecting the column order, applying the %.2f format to the rating and masking the employee's name because its field is marked sensitive = true.

Compare it with the situation in 07-07: one exporter per class, the header and the values in two different methods that can drift apart, and a new field that had to be remembered in two places. Now, adding a column to Book is one line:

@CsvField(name = "Publisher", order = 6)
private final String publisher;

And it appears automatically in the header and in every line, in the right position.

This is exactly what Jackson does when it serialises your objects to JSON by reading @JsonProperty, and what Hibernate does when it builds the INSERT by reading @Column. The difference between your hundred-line exporter and those frameworks is the number of cases covered, not the nature. The mechanism is the same and you have just written it.

Three decisions in the code deserve comment:

The cache is not premature optimisation. getDeclaredFields() builds a fresh array on every call (so that nobody can modify the original) and sorting costs as well. Exporting ten thousand loans would mean ten thousand identical introspections. With computeIfAbsent, it is done once per class. Every real framework caches its introspection; it is the first optimisation they apply.

The explicit ordering is mandatory. getDeclaredFields()'s documentation says literally that it guarantees no order. In practice, HotSpot usually returns them in declaration order, but that is not specified and has changed between versions. A CSV whose columns change order when you upgrade the JVM is a production incident. That is why @CsvField has order.

Walking the hierarchy matters. If Book inherited the isbn and title fields from Material instead of declaring them, getDeclaredFields() on Book would not see them. The loop for (Class<?> c = t; c != Object.class; c = c.getSuperclass()) is what solves it.

  1. Generics and reflection: what survives erasure

Picking up 10-01. We said that type erasure removes the generic information at run time, with a nuance: the declared signatures are kept in the .class metadata. Reflection can read them.

What you CANNOT know:

List<Book> books = new ArrayList<>();
List<String> texts = new ArrayList<>();

System.out.println(books.getClass());                         // class java.util.ArrayList
System.out.println(books.getClass() == texts.getClass());     // true

An instance does not know what type it was parameterised with. That was lost forever.

What you CAN know:

package com.nexussoftware.bibliotech.service;

public class CatalogService {
    private Repository<Material> catalog;            // generic declaration
    private Map<String, List<Loan>> byEmployee;

    public List<Book> findBooks(Predicate<Material> criterion) { ... }
}
package com.nexussoftware.bibliotech;

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

public class GenericsInspector {

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

        // --- FIELDS ---
        for (Field field : CatalogService.class.getDeclaredFields()) {

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

            Type generic = field.getGenericType();
            if (generic instanceof ParameterizedType pt) {
                System.out.println("  raw type:         "
                        + ((Class<?>) pt.getRawType()).getSimpleName());
                System.out.println("  arguments:        "
                        + Arrays.toString(pt.getActualTypeArguments()));
            }
            System.out.println();
        }

        // --- METHODS ---
        Method find = CatalogService.class.getMethod("findBooks", java.util.function.Predicate.class);
        System.out.println("method: " + find.getName());
        System.out.println("  getReturnType():         " + find.getReturnType().getSimpleName());
        System.out.println("  getGenericReturnType():  " + find.getGenericReturnType());
        System.out.println("  getGenericParameterTypes(): "
                + Arrays.toString(find.getGenericParameterTypes()));
    }
}
field: catalog
  getType():        Repository
  getGenericType(): com...Repository<com...Material>
  raw type:         Repository
  arguments:        [class com...Material]

field: byEmployee
  getType():        Map
  getGenericType(): java.util.Map<java.lang.String, java.util.List<com...Loan>>
  raw type:         Map
  arguments:        [class java.lang.String, java.util.List<com...Loan>]

method: findBooks
  getReturnType():         List
  getGenericReturnType():  java.util.List<com...Book>
  getGenericParameterTypes(): [java.util.function.Predicate<com...Material>]
Method Returns Sees the generics
Field.getType() Class<?> No
Field.getGenericType() Type Yes
Method.getReturnType() Class<?> No
Method.getGenericReturnType() Type Yes
Method.getParameterTypes() Class<?>[] No
Method.getGenericParameterTypes() Type[] Yes

The java.lang.reflect.Type hierarchy has these implementers, and they are worth recognising:

graph TD
    T["Type (interface)"] --> C["Class<br/>String, Book, int"]
    T --> P["ParameterizedType<br/>List of Book"]
    T --> G["GenericArrayType<br/>T array"]
    T --> W["WildcardType<br/>? extends Material"]
    T --> V["TypeVariable<br/>the T of Repository of T"]

Why this really matters. Thanks to getGenericType(), a persistence framework can see that the field is List<Loan> and know that it has to create a one-to-many relationship with the loans table. And a deserialiser can see that the parameter is Map<String, List<Loan>> and build the right structure. Without this, Hibernate and Jackson could not work.

It is also the explanation of the super type token technique: if you create an anonymous subclass of a generic class, its declared signature keeps the type:

// Anonymous class: its superclass's signature DOES keep <List<Book>>
var token = new TypeToken<List<Book>>() { };
System.out.println(token.type());
public abstract class TypeToken<T> {

    public Type type() {
        // getGenericSuperclass() of the anonymous class gives TypeToken<List<Book>>
        ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
        return superclass.getActualTypeArguments()[0];
    }
}
java.util.List<com.nexussoftware.bibliotech.domain.Book>

That is, exactly, Jackson's TypeReference that you will see in 11-07 and that solves the problem of List<Book>.class not existing.

  1. Dynamic proxies: Proxy and InvocationHandler

And now the second big mechanism, the one that explains where @Transactional, Spring Data's repositories and half the frameworks' "magic" come from.

A dynamic proxy is a class that the JVM generates at run time and that implements the interfaces you specify. You do not write it: it is manufactured in memory. Every time somebody calls any method of the proxy, the call is diverted to a single method of yours: invoke.

graph LR
    A["Client"] -->|"manager.lend(...)"| B["PROXY<br/>generated in memory"]
    B -->|"invoke(proxy, method, args)"| C["Your InvocationHandler"]
    C -->|"before: audit, measure, open transaction"| C
    C -->|"method.invoke(realObject, args)"| D["REAL object<br/>LoanManager"]
    D -->|"result"| C
    C -->|"after: record, close transaction"| C
    C -->|"result"| A

The two pieces:

// 1. The interface being implemented
public interface LoanService {
    Result<Loan> lend(String isbn, String employee);
    Result<Loan> returnItem(String reference);
    List<Loan> listActive();
}

// 2. The handler that intercepts
InvocationHandler handler = (proxy, method, args) -> {
    System.out.println("Intercepted: " + method.getName());
    return method.invoke(realObject, args);
};

// 3. The manufacturing of the proxy
LoanService proxy = (LoanService) Proxy.newProxyInstance(
        LoanService.class.getClassLoader(),            // who loads the generated class
        new Class<?>[] { LoanService.class },          // interfaces to implement
        handler);                                      // who serves the calls

A minimal and complete example, a proxy that measures times:

package com.nexussoftware.bibliotech.service;

import java.lang.reflect.*;

public final class TimingProxy {

    private TimingProxy() { }

    @SuppressWarnings("unchecked")
    public static <T> T wrap(T object, Class<T> iface) {
        return (T) Proxy.newProxyInstance(
                iface.getClassLoader(),
                new Class<?>[] { iface },
                (proxy, method, args) -> {

                    long start = System.nanoTime();
                    try {
                        return method.invoke(object, args);

                    } catch (InvocationTargetException e) {
                        throw e.getCause();      // rethrow the cause, not the wrapper
                    } finally {
                        long ms = (System.nanoTime() - start) / 1_000_000;
                        System.out.printf("[TIMING] %s() -> %d ms%n", method.getName(), ms);
                    }
                });
    }
}
LoanService real = new LoanManager(catalog, loans);
LoanService measured = TimingProxy.wrap(real, LoanService.class);

measured.lend("978-0000000001", "Marta Ruiz");
measured.listActive();
[TIMING] lend() -> 3 ms
[TIMING] listActive() -> 0 ms

The client notices nothing. measured is a LoanService like any other; it can be passed to any method expecting that interface. And LoanManager does not know it is being measured.

Proxy's fundamental limitation: it only works with interfaces. You cannot make a dynamic proxy of a concrete class with this API. That is why Spring, when the class implements no interface, falls back to CGLIB or ByteBuddy, which generate a subclass in bytecode. And that is why @Transactional on a final method does not work: the subclass cannot override it.

Three rules when writing an InvocationHandler:

  1. Always rethrow e.getCause() when catching InvocationTargetException. Otherwise the client sees a useless wrapper instead of their exception.
  2. Careful with equals, hashCode and toString: they also go through the handler. If you delegate equals to the real object, comparing the proxy with the real one will give true one way and false the other.
  3. The interface's default methods are intercepted too. If you want the default implementation to run, you have to use InvocationHandler.invokeDefault (Java 16+).

  1. BiblioTech: the proxy that consumes @Auditable

We put it all together: the dynamic proxy reads 10-02's @Auditable and records the operations without LoanManager noticing.

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.annotations.Auditable;
import com.nexussoftware.bibliotech.domain.Severity;

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

/**
 * Annotation-driven auditing proxy.
 *
 * It intercepts every call, looks for @Auditable on the method and, if it
 * does not find it, on the class (configuration inheritance, like Spring's
 * @Transactional). It records start, end, duration and failure.
 *
 * LoanManager contains not one line of auditing.
 */
public final class AuditProxy implements InvocationHandler {

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

    private final Object target;
    private final Class<?> targetClass;

    private AuditProxy(Object target) {
        this.target = target;
        this.targetClass = target.getClass();
    }

    /** Typed factory: returns something usable as the original interface. */
    @SuppressWarnings("unchecked")
    public static <T> T audit(T target, Class<T> iface) {
        if (!iface.isInterface()) {
            throw new IllegalArgumentException(
                    iface.getSimpleName() + " is not an interface; Proxy only accepts interfaces");
        }
        return (T) Proxy.newProxyInstance(
                iface.getClassLoader(),
                new Class<?>[] { iface },
                new AuditProxy(target));
    }

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

        Auditable policy = resolvePolicy(method);

        if (policy == null) {
            // No annotation: pass the call through without doing anything else
            return callTarget(method, args);
        }

        String operation = policy.value().isEmpty() ? method.getName() : policy.value();
        Level level = toLogLevel(policy.level());

        String argsDescription = policy.logArguments()
                ? describeArguments(args, policy.sensitiveArguments())
                : "(not recorded)";

        LOG.log(level, () -> String.format("START  %s %s", operation, argsDescription));

        long start = System.nanoTime();
        try {
            Object result = callTarget(method, args);

            long ms = (System.nanoTime() - start) / 1_000_000;
            LOG.log(level, () -> String.format("END    %s in %d ms -> %s", operation, ms, result));
            return result;

        } catch (Throwable failure) {
            long ms = (System.nanoTime() - start) / 1_000_000;
            // Failures are ALWAYS recorded as SEVERE, whatever the level
            LOG.log(Level.SEVERE, failure,
                    () -> String.format("ERROR  %s after %d ms: %s", operation, ms, failure));
            throw failure;
        }
    }

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

    /**
     * Looks for the annotation on the REAL CLASS's method (not on the interface,
     * where it might not be) and, failing that, on the class.
     */
    private Auditable resolvePolicy(Method interfaceMethod) {
        try {
            Method realMethod = targetClass.getMethod(
                    interfaceMethod.getName(), interfaceMethod.getParameterTypes());

            Auditable onMethod = realMethod.getAnnotation(Auditable.class);
            if (onMethod != null) {
                return onMethod;
            }
        } catch (NoSuchMethodException e) {
            // Should not happen: the proxy implements the interface the class implements
        }
        return targetClass.getAnnotation(Auditable.class);     // class-level default
    }

    /** Invokes the real method unwrapping the exception. */
    private Object callTarget(Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();          // RULE: rethrow the cause, not the wrapper
        }
    }

    /** Masks the arguments marked as sensitive (09-06: never log credentials). */
    private String describeArguments(Object[] args, int[] sensitive) {
        if (args == null || args.length == 0) {
            return "()";
        }
        Object[] copy = Arrays.copyOf(args, args.length);
        for (int index : sensitive) {
            if (index >= 0 && index < copy.length) {
                copy[index] = "***";
            }
        }
        return Arrays.toString(copy);
    }

    private Level toLogLevel(Severity severity) {
        return switch (severity) {
            case HIGH   -> Level.WARNING;
            case MEDIUM -> Level.INFO;
            case LOW    -> Level.FINE;
        };
    }
}

The program:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.service.*;

public class TransparentAuditing {

    public static void main(String[] args) {

        LogConfiguration.initialise();      // from module 6

        Repository<Material> catalog = new Repository<>("catalogue");
        Repository<Loan> loans       = new Repository<>("loans");
        catalog.save(new Book("978-0000000001", "Effective Java"));
        catalog.save(new Book("978-0000000002", "Design Patterns"));

        // The REAL object, with no auditing inside
        LoanService real = new LoanManager(catalog, loans);

        // The object the application uses
        LoanService service = AuditProxy.audit(real, LoanService.class);

        service.lend("978-0000000001", "Marta Ruiz");
        service.lend("978-0000000001", "Diego Alonso");    // already on loan: returns failure
        service.listActive();                              // no @Auditable: not recorded
        service.changeEmployeePassword("Nuria Vidal", "real-secret-password");

        try {
            service.returnItem("LN-NONEXISTENT");
        } catch (Exception e) {
            System.out.println("Exception received by the client: "
                    + e.getClass().getSimpleName() + " -- " + e.getMessage());
        }
    }
}
[WARNING] bibliotech.audit: START  lend [978-0000000001, Marta Ruiz]
[WARNING] bibliotech.audit: END    lend in 4 ms -> Success[Loan[LN-2026-0001]]
[WARNING] bibliotech.audit: START  lend [978-0000000001, Diego Alonso]
[WARNING] bibliotech.audit: END    lend in 0 ms -> Failure[The material 'Effective Java' is already on loan]
[INFO]    bibliotech.audit: START  changePassword [Nuria Vidal, ***]
[INFO]    bibliotech.audit: END    changePassword in 1 ms -> Success[null]
[WARNING] bibliotech.audit: START  return [LN-NONEXISTENT]
[SEVERE]  bibliotech.audit: ERROR  return after 0 ms: BiblioTechException: There is no loan LN-NONEXISTENT
    com.nexussoftware.bibliotech.BiblioTechException: There is no loan LN-NONEXISTENT
        at com.nexussoftware.bibliotech.service.LoanManager.returnItem(LoanManager.java:78)
        ...
Exception received by the client: BiblioTechException -- There is no loan LN-NONEXISTENT

Five observations that sum up the whole lesson:

LoanManager contains not one line of auditing. The fourteen repeated calls to OperationLog from 06-07 have disappeared. The loans logic talks about loans.

listActive() does not appear in the output. It carries no @Auditable, and the handler lets it through without recording. The decision of whether to audit is where it should be: next to the method.

Nuria Vidal's password comes out as ***. sensitiveArguments = {1} did its job. 09-06's rule —never log credentials— has gone from being a comment somebody has to remember to being a declaration the engine applies.

The client receives its BiblioTechException, not an InvocationTargetException. The throw e.getCause() in the callTarget method is what achieves that. Without that line, the client's catch (BiblioTechException e) would not work and the application would behave inexplicably.

And this is, literally, how @Transactional works. Spring creates a proxy of your service, intercepts every call, sees the annotation, opens a transaction, invokes the real method, and commits or rolls back depending on whether there was an exception. Swap LOG.log(...) for transaction.begin() and transaction.commit() and you have the complete mechanism. It also explains its two famous limitations: it does not work on private methods (the proxy does not see them) or on internal calls from the class to itself (they do not go through the proxy). When you see it in 11-02, you will already know why.

  1. The price of reflection

Reflection is not free, and it is worth having the magnitudes clear.

Performance cost

These figures are illustrative and vary enormously with the JVM, the version and warm-up, but the order of magnitude is representative:

Operation Approximate relative cost
Direct call to a method 1× (reference)
Call via a cached Method.invoke with setAccessible(true) 2× – 5×
Call via Method.invoke without caching the Method 20× – 100×
Class.forName + getMethod + invoke on every call 100× – 1000×
Direct access to a field 1×
Cached Field.get 2× – 10×

The practical conclusion is a single one: what is expensive is the lookup, not the invocation. getMethod, getDeclaredFields and Class.forName walk internal structures, build fresh defensive arrays on every call and check accesses. The invocation itself, with the Method already in hand and accessible, is optimised fairly well by the JIT.

That is why the universal pattern in frameworks is to introspect once and cache, exactly as AnnotatedExporter does with its Map<Class<?>, List<Field>>. Spring builds its metadata at start-up; Hibernate maps the entities when the session starts; Jackson caches its serialisers per class.

And the honest measurement: measuring this with a home-made loop gives false results, because of JIT warm-up and dead code elimination. You will see that in 10-07 with JMH.

The other costs, which are usually worse

Loss of compile-time checking. It is the most expensive cost and it appears in no performance profile:

// If somebody renames getTitle() to title(), this still compiles
// and fails at run time, perhaps months later, perhaps in production.
Method m = type.getMethod("getTitle");

Code that is fragile under refactoring. The IDE's "Rename" changes every call... except text strings. A getDeclaredField("title") survives a rename untouched and fails at run time. It is the same problem as the XML in section 19 of 10-02, and that is why modern frameworks prefer annotations (which are renamed along with the class) to names in strings.

Problems with native compilation. GraalVM Native Image compiles to a binary and needs to know at compile time which classes will be used. Reflection breaks that analysis: if the class's name is built at run time, the compiler cannot know that it needs to be included, and removes it. It has to be declared in a reflect-config.json. This is one of the big reasons why Spring Boot 3 and Quarkus are moving work from run-time reflection to compile-time annotation processing.

Security. setAccessible(true) goes straight through encapsulation. A dynamically loaded plugin can read private fields holding credentials.

Unreadable stack traces. Every layer of reflection adds jdk.internal.reflect lines. Spring's mile-long traces come from here.

  1. When to use it and when not to

The rule, in one sentence: reflection is used by libraries, not by business logic.

If you are writing code that solves a Nexus Software problem —lending books, calculating fines, sending due notices— and a Class.forName shows up, something is wrong. If you are writing infrastructure that must work with classes that do not exist yet, reflection is the right tool.

Use reflection when... Do not use it when...
You are writing a framework or a generic library You are writing business logic
You must work with classes unknown at compile time You know the type: use polymorphism (03-06)
You are implementing serialisation, mapping or injection You want to "save yourself" a switch or an interface
You load plugins discovered at run time You want to bypass somebody else's public API
You are writing diagnostic tools It is in a hot loop
You are implementing a testing framework There is an alternative with compiler checking

Before turning to reflection, check these alternatives:

  1. Polymorphism. If you have three types and want different behaviour, an interface with three implementations is better than discovering methods by reflection.
  2. ServiceLoader. To discover implementations of an interface on the classpath, the JDK ships a standard mechanism that is cleaner than looking for classes by hand.
  3. Supplier<T> or Function<T, R>. If all you need is "create an object of a type my caller decides", a Supplier<T> (10-01) gives the same thing with type checking.
  4. An annotation processor. If the information is known at compile time, generating code costs zero at run time (10-02, section 16).

  1. MethodHandles and VarHandle: the modern alternative

Java 7 introduced java.lang.invoke with a faster alternative to classic reflection, and Java 9 completed it with VarHandle.

A MethodHandle is an executable, typed reference to a method. The key difference from Method.invoke is that the access check is done once, when you obtain it, and not on every invocation, and that the JIT can optimise it almost like a direct call when the handle is a static final constant.

package com.nexussoftware.bibliotech.service;

import java.lang.invoke.*;

public class MethodHandleExample {

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

        MethodHandles.Lookup lookup = MethodHandles.lookup();

        // The method's TYPE is declared explicitly: (return, parameters...)
        MethodType type = MethodType.methodType(String.class);
        MethodHandle getTitle = lookup.findVirtual(Book.class, "getTitle", type);

        Book book = new Book("978-0000000001", "Effective Java");

        // invokeExact requires the types to match EXACTLY
        String title = (String) getTitle.invokeExact(book);
        System.out.println(title);

        // VarHandle for fields, with explicit memory semantics (picks up 08-04)
        VarHandle onLoanField = MethodHandles
                .privateLookupIn(Book.class, lookup)
                .findVarHandle(Book.class, "onLoan", boolean.class);

        System.out.println("onLoan: " + onLoanField.get(book));
        onLoanField.setVolatile(book, true);                 // volatile write
        System.out.println("onLoan: " + onLoanField.get(book));

        // And atomic operations without AtomicBoolean (08-06)
        boolean changed = onLoanField.compareAndSet(book, true, false);
        System.out.println("compareAndSet: " + changed);
    }
}
Effective Java
onLoan: false
onLoan: true
compareAndSet: true
Aspect Classic reflection MethodHandle / VarHandle
Access check On every invoke (unless setAccessible) Once, when obtaining the handle
JIT optimisation Limited Good, like a direct call if it is static final
Typing of the invocation Object invoke(Object, Object...) Explicit MethodType, strict invokeExact
Exceptions Checked, with a wrapper Throwable, no wrapper
Memory semantics It does not control it VarHandle does: volatile, acquire, release, CAS
Ease of use Higher Lower: more ceremony
Introspection (listing members) Yes No: invocation only

When to use which:

  • To discover the structure (list fields, read annotations): classic reflection, which is the only one that does it.
  • To invoke repeatedly something already discovered on a critical path: MethodHandle.
  • For atomic access or access with memory semantics to fields: VarHandle, which replaces the old AtomicReferenceFieldUpdater and the sun.misc.Unsafe of 08-06.

VarHandles are, in fact, the basis of the JDK's modern atomic classes. And 04-05's lambdas are implemented with invokedynamic and MethodHandles underneath: when you write book -> book.getTitle(), the compiler generates an invokedynamic call that on first use builds the implementation with LambdaMetafactory, using handles. That is why lambdas are almost as fast as a direct call and do not carry reflection's cost.

Common Mistakes and Tips

1. Forgetting @Retention(RetentionPolicy.RUNTIME) on the annotation. The error shows up here, in reflection, even though its cause is in 10-02: isAnnotationPresent returns false with no explanation at all. It is the first thing to check when an annotation engine "does not see" anything.

2. Using getFields() expecting to see the private ones. getFields() gives only the public ones (own and inherited); getDeclaredFields() gives all of this class's but none of the inherited ones. For all the fields in the hierarchy you have to walk the superclasses by hand.

3. Not calling setAccessible(true) before touching a private member. IllegalAccessException fires with a message that mentions the modifiers.

4. Catching InvocationTargetException and not rethrowing getCause(). The client sees a useless wrapper instead of their own exception, and their catch blocks stop working. It is the most frequent bug when writing an InvocationHandler.

5. Looking up constructors or methods with Integer.class where the parameter is int. The lookup compares exact types. getDeclaredConstructor(int.class) and getDeclaredConstructor(Integer.class) are different lookups, and the wrong one produces a baffling NoSuchMethodException.

6. Not caching the introspection. getDeclaredFields() builds a fresh array every time. Calling it inside a loop over ten thousand entities multiplies the cost by ten thousand for no reason at all.

7. Relying on the order of getDeclaredFields() or getDeclaredMethods(). The specification says explicitly that there is no guaranteed order. Sort them yourself; that is why @CsvField has order.

8. Not filtering bridge and synthetic methods. Generics (10-01) generate methods you did not write. Without isBridge() and isSynthetic(), your engine processes them as if they were real.

9. Using Class.newInstance(). Deprecated since Java 9 because it propagates checked exceptions without declaring them. Use getDeclaredConstructor().newInstance().

10. Expecting a dynamic proxy to work on a class. java.lang.reflect.Proxy only implements interfaces. If your service has no interface, this mechanism is no use, and half the questions about why @Transactional "does nothing" come from there.

Tip 1: encapsulate reflection in one layer and do not let it out. Let AnnotatedExporter be the only place in the project where getDeclaredFields appears. The rest of BiblioTech should not know that reflection exists.

Tip 2: fail early and with clear messages. If your engine cannot find a method or an annotation, throw an exception with the class's name, the member's name and what you expected. Reflection errors are opaque by nature; make up for it with generous messages.

Tip 3: always cache, from day one. A Map<Class<?>, X> with computeIfAbsent is ten lines and removes 90% of the cost. If the engine is used from several threads, ConcurrentHashMap (08-06).

Tip 4: prefer annotations to strings. @CsvField on the field is renamed along with it; getDeclaredField("title") is not. Every string with a member's name is a future refactoring that will fail silently.

Tip 5: compile with -parameters. Without that option, the parameter names are arg0, arg1, and any engine that wants to use them is crippled.

Exercises

Exercise 1: the validator that consumes @Validate

In exercise 1 of 10-02 you defined the @Validate annotation with required, minLength, maxLength, pattern and message. Now write the engine.

  • A class AnnotatedValidator with a method Result<T> validate(T entity) (use 10-01's generic Result<T>).
  • It must walk all the annotated fields of the class and of its superclasses, cache the introspection, and accumulate all the failures.
  • It must respect every element of the annotation: requiredness, lengths, pattern (with a precompiled and cached Pattern) and custom message.
  • The default generated messages must include the field's name and the value received.
  • Add a method describe(Class<?>) that prints a class's rules with no need to instantiate it.

Try it with three Book objects: a valid one, one with a malformed ISBN and one with an empty title and an author that is too long.

Exercise 2: mini dependency injection container

Build a minimal dependency container for BiblioTech, with which you will understand what Spring does at start-up.

  • Define @Component (on classes, RUNTIME) and @Inject (on constructors and fields, RUNTIME).
  • Write SimpleContainer with:
    • register(Class<?>... classes): registers the available types.
    • <T> T get(Class<T> type): returns the instance, creating it if needed.
    • Resolution by constructor: if there is a constructor with @Inject, resolve every parameter recursively by its type.
    • Resolution by field: after creating the instance, inject the fields with @Inject.
    • Singleton: each type is instantiated only once.
    • Detection of circular dependencies with a useful message showing the cycle.
  • Demonstrate it by assembling LoanManager with its two Repository objects and a FineCalculator, without writing a single new.

Exercise 3: retry proxy that consumes @Retryable

In exercise 3 of 10-02 you defined @Retryable(attempts, initialWaitMs, growthFactor, exceptions, idempotentOnly). Write the proxy that applies it.

  • RetryProxy implementing InvocationHandler.
  • It must read the real method's annotation, retry only for the declared exceptions, wait with exponential growth and randomisation (jitter, picking up 09-06) and respect thread interruption (08-02).
  • If the attempts run out, it must throw the last exception with the previous ones as suppressed exceptions (addSuppressed, picking up 06-06).
  • It must log every attempt with its wait.
  • Try it with a fake MetadataClient that fails the first two times and works on the third, and with another that always fails.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.annotations.Validate;

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

/**
 * Validation engine driven by @Validate.
 * It does not know Book, Loan or any concrete entity.
 */
public class AnnotatedValidator {

    /** Introspection cached per class (ConcurrentHashMap: it may be used from several threads). */
    private final Map<Class<?>, List<Field>> fieldsByClass = new ConcurrentHashMap<>();

    /** Compiled patterns: Pattern.compile is expensive and the pattern does not change. */
    private final Map<String, Pattern> patterns = new ConcurrentHashMap<>();

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

    public <T> Result<T> validate(T entity) {
        if (entity == null) {
            return Result.failure("The entity is null");
        }

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

        for (Field field : validatableFields(entity.getClass())) {
            Validate rule = field.getAnnotation(Validate.class);
            Object value = read(field, entity);
            check(field, rule, value, failures);
        }

        if (failures.isEmpty()) {
            return Result.success(entity);
        }
        return Result.failure("Invalid " + entity.getClass().getSimpleName()
                + ": " + String.join("; ", failures));
    }

    /** Prints a class's rules without instantiating it. */
    public void describe(Class<?> type) {
        System.out.println("Rules of " + type.getSimpleName() + ":");
        for (Field field : validatableFields(type)) {
            Validate r = field.getAnnotation(Validate.class);
            System.out.printf("  %-12s required=%-5s len=[%d..%s] pattern=%s%n",
                    field.getName(),
                    r.required(),
                    r.minLength(),
                    r.maxLength() == Integer.MAX_VALUE ? "∞" : r.maxLength(),
                    r.pattern().isEmpty() ? "-" : r.pattern());
        }
    }

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

    /** Annotated fields of the class and of all its superclasses. */
    private List<Field> validatableFields(Class<?> type) {
        return fieldsByClass.computeIfAbsent(type, t -> {
            List<Field> found = new ArrayList<>();
            for (Class<?> c = t; c != null && c != Object.class; c = c.getSuperclass()) {
                for (Field field : c.getDeclaredFields()) {
                    if (field.isSynthetic() || !field.isAnnotationPresent(Validate.class)) {
                        continue;
                    }
                    field.setAccessible(true);
                    found.add(field);
                }
            }
            // Stable order by name: getDeclaredFields does not guarantee one
            found.sort(Comparator.comparing(Field::getName));
            return List.copyOf(found);
        });
    }

    private Object read(Field field, Object entity) {
        try {
            return field.get(entity);
        } catch (IllegalAccessException e) {
            throw new IllegalStateException("Could not read " + field.getName(), e);
        }
    }

    private void check(Field field, Validate rule, Object value, List<String> failures) {

        String name = field.getName();

        // 1. Requiredness
        if (value == null) {
            if (rule.required()) {
                failures.add(message(rule, "'" + name + "' is mandatory and is empty"));
            }
            return;                                  // with no value there is nothing else to check
        }

        // The other rules only apply to text
        if (!(value instanceof String text)) {
            return;
        }

        if (rule.required() && text.isBlank()) {
            failures.add(message(rule, "'" + name + "' is mandatory and is blank"));
            return;
        }

        // 2. Lengths
        if (text.length() < rule.minLength()) {
            failures.add(message(rule, String.format(
                    "'%s' needs at least %d characters and has %d",
                    name, rule.minLength(), text.length())));
        }
        if (text.length() > rule.maxLength()) {
            failures.add(message(rule, String.format(
                    "'%s' allows at most %d characters and has %d",
                    name, rule.maxLength(), text.length())));
        }

        // 3. Pattern
        if (!rule.pattern().isEmpty()) {
            Pattern pattern = patterns.computeIfAbsent(rule.pattern(), Pattern::compile);
            if (!pattern.matcher(text).matches()) {
                failures.add(message(rule, String.format(
                        "'%s' does not match the format %s (value: \"%s\")",
                        name, rule.pattern(), text)));
            }
        }
    }

    /** The annotation's custom message takes priority over the generated one. */
    private String message(Validate rule, String generated) {
        return rule.message().isEmpty() ? generated : rule.message();
    }
}

Test:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.service.*;

import java.util.List;

public class AnnotatedValidatorTest {

    public static void main(String[] args) {

        AnnotatedValidator validator = new AnnotatedValidator();

        validator.describe(Book.class);
        System.out.println();

        List<Book> candidates = List.of(
                new Book("978-0000000001", "Effective Java", "Joshua Bloch"),
                new Book("978-1",          "Refactoring",    "Martin Fowler"),
                new Book("978-0000000003", "",               "A".repeat(150)));

        for (Book book : candidates) {
            Result<Book> r = validator.validate(book);
            System.out.println(r.isSuccess()
                    ? "OK       -> " + r.value().getTitle()
                    : "REJECTED -> " + r.error());
        }
    }
}
Rules of Book:
  author       required=false len=[0..120] pattern=-
  isbn         required=true  len=[0..∞] pattern=978-\d{10}
  title        required=true  len=[1..200] pattern=-

OK       -> Effective Java
REJECTED -> Invalid Book: The ISBN must follow the format 978-XXXXXXXXXX
REJECTED -> Invalid Book: 'author' allows at most 120 characters and has 150; 'title' is mandatory and is blank

Comments.

The two levels of cache are the real pattern. One for the introspection per class and another for the compiled Pattern objects. Pattern.compile builds an automaton: doing it on every validation of every field of every entity is an enormous waste and a classic source of slowness.

The custom message beats the generated one. The malformed ISBN produces the message the developer put in the annotation, not the technical one. It is the difference between a message for the user and one for the log.

ConcurrentHashMap and not HashMap. This validator is a shared object —in Spring it would be a singleton @Component— and several threads will call it at once. An unsynchronised HashMap in that situation can become corrupted (08-06).

And notice what this validator has in common with AnnotatedExporter: walk the hierarchy, filter by annotation, setAccessible, sort, cache. It is the same skeleton. Once you have written it twice, you will recognise that skeleton inside any framework.

Solution 2

package com.nexussoftware.bibliotech.annotations;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Component {
    String name() default "";
}
package com.nexussoftware.bibliotech.annotations;

import java.lang.annotation.*;

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

import com.nexussoftware.bibliotech.annotations.*;

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

/**
 * Minimal dependency injection container.
 * It is, in essence, what Spring's ApplicationContext does
 * reduced to the bare essentials.
 */
public class SimpleContainer {

    /** Types registered as available. */
    private final Set<Class<?>> registered = new LinkedHashSet<>();

    /** Instances already created: every component is a singleton. */
    private final Map<Class<?>, Object> instances = new LinkedHashMap<>();

    /** Types currently being created: used to detect cycles. */
    private final Deque<Class<?>> underConstruction = new ArrayDeque<>();

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

    public SimpleContainer register(Class<?>... classes) {
        for (Class<?> clazz : classes) {
            if (!clazz.isAnnotationPresent(Component.class)) {
                throw new IllegalArgumentException(
                        clazz.getSimpleName() + " is not annotated with @Component");
            }
            registered.add(clazz);
        }
        return this;
    }

    /** Registers an already-built instance (for configuration, values, etc.). */
    public <T> SimpleContainer registerInstance(Class<T> type, T instance) {
        instances.put(type, instance);
        registered.add(type);
        return this;
    }

    @SuppressWarnings("unchecked")
    public <T> T get(Class<T> type) {
        // Safe cast: we only store instances of the type they are indexed by
        return (T) resolve(type);
    }

    public void list() {
        System.out.println("Instantiated components (" + instances.size() + "):");
        instances.forEach((type, obj) ->
                System.out.printf("  %-24s -> %s%n", type.getSimpleName(), obj));
    }

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

    private Object resolve(Class<?> type) {

        // 1. Does it already exist? Singleton.
        Object existing = instances.get(type);
        if (existing != null) {
            return existing;
        }

        // 2. Look for a compatible registered implementation
        Class<?> implementation = findImplementation(type);

        // 3. Detect cycles BEFORE going into recursion
        if (underConstruction.contains(implementation)) {
            List<String> cycle = new ArrayList<>();
            for (Class<?> c : underConstruction) {
                cycle.add(0, c.getSimpleName());
            }
            cycle.add(implementation.getSimpleName());
            throw new IllegalStateException("Circular dependency: " + String.join(" -> ", cycle));
        }

        underConstruction.push(implementation);
        try {
            Object instance = build(implementation);
            injectFields(instance);
            instances.put(type, instance);
            if (type != implementation) {
                instances.put(implementation, instance);
            }
            return instance;
        } finally {
            underConstruction.pop();
        }
    }

    /** If an interface is requested, find the single registered implementation. */
    private Class<?> findImplementation(Class<?> type) {
        if (registered.contains(type) && !type.isInterface()) {
            return type;
        }
        List<Class<?>> candidates = new ArrayList<>();
        for (Class<?> c : registered) {
            if (type.isAssignableFrom(c) && !c.isInterface()
                    && !Modifier.isAbstract(c.getModifiers())) {
                candidates.add(c);
            }
        }
        if (candidates.isEmpty()) {
            throw new IllegalStateException(
                    "There is no registered component satisfying " + type.getSimpleName());
        }
        if (candidates.size() > 1) {
            throw new IllegalStateException("There are " + candidates.size()
                    + " candidates for " + type.getSimpleName() + ": " + candidates);
        }
        return candidates.get(0);
    }

    /** Instantiates, resolving the constructor's parameters recursively. */
    private Object build(Class<?> clazz) {

        Constructor<?> chosen = chooseConstructor(clazz);
        chosen.setAccessible(true);

        Class<?>[] parameterTypes = chosen.getParameterTypes();
        Object[] arguments = new Object[parameterTypes.length];

        for (int i = 0; i < parameterTypes.length; i++) {
            arguments[i] = resolve(parameterTypes[i]);        // RECURSION
        }

        try {
            return chosen.newInstance(arguments);
        } catch (InvocationTargetException e) {
            throw new IllegalStateException(
                    clazz.getSimpleName() + "'s constructor threw an exception",
                    e.getCause());                            // the CAUSE, not the wrapper
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("Could not instantiate " + clazz.getSimpleName(), e);
        }
    }

    private Constructor<?> chooseConstructor(Class<?> clazz) {
        Constructor<?> noArgs = null;

        for (Constructor<?> c : clazz.getDeclaredConstructors()) {
            if (c.isAnnotationPresent(Inject.class)) {
                return c;                                     // the annotated one wins
            }
            if (c.getParameterCount() == 0) {
                noArgs = c;
            }
        }
        if (noArgs != null) {
            return noArgs;
        }
        // Convention: if there is only one, it is used (as Spring does since 4.3)
        Constructor<?>[] all = clazz.getDeclaredConstructors();
        if (all.length == 1) {
            return all[0];
        }
        throw new IllegalStateException(clazz.getSimpleName()
                + " has several constructors and none carries @Inject");
    }

    /** Field injection, AFTER building. */
    private void injectFields(Object instance) {
        for (Class<?> c = instance.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
            for (Field field : c.getDeclaredFields()) {
                if (!field.isAnnotationPresent(Inject.class)) {
                    continue;
                }
                field.setAccessible(true);
                try {
                    field.set(instance, resolve(field.getType()));
                } catch (IllegalAccessException e) {
                    throw new IllegalStateException(
                            "Could not inject the field " + field.getName(), e);
                }
            }
        }
    }
}

The components:

@Component
public class FineCalculator {
    public double calculate(int daysLate) { return daysLate * 0.25; }
    @Override public String toString() { return "[email protected] EUR/day"; }
}

@Component
public class MaterialRepository extends Repository<Material> {
    public MaterialRepository() { super("catalogue"); }
}

@Component
public class LoanRepository extends Repository<Loan> {
    public LoanRepository() { super("loans"); }
}

@Component
public class LoanManager implements LoanService {

    private final MaterialRepository catalog;
    private final LoanRepository loans;

    @Inject                                    // the container will use THIS constructor
    public LoanManager(MaterialRepository catalog, LoanRepository loans) {
        this.catalog = catalog;
        this.loans = loans;
    }

    @Inject                                    // field injection
    private FineCalculator calculator;

    @Override public String toString() {
        return "LoanManager[catalogue=" + catalog + ", fines=" + calculator + "]";
    }
}

The start-up:

public class ContainerBootstrap {

    public static void main(String[] args) {

        SimpleContainer container = new SimpleContainer()
                .register(LoanManager.class,
                          MaterialRepository.class,
                          LoanRepository.class,
                          FineCalculator.class);

        // NOT A SINGLE new: the container resolves the whole graph
        LoanService service = container.get(LoanService.class);

        System.out.println("Obtained: " + service);
        System.out.println();
        container.list();

        // Singleton: two requests give the SAME object
        System.out.println();
        System.out.println("Singleton? "
                + (container.get(FineCalculator.class)
                   == container.get(FineCalculator.class)));
    }
}
Obtained: LoanManager[catalogue=Repository[catalogue, 0 entities], [email protected] EUR/day]

Instantiated components (5):
  MaterialRepository       -> Repository[catalogue, 0 entities]
  LoanRepository           -> Repository[loans, 0 entities]
  FineCalculator           -> [email protected] EUR/day
  LoanManager              -> LoanManager[...]
  LoanService              -> LoanManager[...]

Singleton? true

And the cycle detection, with two components needing each other:

Exception in thread "main" java.lang.IllegalStateException:
    Circular dependency: ServiceA -> ServiceB -> ServiceA

Comments. This exercise is the most revealing of the lesson.

A hundred and fifty lines and you already have dependency injection. Recursive resolution by constructor, field injection, singletons, interface-to-implementation resolution and cycle detection. Spring does the same, with vastly more cases: scopes, life cycles, external configuration, proxies, profiles, events. But the core is this, and it is no longer a mystery.

The cycle detection uses a stack. underConstruction holds what is being created right now; if the recursion asks again for something already on the stack, there is a cycle. Without that check, the program would go into infinite recursion and die with StackOverflowError without saying why — which is exactly what the primitive containers did.

Field injection happens after building, and it cannot be otherwise: you cannot write into a field of an object that does not exist yet. This explains a real Spring limitation: an @Autowired field is not available inside the constructor. It is the main reason constructor injection is recommended: it guarantees that the object is born complete and it allows final fields.

The throw new IllegalStateException(..., e.getCause()) in the constructor is section 9's rule applied. If your component's constructor throws an exception, you want to see that, not an InvocationTargetException.

Solution 3

package com.nexussoftware.bibliotech.network;

import com.nexussoftware.bibliotech.annotations.Retryable;

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 that applies @Retryable's policy.
 * It replaces the retry logic hard-coded by hand
 * inside ResilientHttpClient (09-06).
 */
public final class RetryProxy implements InvocationHandler {

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

    private final Object target;

    private RetryProxy(Object target) {
        this.target = target;
    }

    @SuppressWarnings("unchecked")
    public static <T> T withRetries(T target, Class<T> iface) {
        return (T) Proxy.newProxyInstance(
                iface.getClassLoader(),
                new Class<?>[] { iface },
                new RetryProxy(target));
    }

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

        Retryable policy = resolvePolicy(method);

        if (policy == null || policy.attempts() <= 1) {
            return callTarget(method, args);       // no policy: just once
        }

        List<Throwable> previousFailures = new ArrayList<>();
        long wait = policy.initialWaitMs();

        for (int attempt = 1; attempt <= policy.attempts(); attempt++) {
            try {
                Object result = callTarget(method, args);

                if (attempt > 1) {
                    int n = attempt;
                    LOG.info(() -> String.format("%s() succeeded on attempt %d",
                            method.getName(), n));
                }
                return result;

            } catch (Throwable failure) {

                if (!isRetryable(failure, policy)) {
                    LOG.fine(() -> "Not retrying " + failure.getClass().getSimpleName()
                            + ": it is not in the policy");
                    throw failure;                  // permanent error: do not insist
                }

                previousFailures.add(failure);

                if (attempt == policy.attempts()) {
                    // Last attempt: attach the previous ones as suppressed (06-06)
                    for (int i = 0; i < previousFailures.size() - 1; i++) {
                        failure.addSuppressed(previousFailures.get(i));
                    }
                    LOG.log(Level.SEVERE, () -> String.format(
                            "%s() ran out of its %d attempts", method.getName(), policy.attempts()));
                    throw failure;
                }

                // Randomisation (jitter): stops N clients retrying at once (09-06)
                long withJitter = wait + ThreadLocalRandom.current().nextLong(wait / 2 + 1);

                int n = attempt;
                LOG.warning(() -> String.format("%s() failed on attempt %d (%s); waiting %d ms",
                        method.getName(), n, failure.getClass().getSimpleName(), withJitter));

                sleep(withJitter);

                wait = (long) (wait * policy.growthFactor());
            }
        }
        throw new IllegalStateException("unreachable");
    }

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

    private Retryable resolvePolicy(Method interfaceMethod) {
        try {
            Method real = target.getClass().getMethod(
                    interfaceMethod.getName(), interfaceMethod.getParameterTypes());
            return real.getAnnotation(Retryable.class);
        } catch (NoSuchMethodException e) {
            return null;
        }
    }

    private boolean isRetryable(Throwable failure, Retryable policy) {
        for (Class<? extends Exception> type : policy.exceptions()) {
            if (type.isInstance(failure)) {     // isInstance: the reflective instanceof
                return true;
            }
        }
        return false;
    }

    private Object callTarget(Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        }
    }

    /** Respects interruption: restores the flag and aborts (08-02). */
    private void sleep(long ms) throws InterruptedException {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw e;
        }
    }
}

Test with a fake client:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.annotations.Retryable;
import com.nexussoftware.bibliotech.network.RetryProxy;

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

public class RetryProxyTest {

    interface MetadataService {
        String lookupByIsbn(String isbn) throws IOException;
        String alwaysFails(String isbn) throws IOException;
    }

    /** Test double: it fails the first two times and then works. */
    static class FlakyClient implements MetadataService {

        private int calls = 0;

        @Override
        @Retryable(attempts = 4, initialWaitMs = 100, growthFactor = 2.0,
                   exceptions = { SocketTimeoutException.class })
        public String lookupByIsbn(String isbn) throws IOException {
            calls++;
            if (calls < 3) {
                throw new SocketTimeoutException("timed out (call " + calls + ")");
            }
            return "{\"isbn\":\"" + isbn + "\",\"title\":\"Effective Java\"}";
        }

        @Override
        @Retryable(attempts = 3, initialWaitMs = 50, growthFactor = 3.0,
                   exceptions = { IOException.class })
        public String alwaysFails(String isbn) throws IOException {
            throw new IOException("the metadata service is not responding");
        }
    }

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

        LogConfiguration.initialise();

        MetadataService service =
                RetryProxy.withRetries(new FlakyClient(), MetadataService.class);

        System.out.println("--- case 1: recovers on the third attempt ---");
        System.out.println("Result: " + service.lookupByIsbn("978-0000000001"));

        System.out.println();
        System.out.println("--- case 2: runs out of attempts ---");
        try {
            service.alwaysFails("978-0000000002");
        } catch (IOException e) {
            System.out.println("Final exception: " + e.getMessage());
            System.out.println("Suppressed failures: " + e.getSuppressed().length);
            for (Throwable s : e.getSuppressed()) {
                System.out.println("  suppressed: " + s.getMessage());
            }
        }
    }
}
--- case 1: recovers on the third attempt ---
[WARNING] lookupByIsbn() failed on attempt 1 (SocketTimeoutException); waiting 137 ms
[WARNING] lookupByIsbn() failed on attempt 2 (SocketTimeoutException); waiting 265 ms
[INFO]    lookupByIsbn() succeeded on attempt 3
Result: {"isbn":"978-0000000001","title":"Effective Java"}

--- case 2: runs out of attempts ---
[WARNING] alwaysFails() failed on attempt 1 (IOException); waiting 71 ms
[WARNING] alwaysFails() failed on attempt 2 (IOException); waiting 194 ms
[SEVERE]  alwaysFails() ran out of its 3 attempts
Final exception: the metadata service is not responding
Suppressed failures: 2
  suppressed: the metadata service is not responding
  suppressed: the metadata service is not responding

Comments.

The policy is no longer in the method: it is declared on it. lookupByIsbn contains no retry loop, no Thread.sleep, no counter. Compare it with 09-06's version, where those twenty lines lived inside every network method and had to be copied when the next one was added.

The suppressed exceptions keep the complete history. With addSuppressed (06-06), whoever receives the final failure can see all the attempts, not just the last. In a real incident, knowing that the three failures were the same IOException or that the first was a timeout and the second a ConnectException changes the diagnosis.

type.isInstance(failure) is the reflective instanceof, and it is the only way of checking against a Class<?> that comes from an annotation. A failure instanceof type would not compile: type is a variable, not a type.

Interruption is respected. If somebody interrupts the thread during the wait, the proxy restores the flag and propagates the InterruptedException instead of swallowing it and carrying on retrying. It is 08-02's rule, and in a proxy that sleeps it is especially important: without it, an ExecutorService could not shut down while somebody is retrying.

And with this, BiblioTech has its three engines. One reads field annotations and exports; another reads class annotations and assembles the object graph; another reads method annotations and intercepts calls. All three are completely ignorant of the library's domain. That is a framework.

Conclusion

You have written the missing reader, and with it you have seen from the inside how frameworks work.

You know what reflection is: inspecting and manipulating classes, fields and methods at run time without knowing them at compile time. And you know what the deal is: you gain total flexibility and lose every bit of help from the compiler.

You obtain the Class<?> object in three ways —the typed .class literal, getClass() which gives the real type and not the declared one, and Class.forName which takes a string, does not require the class at compile time and initialises the class—. You inspect it with getName/getSimpleName/getCanonicalName (and you know how they differ with nested classes, arrays and anonymous ones), you walk its hierarchy with getSuperclass and getInterfaces, and you read its modifiers with the bit mask that Modifier interprets. You classify it with isEnum, isRecord, isInterface, isArray, and you extract its constants with getEnumConstants or its components with getRecordComponents.

You have mastered the distinction that confuses everybody: getFields gives the public ones, own and inherited; getDeclaredFields gives all of this class's but none of the inherited ones. One looks upwards and the other inwards, and neither gives everything, so you have to walk the hierarchy with a loop over getSuperclass(). The same, exactly the same, for methods and constructors. And you filter out the bridge and synthetic ones that 10-01's generic erasure generated behind your back.

You create instances with getDeclaredConstructor(types).newInstance(values) —remembering that an int parameter is looked up with int.class and not with Integer.class, and that Class.newInstance() is deprecated because it bypasses checked exceptions—. You invoke methods with invoke(instance, args...), passing null for the static ones and always receiving Object. And you know that the invoked method's exceptions arrive wrapped in InvocationTargetException, with the corresponding golden rule: catch and rethrow getCause(), never the wrapper, or your users' catch blocks will stop working.

You reach what is private with setAccessible(true), you understand that it goes completely through 03-07's encapsulation —to the point of being able to put -999 pages into a private final that the constructor validates—, and you know that since Java 9 the module system can block it with InaccessibleObjectException, that --add-opens is the emergency exit and that opens is different from exports. All of that is developed in 10-06.

And above all: you wrote the engines. The AnnotatedExporter walks the @CsvField fields of any entity —present or future—, sorts them, applies formats, masks the sensitive ones and generates header and lines without knowing Book, Loan or Card. The AuditProxy, built on Proxy.newProxyInstance and InvocationHandler, intercepts every call to LoanManager, reads its @Auditable —from the method, and failing that, from the class—, records start, end, duration and failure, and masks the arguments marked as sensitive, while LoanManager contains not one line of auditing. And in the exercises you have written an annotation-driven validator, a complete dependency injection container with singletons, recursive resolution and cycle detection, and a retry proxy with increasing waits and suppressed exceptions.

That is no longer learning reflection: it is having written a framework in miniature. When in 11-02 you see Spring instantiating your services and injecting them, you will know that it walks the classpath, looks for @Component, resolves constructors and calls newInstance. When @Transactional opens transactions, you will know there is a proxy intercepting — and you will also know why it does not work on private methods or on internal calls. When Hibernate maps your entities by reading @Column, and Jackson serialises your record by reading its components, and JUnit runs your @Test methods, you will recognise the same skeleton you have written three times: walk, filter by annotation, setAccessible, sort, cache, invoke.

You also know the price. What is expensive is not invoking, it is looking up: getMethod and getDeclaredFields build fresh arrays on every call, and that is why every framework —and your exporter— caches the introspection. But the worse costs are not about performance: you lose the compiler's checking, the code becomes fragile under refactoring because the IDE does not rename text strings, and native compilation can no longer analyse your program, which is the real reason why Spring Boot 3 and Quarkus are moving work from reflection towards compile-time annotation processing. Hence the rule: reflection is used by libraries, not by business logic. And you know the modern alternative: MethodHandles to invoke quickly what has already been discovered, and VarHandle for access with memory semantics and atomic operations — the machinery on which the JDK's own lambdas and atomic classes are implemented.

BiblioTech has changed category. It is no longer a program with classes: it is a program with its own infrastructure. A universal exporter, a validation engine, a dependency container and two proxies that add auditing and retries without touching the logic. LoanManager talks only about loans; everything else it declares with labels.

And yet, the code inside is still just as verbose. To find out how many books of more than 400 pages are on loan and to sort them by title, BiblioTechStatistics still does the same as in module 5: declare an empty list, walk it with a for, check with an if, accumulate, sort at the end. To group loans by employee there are nested loops with a HashMap and a computeIfAbsent. And when findBySubject finds nothing it returns null, and there are sixteen places where somebody has to remember to check it — and one of them will forget.

In 10-04, Java 8 features: Streams and Optional, both of those end. You will see the Streams API, which turns those loops into a declarative description of what you want —filter, map, flatMap, sorted, collect, groupingBy— and makes the report by material type and employee that took thirty lines of nested loops take three. You will see lazy evaluation, which makes the real execution order not the one you expect. You will see parallel streams and when they really help and when they hurt. And you will see Optional, which makes it impossible to forget to check for absence, antipatterns included.

It is the lesson the course has been waiting for since module 4, when you learned lambdas and we told you their real destination was further ahead. It has arrived.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved