Keyboard Shortcuts N Next post
P Previous post
S Save / unsave
R Read aloud
T Toggle theme
/ Focus search
Esc Close panels
🔥
Ready to read...
Interview Questions Java Java and Cryptography Interview Series Java Programming Java Tutorial Module 27 - Java Advanced and JVM Internals

Java Annotation Processing at Compile Time with javax.annotation.processing

Reviewed & accurate
AI Summary

Introduction

The Java annotation processing API shipped in Java 6 in December 2006 as JSR 269, designed by Joseph D. Darcy and the JSR 269 expert group. It replaced the earlier com.sun.mirror.apt.* API from Java 5 (September 2004) whose standalone apt tool was eventually removed in Java 7 (July 2011). Annotation processing lets you hook into javac itself: while the compiler is running, it hands your Processor every source element annotated with the types you registered for, and you can use a Filer to write new Java source files that the compiler then includes in the same compilation. For a Java learner, Java annotation processing is the bridge between plain annotations like @Override and heavyweight compile-time frameworks like Lombok, MapStruct, Dagger, and Google AutoValue.

Understanding Java annotation processing also explains how modern Java libraries reduce boilerplate without runtime reflection. Every @Mapper interface in MapStruct, every @AutoValue class, every Dagger @Component, and every Lombok @Data generates source code at compile time via the javax.annotation.processing.Processor interface. Interviewers ask about annotation processing because it sits at the intersection of compilation, reflection, and code generation, and a senior Java engineer is expected to know the difference between a runtime annotation and a source-level annotation processor.

What is Java Annotation Processing?

Java annotation processing is a compile-time hook into the javac compiler. The javax.annotation.processing package (in the java.compiler module) defines a Processor interface that the compiler invokes once per round. Each round, the compiler gives the processor a RoundEnvironment that contains every TypeElement, VariableElement, ExecutableElement, and PackageElement annotated with one of the @SupportedAnnotationTypes the processor declared. The processor uses a Filer to create new Java source files, a Messager to emit compiler errors and warnings, and the Elements and Types utility classes to inspect the annotated program elements.

Annotation processing is categorised as compile-time metaprogramming, in contrast to runtime reflection (java.lang.reflect) which inspects classes after they are loaded. The two key differences are: (1) annotation processing sees the program as it is being compiled, so it can generate new types that participate in the same compilation, and (2) it operates on javax.lang.model.element elements (source-level) instead of java.lang.Class objects (runtime-level). A processor runs in rounds: round 1 sees the original source files, round N sees the source files generated in round N-1, and a final round with processingOver() true lets the processor clean up.

How Java Annotation Processing Works - Step by Step

Walk through the lifecycle of a single annotation processor. Suppose you write @AutoToString on a class Person and want the processor to generate a PersonToStringSupport class with a static method that returns a String representation of every field. Here is exactly what happens when you run javac on Person.java.

Step 1: javac starts compiling Person.java. Before generating any bytecode, it asks the ServiceLoader for any Processor implementations on the classpath (or annotation processors explicitly registered with the -processor flag).

Step 2: For each processor discovered, javac calls the init(SavedEnv) method (AbstractProcessor.init) which receives a ProcessingEnvironment. The environment exposes filer, messager, elements, types, and source version helpers.

Step 3: javac asks the processor which annotation types it supports by calling getSupportedAnnotationTypes(). A processor can also use @SupportedAnnotationTypes("*") to receive every annotation, or @SupportedAnnotationTypes("com.example.AutoToString") to filter.

Step 4: javac calls process(annotations, roundEnv). Inside process, the processor calls roundEnv.getElementsAnnotatedWith(AutoToString.class) to receive a Set<? extends Element> containing every class annotated with @AutoToString. The processor casts each to TypeElement, iterates its enclosedElements to find fields (ElementKind.FIELD), and builds a String template.

