Annotations and reflection

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

6 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).

Meta-annotations, and annotations made of annotations

@Retention and @Target are meta-annotations: annotations on an annotation. There are three more from the JDK worth knowing. @Documented puts it in the Javadoc. @Inherited makes a class-level annotation visible on subclasses — and only class-level, only through extends, never through an interface, which is why @Transactional on an interface does nothing for the implementing class. @Repeatable allows the same annotation twice on one element (@Scheduled twice on one method), by having the compiler wrap them in a container annotation you also declare.

The trick frameworks build on this is the composed annotation: an annotation annotated with the ones it stands for. @RestController is @Controller plus @ResponseBody; @SpringBootApplication is @Configuration, @EnableAutoConfiguration and @ComponentScan; your own @Transactional @Service @Validated triple can become one @DomainService. The JDK's reflection does not see through this — getAnnotation(Controller.class) on a @RestController class returns null — so Spring reads annotations through its own MergedAnnotations, which walks the meta-annotation graph and lets a composed annotation override attributes of the ones underneath (@AliasFor). When "the annotation is there but Spring ignores it", the usual reasons are retention, @Inherited through an interface, or a lookup that used plain reflection instead of Spring's.

Annotation processors

The compile-time route runs inside javac: a processor implements javax.annotation.processing.Processor, is discovered through META-INF/services, and is handed the annotated elements of the sources being compiled, as a read-only tree. It cannot change them — the API has no write side — but it can generate new source files, which javac then compiles in the same run. That is how MapStruct writes your mapper implementation, how Dagger writes its factories, how Immutables writes builders, and how Spring's configuration-processor writes the metadata your IDE uses to autocomplete application.properties. Zero cost at run time, full type checking, no reflection: the price is that the generated code is a build artifact you did not write and a compile error inside it is opaque. Lombok is the exception that proves the rule — it does modify the tree, through compiler internals rather than the processor API, which is why every JDK upgrade has a Lombok release beside it.

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; since Java 18 (JEP 416) Method.invoke is itself built on method handles, and MethodHandles.lookup().findVirtual(...) is the faster, checked-once form the JDK and the frameworks use directly — 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.