Processing annotations in Java
Runtime reflection vs compile-time annotation processors (Lombok, MapStruct).
Annotations do nothing alone
An annotation is data. Some code must read it and act. There are two moments to do that: at runtime, by scanning classes with reflection, or at compile time, inside javac.
Runtime: reflection scanning
JUnit finds @Test methods by reflection while your tests run. That only works for annotations with RUNTIME retention, and scanning many classes costs startup time.
A homemade test runner
What does this print?
@interface Test { }
class T { @Test void a() { } @Test void b() { } }
void main() {
int n = 0;
for (var m : T.class.getDeclaredMethods())
if (m.isAnnotationPresent(Test.class))
n++;
System.out.println("tests found: " + n);
}tests found: 2tests found: 0Compile error
Show the answer
Test has no @Retention, so it defaults to CLASS retention and reflection never sees it. The runner finds zero tests. Fix: add @Retention(RetentionPolicy.RUNTIME).
Compile time: processors
Annotation processors plug into javac. They run in rounds: see the annotated elements, generate new source files, and javac compiles those too. MapStruct generates mapper classes this way. Lombok goes further: it modifies the class being compiled through javac internals, while standard processors may only add new files.
Mapping DTOs
mapper.map(order, OrderDto.class);Mistakes appear at runtime; reflective overhead; native images need reflection config.
dto.setId(order.getId());
dto.setTotal(order.getTotal());Plain Java: fast, type-checked at build time, no reflection.
JDK 23 and the vanishing getters
After moving to JDK 23+, Lombok's getters "disappear" with plain javac. Since JDK 23, javac no longer runs processors it merely finds on the class path. Configure them explicitly: a processor path in your build, or -proc:full.
Who processes what
JUnit reads @Test via reflection at runtime. MapStruct generates code at compile time. Lombok rewrites the class being compiled. And **@Override** is checked by javac itself, then discarded. Knowing which kind you're using tells you where to look when an annotation "does nothing".
Key takeaways
- Runtime processing needs RUNTIME retention
- Processors run inside javac and generate new files
- Generated code is type-checked and fast, with no reflection
- Since JDK 23, javac runs processors only when configured
Lombok enters through the annotation-processor hook and then edits javac's internal syntax trees, which is why it often needs an update when a new JDK comes out.
Practice questions
A homemade test runner looks for @Check methods. What does this print?
@interface Check { }
class Tests {
@Check void a() { }
}
void main() throws Exception {
var m = Tests.class.getDeclaredMethod("a");
System.out.println(
m.isAnnotationPresent(Check.class));
}- false
- true
- Compile error
Check your answer
false. Check has no @Retention, so it defaults to CLASS retention and reflection never sees it. The runner would find zero tests.
Why might a team prefer a compile-time generator like MapStruct over a reflection-based mapper?
- Reflection-based mappers can't map nested objects
- The generated code is plain Java: fast, checked at build time, and needs no reflection config for native images
- Compile-time tools work without a build tool
- It makes the JAR smaller than having no mapper at all
Check your answer
The generated code is plain Java: fast, checked at build time, and needs no reflection config for native images. Generated code is ordinary method calls, so mapping errors fail the build instead of production, and there's no reflective overhead at runtime.