Step 5: The processor writes a new source file via filer.createSourceFile("com.example.PersonToStringSupport"). It returns true to claim the annotations so that no other processor also handles them, or false to let other processors see them.

Step 6: javac compiles the generated PersonToStringSupport.java file as part of the same compilation, possibly triggering further rounds if it is itself annotated. Once no new source files are generated, javac calls process one more time with roundEnv.processingOver() returning true so the processor can release resources.

Java Program for Java Annotation Processing

package com.example.annotationprocessing;

import java.util.Set;
import java.util.LinkedHashSet;
import java.util.List;
import java.io.IOException;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.VariableElement;
import javax.lang.model.util.Elements;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;

@SupportedAnnotationTypes("com.example.annotationprocessing.AutoToString")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class AutoToStringProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                           RoundEnvironment roundEnv) {
        if (roundEnv.processingOver()) {
            return false;
        }
        for (Element e : roundEnv.getElementsAnnotatedWith(AutoToString.class)) {
            if (!(e instanceof TypeElement)) {
                continue;
            }
            TypeElement type = (TypeElement) e;
            String packageName = processingEnv.getElementUtils()
                    .getPackageOf(type).getQualifiedName().toString();
            String simpleName = type.getSimpleName().toString();
            String helperName = simpleName + "ToStringSupport";
            String qualifiedHelper = packageName.isEmpty()
                    ? helperName
                    : packageName + "." + helperName;
            List<? extends Element> members = type.getEnclosedElements();
            LinkedHashSet<String> fieldNames = new LinkedHashSet<>();
            for (Element m : members) {
                if (m.getKind() == ElementKind.FIELD) {
                    fieldNames.add(m.getSimpleName().toString());
                }
            }
            try {
                JavaFileObject jfo = processingEnv.getFiler()
                        .createSourceFile(qualifiedHelper);
                StringBuilder sb = new StringBuilder();
                sb.append("package ").append(packageName).append(";\n\n");
                sb.append("public final class ").append(helperName).append(" {\n");
                sb.append("    public static String render(Object target) {\n");
                sb.append("        ").append(simpleName).append(" o = (")
                  .append(simpleName).append(") target;\n");
                sb.append("        StringBuilder sb = new StringBuilder();\n");
                sb.append("        sb.append(\"").append(simpleName)
                  .append("{\");\n");
                boolean first = true;
                for (String fn : fieldNames) {
                    sb.append("        ");
                    if (first) {
                        sb.append("first = false;\n");
                        first = false;
                    } else {
                        sb.append("sb.append(\", \");\n");
                    }
                    sb.append("        sb.append(\"").append(fn)
                      .append("=\").").append(".append(o.").append(fn)
                      .append(");\n");
                }
                sb.append("        sb.append(\"}\");\n");
                sb.append("        return sb.toString();\n");
                sb.append("    }\n");
                sb.append("}\n");
                jfo.openWriter().append(sb.toString()).close();
            } catch (IOException ioe) {
                processingEnv.getMessager().printMessage(
                        Diagnostic.Kind.ERROR,
                        "Failed to generate " + qualifiedHelper
                                + ": " + ioe.getMessage());
            }
        }
        return true;
    }
}

Code Explanation - Line by Line

The class extends javax.annotation.processing.AbstractProcessor, the convenience base class that implements Processor and provides the protected processingEnv field. The @SupportedAnnotationTypes annotation tells javac that this processor only cares about @AutoToString; javac will not invoke process() in a round where no such annotation exists. The @SupportedSourceVersion(SourceVersion.RELEASE_17) annotation declares the latest source version the processor understands; emitting files for newer source versions would print a warning.

Inside process, the first check is roundEnv.processingOver(). When true, the round is the final one and no new source files may be written, so the processor returns false. The loop iterates every element annotated with @AutoToString; for each TypeElement it queries the Elements utility for the enclosing package and then walks the enclosedElements list to collect fields. The Filer.createSourceFile call creates a JavaFileObject whose writer receives the generated source. The Messager.printMessage call is the only way to report compiler errors that point at the original source location of the offending annotation.

