Skip to content

VOL 09 / CH 06 / LESSON 02

6.2 Runtime Metaprogramming: Reflection, Decorators, and Class Creation ​

Build-time generators can only use information available during compilation. Dependency injection containers, serialization frameworks, and testing tools often need to make decisions based on runtime types or configurations, so they rely on reflection, proxies, decorators, or class creation hooks. These mechanisms add flexibility and defer some errors to runtime. The program gains more options; so does the timing of its failures.

Reflection: Treating Program Structure as Data to Query ​

Java reflection inspects classes, fields, methods, and annotations and invokes members at runtime. Place this Java 17 method fragment inside a class with imports for java.lang.reflect.* and java.util.*:

java
static Map<String, Object> inspectRecord(Object value)
        throws ReflectiveOperationException {
    Class<?> type = Objects.requireNonNull(value, "value").getClass();
    if (!type.isRecord()) {
        throw new IllegalArgumentException("record required");
    }

    Map<String, Object> result = new LinkedHashMap<>();
    for (RecordComponent component : type.getRecordComponents()) {
        Method accessor = component.getAccessor();
        result.put(component.getName(), accessor.invoke(value));
    }
    return result;
}

For an accessible public record User(String name, int age), passing new User("Ada", 36) produces {name=Ada, age=36}. Passing an ordinary string throws IllegalArgumentException. Component order follows the record declaration. Nonpublic types, unexported module packages, or overridden accessors that throw can still cause invocation to fail.

The caller can inspect a record type it did not know at compile time. General costs of reflection also include:

  • Invalid member names being detected only at runtime;
  • Access control and module boundaries potentially blocking deep reflection;
  • Exceptions being wrapped by reflection calls;
  • Renaming and static analysis tools struggling to track string references;
  • Repeated lookups requiring caching, which in turn must account for classloader unloading.

Prefer public APIs and access capabilities supported by MethodHandles.Lookup. Avoid forcing access to internal members as a stable extension point.

Dynamic Proxy: Inserting Behavior at Call Boundaries ​

Java interface proxies route calls through a handler. This fragment assumes target implements the proxied interface and metrics.record is thread-safe and does not throw:

java
InvocationHandler timing = (proxy, method, args) -> {
    long start = System.nanoTime();
    try {
        return method.invoke(target, args);
    } catch (InvocationTargetException error) {
        throw error.getCause();
    } finally {
        metrics.record(method.getName(), System.nanoTime() - start);
    }
};

Method.invoke wraps a target exception in InvocationTargetException. Unwrapping preserves the exception declared by the interface, or the original runtime exception. A checked exception not declared by the interface still becomes UndeclaredThrowableException. A metrics failure inside finally can also replace the original result or exception, so the observation callback needs an explicit failure policy.

Transaction, authorization, tracing, and retry frameworks often use such mechanisms. However, the more interception layers are added, the harder it becomes to trace the actual call path from source code. It's especially important to clearly understand:

  • Whether self-invocations within the same object go through the proxy;
  • Whether exceptions are unwrapped or transformed;
  • How equals, hashCode, and toString are handled; blindly delegating equals to the target can even make proxy.equals(proxy) false;
  • When tracing and transaction boundaries end after an asynchronous return;
  • Whether the proxy preserves annotation and generic type information.

Bytecode instrumentation can transform a class during loading. After loading, retransformation and redefinition depend on JVM capabilities and restrictions; they cannot arbitrarily add or remove fields and methods. Instrumentation offers broader coverage than interface proxies. However, it is more prone to conflicts with JDK versions, other agents, and class validation rules. It's well-suited for observability and framework infrastructure, but not appropriate for hiding ordinary business logic.

Python Decorators Occur at Definition Time ​

The example uses Python 3.10 or later. A decorator receives the decorated object when its definition statement executes and returns a replacement. Timing inside the wrapper happens later, when that wrapper is called:

python
from collections.abc import Callable
from functools import wraps
from time import perf_counter
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        started = perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__qualname__}: {perf_counter() - started:.6f}s")
    return wrapper

@timed
def calculate(value: int) -> int:
    return value * 2

The @timed can be roughly understood as executing a single assignment after a class or function definition has completed:

python
calculate = timed(calculate)

