🪞 Reflection, Annotations & Modules · Advanced

Processing annotations in Java

Runtime reflection vs compile-time annotation processors (Lombok, MapStruct).

🧩 The mysteryYou add @Getter to a class and getters appear, yet there's no getter code in your source and no reflection at runtime. Who wrote them?

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.

🔮 Predict it

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);
}
  1. tests found: 2
  2. tests found: 0
  3. Compile 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

✗ Reflection mapper
mapper.map(order, OrderDto.class);

Mistakes appear at runtime; reflective overhead; native images need reflection config.

✓ Generated (MapStruct)
dto.setId(order.getId());
dto.setTotal(order.getTotal());

Plain Java: fast, type-checked at build time, no reflection.

⚠️ The trap

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.

💼 In the real world

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

  1. Runtime processing needs RUNTIME retention
  2. Processors run inside javac and generate new files
  3. Generated code is type-checked and fast, with no reflection
  4. Since JDK 23, javac runs processors only when configured
🤯 Did you know?

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));
}
  1. false
  2. true
  3. 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?

  1. Reflection-based mappers can't map nested objects
  2. The generated code is plain Java: fast, checked at build time, and needs no reflection config for native images
  3. Compile-time tools work without a build tool
  4. 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.

Frameworks also add behavior around your methods without touching your code. How? Next: dynamic proxies.