The return value of process matters: returning true claims the annotations so no other processor sees them in this round, returning false lets other processors try. The generated PersonToStringSupport class is compiled by javac immediately, and if any element of the generated file is itself annotated with @AutoToString, a new round begins. This is why the processingOver guard is critical: writing files in the final round throws FilerException.

Interview Questions and Answers

Q1. What is the difference between a runtime annotation and a source annotation in Java?

A source annotation has RetentionPolicy.SOURCE and is discarded by the compiler after processing, so it never reaches the .class file. A runtime annotation has RetentionPolicy.RUNTIME and is visible via reflection at runtime. Annotation processors see SOURCE and CLASS retention annotations, while reflection only sees RUNTIME ones; this is why @Override (SOURCE) can be processed by a compiler but not by reflection.

Q2. Why does AbstractProcessor have both getSupportedAnnotationTypes and the @SupportedAnnotationTypes annotation?

They are two equivalent ways to declare the same metadata: the @SupportedAnnotationTypes annotation is read by AbstractProcessor.getSupportedAnnotationTypes() which reflects on the runtime class, and overriding the method directly is the alternative. The annotation form is shorter and the programmatic form is more flexible (you can compute the set dynamically from a config file), so the annotation is the typical choice and the override is for advanced use cases.

Q3. What happens when a processor generates a new source file that itself has an annotation another processor handles?

javac starts a new round: it compiles the generated file, discovers the annotation, and invokes the other processor with a fresh RoundEnvironment. Rounds continue until a round produces no new files, at which point javac calls process() one last time with processingOver() returning true. This loop is what lets annotation processors compose and what makes MapStruct able to consume AutoValue output, for example.

Q4. What is the difference between javax.lang.model.element.TypeElement and java.lang.Class?

TypeElement is a source-level model element that exists only during compilation; it represents a class or interface that the compiler is currently compiling and it may not yet have a .class file. java.lang.Class is a runtime reflective object that exists only after the class loader has loaded the bytecode. Processors must use Elements and Types utility classes instead of reflection because the types they inspect are not yet loadable.

Q5. Why must you return true from process() to claim an annotation?

Returning true tells javac that this processor has handled the annotation and no other processor should be asked about it in this round. Returning false lets other registered processors also process the same annotation, which is useful when multiple processors cooperate but can cause duplicate source-file writes if two processors generate the same qualified name. The claim is per-annotation-set, not per-element, so a processor that handles @AutoToString and @AutoEquals must claim each independently.

Q6. How does Lombok generate getters and setters without writing a separate source file?

Lombok does not use the public Filer API; instead it uses internal javac and eclipse compiler hooks (com.sun.tools.javac.code.Symbol, com.sun.tools.javac.tree.JCTree) to mutate the abstract syntax tree in place. This is why Lombok is technically not a standard annotation processor and why it can break between JDK versions: it relies on unsupported internal compiler APIs that the JSR 269 specification does not require javac to expose.

Q7. You are designing a compile-time @FreeBuilder annotation that generates an immutable builder for a class. Walk through the failure modes when the annotated class has a field whose type is itself an @FreeBuilder-annotated class.

The first failure mode is ordering: in round 1 the processor sees the outer class but the inner class may not yet be compilable, so calling Types.erasure on the field type can throw. The second is recursion: the generated builder for the outer class references the inner class builder, which is generated in round 2, so the outer class file must be regenerated once the inner class file exists. The third is filer immutability: a generated source file cannot be overwritten in a later round, so the processor must defer all generation until all annotated types are known or use a single combined builder file. Senior engineers solve this by gathering elements across rounds in an internal map and only emitting files in the processingOver() round.

Sample Input and Output

Input:
$ cat Person.java
package com.example;
@AutoToString
public class Person {
    String name;
    int age;
}

