6.2 Runtime Metaprogramming: Reflection, Decorators, and Class Creation
The Developer Workshop's dependency injection container must know which objects to instantiate only after the program runs, static generation isn't sufficient to cover all scenarios.
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 introduce dynamism, but they also defer certain errors to runtime.
Reflection: Treating Program Structure as Data to Query
Java reflection allows inspection of classes, fields, methods, and annotations, and invocation of members at runtime:
static Map<String, Object> inspectRecord(Object value)
throws ReflectiveOperationException {
Class<?> type = 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;
}2
3
4
5
6
7
8
9
10
11
12
13
14
This code can handle records defined at compile time that are unknown at runtime, but at a cost including:
- Member names becoming visible 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's interface-based proxy allows method calls to be routed to a unified handler:
InvocationHandler timing = (proxy, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(target, args);
} finally {
metrics.record(method.getName(), System.nanoTime() - start);
}
};2
3
4
5
6
7
8
Transaction, authorization, tracing, and retry frameworks often rely on 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, andtoStringare handled; - When tracing and transaction boundaries end after an asynchronous return;
- Whether the proxy preserves annotation and generic type information.
Bytecode instrumentation can modify classes before or after loading, offering broader coverage than interface-based 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
Decorators receive the object being decorated and return a replacement object:
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 * 22
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
The @timed can be roughly understood as executing a single assignment after a class or function definition has completed:
calculate = timed(calculate)functools.wraps preserves names, documentation, and __wrapped__ among other metadata, which aids debugging and tool recognition. 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:
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 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"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
This approach is suitable for uniformly registering or constraining 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:
interface Codec<T> {
byte[] encode(T value);
T decode(byte[] bytes);
}
final class CodecRegistry {
<T> Codec<T> codecFor(Class<T> type) {
// Internally discoverable via reflection, returned as a typed interface
}
}2
3
4
5
6
7
8
9
10
By encapsulating dynamic behavior behind registry, factory, or adapter layers, 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 the Classpath Resolution Path
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:
- Cache stable member lookup results;
- Move scanning to startup or build phases;
- Use method handles or generated code to reduce adaptation overhead on hot paths;
- 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.
Use allowlists, capability-restricted interfaces, and isolated-process plugin mechanisms. The convenience of reflection in bypassing type checks can also circumvent well-intended authorization boundaries.
Completion Check
For a plugin registration system, evaluate three approaches: explicit Map, annotation scanning, and Python metaclass-based automatic registration. Answer the following:
- When do errors occur, during build, startup, or the first request?
- Can the IDE locate all implementations?
- Is the class loader properly released when a plugin is uninstalled?
- How are duplicate names reported?
- How are untrusted plugins isolated?