Annotation Processing
Annotation processing lets code run during compilation itself, inspecting annotated source and generating new source files before javac finishes its job.
Annotation processing is a compile-time mechanism: javac invokes registered annotation processors as part of compilation, handing them the set of annotations present in the source being compiled. A processor can inspect that code (via a stable, compiler-neutral model of the program, not full reflection) and generate new source files, which javac then compiles in the same run. This is fundamentally different from reflection-based annotation handling, which happens at run time against already-compiled classes — annotation processing happens before a program ever exists as bytecode, and is how tools like Lombok, Dagger, MapStruct, and JPA metamodel generators eliminate hand-written boilerplate.
Compile-Time Processing vs. Runtime Reflection
Two very different mechanisms both consume annotations. Reflection-based frameworks (Spring, JUnit) read RUNTIME-retained annotations from already-loaded classes while the program executes, using java.lang.reflect. Annotation processing runs inside javac itself, during compilation, and works from source-level Element/TypeMirror models rather than loaded Class objects — the classes being compiled are not even loadable yet.
An annotation processor cannot modify the AST of existing source files. It can only generate brand-new source (or other) files, which are then fed back into another round of compilation. This is a deliberate constraint of the API.
Why Compile-Time Processing Matters
Doing code generation at compile time rather than run time has two big advantages: zero run-time reflection cost (the generated code is ordinary compiled Java, as fast as anything you'd write by hand), and errors surface immediately as compiler diagnostics rather than as run-time exceptions discovered later, often in production. The trade-off is build complexity — generated code, a build step that must run the processor, and sometimes harder-to-read stack traces referencing generated classes.
The Processor SPI: javax.annotation.processing
A processor implements the Processor interface, almost always by extending the convenience base class AbstractProcessor. The core method to override is process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv), called once per 'round' of compilation.
@SupportedAnnotationTypes and @SupportedSourceVersion (or overriding getSupportedAnnotationTypes()/getSupportedSourceVersion()) tell javac which annotations this processor cares about and which language level it understands. RoundEnvironment.getElementsAnnotatedWith gives every program element carrying that annotation in the current round.
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;
@SupportedAnnotationTypes("com.example.GenerateToString")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class ToStringProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (Element element : roundEnv.getElementsAnnotatedWith(GenerateToString.class)) {
if (element.getKind() != ElementKind.CLASS) continue;
TypeElement type = (TypeElement) element;
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE, "Generating toString for " + type.getSimpleName());
// real implementation would write a new source file here
}
return true; // claim these annotations so no later processor also handles them
}
}The Element and TypeMirror Model
Processors never see java.lang.Class or java.lang.reflect.Method — those require loaded bytecode, which doesn't exist yet mid-compilation. Instead, the javax.lang.model API represents the program as a tree of Element (PackageElement, TypeElement, ExecutableElement, VariableElement, ...) and types as TypeMirror. This mirrors the source structure abstractly and works identically whether the source came from a .java file or was itself generated in an earlier round.
processingEnv, available inside AbstractProcessor, exposes the supporting services: Filer (to write new files), Messager (to report errors/warnings tied to specific elements), Elements and Types (utility queries over the model).
Generating Source with Filer
New source files are written through processingEnv.getFiler(), typically using JavaPoet or hand-built strings with a JavaFileObject.
Filer.createSourceFile returns a JavaFileObject javac will compile as part of a subsequent round. Attempting to create the same file twice in one compilation is an error — generated output must be deterministic per compilation.
import javax.annotation.processing.Filer;
import javax.tools.JavaFileObject;
import java.io.Writer;
void generateClass(String packageName, String className) throws Exception {
Filer filer = processingEnv.getFiler();
JavaFileObject file = filer.createSourceFile(packageName + "." + className);
try (Writer writer = file.openWriter()) {
writer.write("package " + packageName + ";\n");
writer.write("public class " + className + " {\n");
writer.write(" public static final String GENERATED = \"yes\";\n");
writer.write("}\n");
}
}Rounds of Processing
Compilation with annotation processing proceeds in rounds. Round 1 processes the original source files; if any processor generates new source in that round, javac compiles the generated files in round 2, giving processors another chance to react to them (including to their own or other processors' output); this repeats until a round produces no new files, then a final round runs with RoundEnvironment.processingOver() true, used for final validation without generating anything new.
original.java source source generated in Round 1 no new files generated; processingOver()==true
Each round can trigger another if it generates new source; processing converges when a round produces nothing new.
Registering a Processor
javac discovers processors via the ServiceLoader mechanism: a file named META-INF/services/javax.annotation.processing.Processor inside the processor's JAR, listing the fully qualified processor class name(s), one per line. Modern build tools (Maven's annotationProcessorPaths, Gradle's annotationProcessor configuration) put the processor JAR on a separate classpath used only during compilation, keeping it out of the runtime dependency graph.
The @AutoService annotation (from Google's auto-service library) is itself an annotation processor that generates this META-INF/services file automatically — a common bootstrapping trick, since hand-maintaining service files is easy to get wrong.
# META-INF/services/javax.annotation.processing.Processor
com.example.ToStringProcessorReporting Errors from a Processor
A processor should never throw an uncaught exception to signal a user mistake — that produces a confusing internal compiler error. Instead, use Messager to attach a diagnostic to the exact offending element, which javac then reports with correct file/line information, just like a normal compiler error.
Diagnostic.Kind.ERROR fails the build with a message pointing at the annotated element's source location, exactly like a native compiler error would.
import javax.tools.Diagnostic;
if (!element.getModifiers().contains(javax.lang.model.element.Modifier.PUBLIC)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@GenerateToString can only be applied to public classes",
element);
}Common Use Cases
Lombok generates getters/setters/constructors/builders (through a more invasive, unofficial compiler-tree-mutation technique, not pure standard processing). Dagger and other dependency-injection frameworks generate factory and component classes for @Inject-annotated code, avoiding reflection entirely at run time. MapStruct generates type-mapping implementation classes from annotated mapper interfaces. JPA metamodel generators (Hibernate, EclipseLink) produce typed *_ metamodel classes for criteria queries. AutoValue generates immutable value-class implementations from an abstract skeleton.
Annotation Processing vs. Reflection-Based Frameworks
Spring's classic component scanning uses reflection at application startup to find @Component classes — flexible, but with real startup-time cost, which is why frameworks aiming at fast-startup environments (Micronaut, Quarkus) instead use compile-time annotation processing to precompute the equivalent wiring, trading some build-time complexity for much faster, more predictable start-up and no reflective overhead at run time.
Limitations
Processors cannot modify existing source files, cannot remove annotations from the source they see, and cannot easily execute application logic (they run inside the compiler's own JVM instance during the build, not the target program). Debugging a processor typically means attaching a debugger to the javac process itself or using extensive Messager NOTE-level logging.
Key Takeaways
Annotation processing runs inside javac during compilation, working from source-level Element/TypeMirror models, not loaded classes — it is unrelated to run-time reflection.
A processor extends AbstractProcessor, declares which annotations and source version it supports, and implements process() to react each round.
Processors generate new source files via Filer; they cannot edit files that already exist.
Compilation proceeds in rounds until no new source is generated, then a final validation-only round runs.
Processors are discovered via ServiceLoader through a META-INF/services entry, and use Messager to report errors tied to exact source locations.
Expecting an annotation processor to modify or rewrite an existing class's source code.
Design the processor to generate a new, separate source file (e.g. a companion Builder or Impl class) instead.
The standard annotation processing API is generate-only; it never mutates the AST of files already being compiled.
Throwing a RuntimeException from process() to report a misuse of the annotation.
Call processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, message, element) instead.
An uncaught exception surfaces as an opaque internal compiler error; Messager produces a normal, well-located compiler diagnostic.
Forgetting to register the processor class in META-INF/services/javax.annotation.processing.Processor.
Add the fully qualified processor class name to that service file (or use @AutoService to generate it).
Without registration, javac never discovers or invokes the processor, and the annotations are simply ignored with no generated output and no error.
How does annotation processing differ fundamentally from reflection-based annotation handling?
Annotation processing runs inside the compiler during compilation, working on a source-level Element/TypeMirror model of code that isn't yet bytecode; reflection reads RUNTIME-retained annotations from already-compiled, loaded classes while a program executes.
Can an annotation processor modify the source file it is processing?
No. It can only generate new source files; existing files are never rewritten by the standard processing API.
What triggers a new round of annotation processing?
A processor generating at least one new source file in the current round; javac then compiles that generated source in the next round, giving processors another opportunity to react.
How does javac discover which annotation processors to run?
Via ServiceLoader, using a META-INF/services/javax.annotation.processing.Processor file inside the processor's JAR that lists fully qualified processor class names.
Why do frameworks like Dagger prefer compile-time annotation processing over runtime reflection for dependency injection?
It generates ordinary compiled Java code ahead of time, so there is zero reflective overhead and no run-time discovery cost at application startup, and configuration errors surface as compiler errors rather than run-time exceptions.
What is the correct way for a processor to report an invalid use of its annotation?
Use processingEnv.getMessager().printMessage with Diagnostic.Kind.ERROR and the offending Element, which produces a normal compiler diagnostic pointing at the exact source location.
What does RoundEnvironment.processingOver() tell a processor?
That this is the final round — no new source was generated in the prior round, so no further files will be compiled; it's the appropriate time to do final validation without attempting to generate more code.
Write the @SupportedAnnotationTypes/@SupportedSourceVersion declarations and process() skeleton for a processor handling an annotation named com.example.Immutable.
import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import java.util.Set; @SupportedAnnotationTypes("com.example.Immutable") @SupportedSourceVersion(SourceVersion.RELEASE_17) public class ImmutableProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends javax.lang.model.element.TypeElement> annotations, RoundEnvironment roundEnv) { for (var element : roundEnv.getElementsAnnotatedWith( com.example.Immutable.class)) { // inspect and optionally generate a builder/validator here } return true; } }
The two class-level annotations declare which annotation the processor claims and which language level it targets; process() is called once per round with every element annotated @Immutable found via RoundEnvironment.
What happens on the second createSourceFile call for the same fully qualified name?
It throws a FilerException — a given source file may only be created once per compilation. This is why processors must guard against re-generating the same file across rounds (e.g. by tracking what they've already produced).
Write a JAR service-registration file that registers com.example.ImmutableProcessor as an annotation processor.
javac's ServiceLoader-based discovery reads this exact resource path; each line is a fully qualified Processor implementation class that will be instantiated and invoked during compilation when its JAR is on the annotation processor path.
// processor code:
Filer filer = processingEnv.getFiler();
filer.createSourceFile("com.example.Generated");
// ... later in the SAME round, called again on the same input:
filer.createSourceFile("com.example.Generated");