Command:
$ javac -processor com.example.annotationprocessing.AutoToStringProcessor Person.java

Output file generated at compile time:
$ cat com/example/PersonToStringSupport.java
package com.example;

public final class PersonToStringSupport {
    public static String render(Object target) {
        Person o = (Person) target;
        StringBuilder sb = new StringBuilder();
        sb.append("Person{");
        first = false;
        sb.append("name=").append(o.name);
        sb.append(", ");
        sb.append("age=").append(o.age);
        sb.append("}");
        return sb.toString();
    }
}

The output shows that javac invoked the AutoToStringProcessor during the compilation of Person.java. The processor used the Filer to write PersonToStringSupport.java into the same compilation, and that file was then compiled by javac into PersonToStringSupport.class, ready for use by the rest of the application.

Common Mistakes to Avoid

Forgetting to check roundEnv.processingOver() is the most common mistake: calling filer.createSourceFile in the final round throws a FilerException because no further rounds can occur. Always guard the create-source calls with if (!roundEnv.processingOver()) and use the final round only for cleanup or statistics.

Using @SupportedSourceVersion(SourceVersion.latest()) instead of an explicit version is a portability trap: when your processor is run on a newer JDK than the one it was compiled against, latest() returns a constant the old runtime does not know, throwing NoSuchMethodError. Always pin to the latest version you actually support, or omit the annotation and override getSupportedSourceVersion to return SourceVersion.RELEASE_17 explicitly.

Returning true unconditionally from process() is wrong: you must return true only for annotations you actually claim, not for every round. The correct convention is to return true if and only if you handled at least one of the requested annotation types in this round. Returning true in the final round when nothing was handled prevents other processors from running.

Finally, calling Class.forName on annotated elements at processing time never works because the types are not yet on the classpath. Use Elements.getTypeElement and Types utilities to inspect types; never mix reflection with annotation processing.

Real-World Applications

The most visible application of Java annotation processing is Lombok, which uses internal javac tree mutation to add getters, setters, equals, hashCode, and toString methods at compile time. Google AutoValue (AutoValue generated immutable value classes since 2015) and Google AutoService (which generates the META-INF/services/javax.annotation.processing.Processor file so that javac discovers your processor without manual registration) are textbook examples of standards-compliant processors.

MapStruct uses annotation processing to generate type-safe bean mappers between DTO and entity classes at compile time; the generated mappers are plain Java, debuggable, and zero-overhead versus reflection-based mappers. Dagger 2 generates dependency injection code without the runtime cost of reflection-based DI containers like Spring's classic @Autowired field injection. Immutables, Hibernate's JPA metamodel generator (used to type-check JPQL queries with the static metamodel), and Spring's @ConfigurationProperties metadata generator all use the same javax.annotation.processing.Processor interface.

Beyond libraries, build-time code generation for protocol buffers (the protobuf Java plugin generates Java message classes from .proto files) and gRPC stubs use annotation-processor-style generation. In each case the compile-time check is the value: typos in field names are caught by javac instead of by tests.

Conclusion

Java annotation processing is the canonical way to generate source code at compile time using the javax.annotation.processing API. By extending AbstractProcessor, declaring @SupportedAnnotationTypes, and using Filer and Messager inside process(), you can synthesise new Java files that javac compiles in the same run, which is the foundation of Lombok, MapStruct, Dagger, AutoValue, and the JPA static metamodel. Study the Elements and Types utility classes next, then move on to javax.lang.model.element so you can build processors that compose across multiple rounds. Finally, learn how to register your processor via META-INF/services/javax.annotation.processing.Processor so that any javac invocation on the classpath discovers it automatically.

Test Your Knowledge
How did you find this?

Comments

Join the discussion! Sign in with your Google or Blogger account, or comment as Anonymous - no account needed. For quick questions, also reach me on Telegram @cytestch.

Comments