Annotations and reflection

Retention, targets, reading annotations at runtime, proxies — and what it costs, so you know why Spring startup takes what it takes.

4 min read🧰 Exceptions, I/O and Reflection

Every Spring application is held together by two mechanisms you rarely call directly: annotations, which attach metadata to code, and reflection, which reads that metadata and the code's structure at run time. @RestController, @Transactional, @Autowired, @Entity, @JsonProperty — each is an annotation that some framework finds by reflection and acts on. Understanding the machinery explains what the frameworks can and cannot do, and what it costs at startup.

Annotations

An annotation is a type. Declaring one:

java
@Retention(RetentionPolicy.RUNTIME)      // keep it in the class file AND make it readable at run time
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RateLimited {
    int permitsPerSecond() default 10;
    String key() default "";
}

Retention decides where the annotation survives: SOURCE (compiler only — @Override, @SuppressWarnings), CLASS (in the .class file but invisible at run time — the default, rarely useful), RUNTIME (readable by reflection — every framework annotation). Target restricts what it can annotate. Elements are methods with optional defaults; values must be constants, enums, classes, other annotations, or arrays of those.

Annotations do nothing by themselves. @RateLimited on a method has no effect until something reads it. That "something" is either a compile-time annotation processor (Lombok, MapStruct, Dagger — generating code from annotations) or run-time reflection (Spring, Hibernate, Jackson, JUnit).

Reflection

java.lang.reflect lets code inspect and manipulate classes at run time:

java
Class<?> type = Class.forName("com.shop.OrderService");
for (Method m : type.getDeclaredMethods()) {
    RateLimited rl = m.getAnnotation(RateLimited.class);
    if (rl != null) registry.limit(m, rl.permitsPerSecond());
}
 
Constructor<?> ctor = type.getDeclaredConstructor(OrderRepository.class);
Object instance = ctor.newInstance(repository);
 
Field f = type.getDeclaredField("repository");
f.setAccessible(true);                       // bypass private — this is what field injection does
f.set(instance, repository);
 
Method save = type.getMethod("save", Order.class);
save.invoke(instance, order);

getDeclared* returns members declared on that class (including private); get* returns public members including inherited ones. setAccessible(true) is what lets frameworks touch private fields — and since Java 16 it fails for JDK internals unless --add-opens is given, which is why old libraries broke on that release.

What Spring does with it

At startup, Spring scans the classpath for classes, reads their annotations, and builds a graph of bean definitions. For each bean it picks a constructor by reflection, resolves the parameters from the graph, and instantiates it. Then it looks for @Transactional, @Cacheable, @Async, @PreAuthorize and wraps those beans in proxies — generated subclasses (CGLIB) or interface implementations (JDK Proxy) that intercept calls and run the cross-cutting behaviour around them.

The proxy is where two famous surprises come from:

  • Self-invocation. A @Transactional method calling another @Transactional method on this bypasses the proxy, so the second annotation is ignored. The call never goes through the wrapper.
  • final and private. CGLIB subclasses your class and overrides its methods; it cannot override final or private ones, so annotations on them do nothing.

Jackson does the same kind of work for JSON: reads the fields and getters, honours @JsonProperty and @JsonIgnore, calls a constructor. Hibernate reads @Entity and @Column to build SQL. JUnit finds @Test.

The cost

Reflection is slower than a direct call — a Method.invoke goes through access checks and argument boxing, though the JIT inlines hot reflective calls after a while — and, more significantly, it is slow at startup. Scanning thousands of classes, reading every annotation, generating proxies: this is the bulk of a Spring Boot application's start time. It also defeats ahead-of-time optimisation, because the set of classes used is not known until the reflection runs. Spring AOT and GraalVM native image exist to move that work to build time, and they need hints about what will be reflected on — the price of having been dynamic.

Reflection also bypasses the type system. A method found by name is a string that the compiler does not check; a renamed method breaks it at run time. Frameworks accept that cost; application code should not — if you are writing getDeclaredMethod("...") in a service, ask what design would avoid it.

Dynamic proxies, briefly

java
PaymentGateway logged = (PaymentGateway) Proxy.newProxyInstance(
        loader, new Class<?>[]{PaymentGateway.class},
        (proxy, method, args) -> {
            log.info("calling {}", method.getName());
            return method.invoke(real, args);
        });

A JDK proxy implements interfaces; every call arrives at one InvocationHandler. This is the mechanism behind Spring Data repositories (an interface with no implementation), Feign clients, and Mockito mocks. Fifteen lines, and it explains three libraries.

Progress is saved on this device and to your account when signed in.