Calling calculate(5) returns 10 and prints one timing line; calculate.__name__ remains calculate. The second assignment block explains the syntax equivalence. Do not execute it again on the already decorated function, which would add a second wrapper.

functools.wraps preserves names, documentation, and __wrapped__, but does not automatically preserve asynchronous or generator execution semantics. This timed measures synchronous calls. Applied to async def, it measures coroutine creation; an asynchronous variant must await fn(...) inside its wrapper. Decorator factories can accept configuration options, but nested layers complicate exception traces and type inference.

How Metaclasses Control Class Object Creation ​

In Python, classes themselves are objects, and metaclasses define the process of creating class objects:

python
class RegistryMeta(type):
    registry: dict[str, type] = {}

    def __new__(mcls, name, bases, namespace, **kwargs):
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)
        key = namespace.get("plugin_name")
        if key is not None:
            if not isinstance(key, str) or not key:
                raise TypeError("plugin_name must be a nonempty string")
            if key in mcls.registry:
                raise TypeError(f"duplicate plugin: {key}")
            mcls.registry[key] = cls
        return cls

class Plugin(metaclass=RegistryMeta):
    pass

class CsvPlugin(Plugin):
    plugin_name = "csv"

After execution, RegistryMeta.registry["csv"] is CsvPlugin is true. Defining another class with plugin_name = "csv" raises TypeError during that definition. Only an explicitly declared plugin_name is registered, so subclasses that merely inherit it do not register again. The registry retains classes strongly and needs explicit removal for unloading. A failed module import does not roll back registrations completed earlier in that import.

This approach can register or constrain a family of classes. If you're only modifying a single class, class decorators are typically more localized. If you're simply reusing behavior, plain base classes or composition are often more intuitive. Before adopting metaclasses, you must evaluate the costs of metaclass conflicts, side effects during imports, and lack of test isolation.

Dynamic Mechanisms Must Preserve Static Boundaries ​

Even if internal dependencies use reflection, the external interface can still expose ordinary type interfaces:

java
interface Codec<T> {
    byte[] encode(T value);
    T decode(byte[] bytes);
}

interface CodecRegistry {
    <T> Codec<T> codecFor(Class<T> type);
}

This is an interface contract. Its implementation must ensure that Class<T> matches the returned Codec<T>; an unchecked cast alone does not establish type safety. By encapsulating dynamic behavior behind a registry, factory, or adapter, most business code can continue to benefit from compile-time type checking.

Runtime discoveries should be validated early during startup, rather than waiting until the first real request to discover missing constructors or duplicate registrations. During service startup, you can:

  • Scan and build a metadata cache;
  • Validate all handler signatures;
  • Detect conflicts and cyclic dependencies;
  • Produce a queryable registry;
  • Terminate startup on failure, rather than degrading into unpredictable runtime errors.

Performance Issues: First Measure Repeated Lookup ​

Reflection calls aren't necessarily performance bottlenecks. The real cost often lies elsewhere: repeatedly scanning the classpath on every request, incorrectly creating proxies, holding onto class loaders in memory, or triggering massive allocations in dynamic layers.

The optimization order should be:

  1. Cache stable member lookup results;
  2. Move scanning to startup or build phases;
  3. Use method handles or generated code to reduce adaptation overhead on hot paths;
  4. Validate with JFR or benchmarking, never rely solely on the assumption that "reflection is slow."

Security Boundaries ​

Never let untrusted input directly determine:

  • The arbitrary class name to load;
  • The arbitrary method name to invoke;
  • The files or modules that are accessible;
  • The specific type to deserialize and instantiate.

Choose allowlists, restricted interfaces, or separate processes according to plugin trust, and constrain the isolated process's resources and permissions. Reflection still checks access and argument compatibility. The exposure arises when the application lets external input select capabilities that should not be available.

Completion Check ​

For a plugin registration system, evaluate three approaches: explicit Map, annotation scanning, and Python metaclass-based automatic registration. Answer the following:

  1. When do errors occur, during build, startup, or the first request?
  2. Can the IDE locate all implementations?
  3. Is the class loader properly released when a plugin is uninstalled?
  4. How are duplicate names reported?
  5. How are untrusted plugins isolated?

References ​

Built with VitePress | Software Systems Atlas