W3docs

Java Annotation Processing

Process Java annotations at compile time with the javax.annotation.processing API to generate code or validate sources.

Java Annotation Processing

Annotation processing is a plugin point in javac. You write a class — an annotation processor — that the compiler calls during compilation, hands the elements it has seen so far, and waits on. The processor can do two useful things: validate the annotated code (emit errors or warnings through javac's diagnostic channel) or write new source files that participate in the same compilation.

Frameworks that you've probably already used are powered by this mechanism:

  • Lombok rewrites annotated classes to add getters, builders, and equals/hashCode.
  • Dagger / Hilt generate dependency-injection wiring in response to @Inject and @Module.
  • Hibernate's static metamodel generates Entity_ classes for type-safe Criteria queries.
  • Auto-Service / Auto-Value generate boilerplate META-INF service entries and value classes.
  • Micronaut / Quarkus generate framework wiring at build time instead of at startup.

The processor API lives in javax.annotation.processing and the language model lives in javax.lang.model. Together they let javac host third-party compile-time tools.

The shape of a processor

A processor implements javax.annotation.processing.Processor. In practice you extend AbstractProcessor and override process(...):

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;

@SupportedAnnotationTypes("com.example.Marker")           // which annotations to handle
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MarkerProcessor extends AbstractProcessor {

  @Override
  public boolean process(Set<? extends TypeElement> annotations,
                         RoundEnvironment roundEnv) {
    for (Element e : roundEnv.getElementsAnnotatedWith(Marker.class)) {
      processingEnv.getMessager().printMessage(
        javax.tools.Diagnostic.Kind.NOTE,
        "found @Marker on " + e.getSimpleName(),
        e);
    }
    return true;                                          // claim the annotation
  }
}

The two annotations on the class declare which annotation types this processor wants to handle and which language level it targets. Both can also be returned dynamically from getSupportedAnnotationTypes() / getSupportedSourceVersion() if you need to compute them.

process is called per round. Each round is one pass through the sources; if your processor produces new files, those new files are themselves processed in a subsequent round. The loop terminates when no round produces new files.

The language model: not reflection

The first surprise: inside a processor you don't have Class<?>. The classes you're processing haven't been compiled yet. Instead you work with the javax.lang.model.element types:

  • Element — anything in source: a class, method, field, parameter, package.
  • TypeElement — a class, interface, or enum (an Element you can ask for getQualifiedName()).
  • ExecutableElement — a method or constructor.
  • VariableElement — a field, parameter, or local variable.
  • TypeMirror — a type (as in "the type List<String>"), distinct from the element that declared it.

These mirror the runtime reflection types but represent source, not loaded classes. You can walk them, ask their annotations, ask their enclosing scope. You cannot call methods on them, evaluate constant expressions arbitrarily, or instantiate them — there's no instance yet.

To read an annotation's element values, you use Element.getAnnotation(MyAnn.class) (returns a proxy, similar to reflection) or Element.getAnnotationMirrors() (returns the structural form, which is what you need when the element value contains a Class reference to a type that's also being compiled in this same round).

Registering the processor

The compiler needs to find your processor. There are two ways:

  1. Service-loader file. Put a file named META-INF/services/javax.annotation.processing.Processor on the processor's classpath whose content is the fully qualified processor class name, one per line. This is what tools like Google's auto-service generate automatically.
  2. -processor flag. Pass -processor com.example.MarkerProcessor to javac (or configure it in your build tool — Gradle's annotationProcessor configuration, Maven's <annotationProcessorPaths>).

In Maven and Gradle the convention is to keep the processor in its own module and depend on it from your main module with annotationProcessor (Gradle) / <scope>provided</scope> (Maven). The processor only runs during compilation and isn't shipped to runtime.

Generating files

Two outputs are possible:

  • Source files — written via processingEnv.getFiler().createSourceFile(name). The result is a JavaFileObject whose openWriter() you fill with source code. The new file is compiled in the next round.
  • Resource files — written via getFiler().createResource(...) for anything that ends up on the classpath at runtime (e.g. service registrations).

The pattern is to derive the new class's package and name from the annotated element, then template the source as a String:

TypeElement cls = ...;                                     // the annotated class
String pkg = elementUtils.getPackageOf(cls).getQualifiedName().toString();
String genName = cls.getSimpleName() + "Generated";

JavaFileObject src = filer.createSourceFile(pkg + "." + genName, cls);
try (Writer w = src.openWriter()) {
  w.write("package " + pkg + ";\n");
  w.write("public class " + genName + " {\n");
  w.write("  public static String origin() { return \"" + cls.getSimpleName() + "\"; }\n");
  w.write("}\n");
}

A real processor typically uses a code generator like JavaPoet (which exposes a typed AST builder) instead of string concatenation. The mechanics are identical; JavaPoet just makes the source readable.

Errors, warnings, notes

A processor reports diagnostics through Messager:

processingEnv.getMessager().printMessage(
    Diagnostic.Kind.ERROR,
    "@Marker may only annotate top-level classes",
    element);

Kind.ERROR fails the build at that element's source position. WARNING, MANDATORY_WARNING, and NOTE are the lower levels. Always pass the Element argument when you can — it gives the user a clickable source location instead of a build-log blob.

Incremental compilation considerations

Annotation processors are a known cause of build slowdowns. Two reasons:

  • They can be non-incremental: if the processor isn't told which sources to re-process, the build tool reprocesses everything when any source changes.
  • They can block parallelism: rounds are sequential.

Gradle introduced isolating and aggregating processor categories to let processors participate in incremental compilation. A processor that produces one generated file per annotated source (Dagger does this for @Component) can declare itself "isolating" and Gradle re-runs it only for the changed sources. Aggregating processors — those that look across all annotated elements to produce a single registry file — re-run when any annotated source changes. Choose the processor's category honestly; the tradeoff is correctness vs. speed.

A worked example: a runtime stand-in for compile-time processing

Real annotation processing requires a multi-module build, the javac plugin point, and a service file — none of which fits in a single program. The next-best demonstration is a runtime stand-in that does the same shape of work: walk annotated classes, validate them, and write source files to a temporary directory as a compile-time processor would.

java— editable, runs on the server

What to take from the run:

  • The processor walked three classes and acted on two — exactly the shape of RoundEnvironment.getElementsAnnotatedWith(Generate.class) in a real javac processor. The third class was silently skipped because its annotation wasn't present. That's the model: a processor consumes a set of elements per round and does work only for those it cares about.
  • Each generated file carried the source class's package and a derived name. In javax.lang.model you compute the package from elementUtils.getPackageOf(typeElement).getQualifiedName() and the name from typeElement.getSimpleName(); here we used Class.getPackageName() and Class.getSimpleName() as the analogue. The shape transfers.
  • The suffix element gave per-use customisation: Account produced AccountGenerated, Invoice produced InvoiceHelper. Annotation elements are the knob you offer the user; defaults make the common case terse and named elements give precise control when needed.
  • The simulated validation printed an ERROR: line for abstract classes. In a real processor this would be messager.printMessage(Diagnostic.Kind.ERROR, "...", element) and the build would fail at the user's source location. Diagnostics are a first-class feature, not a fallback — use them whenever the annotation is misused, never throw.
  • The generated source contains nothing tricky — a List.of(...) of field names and an origin() helper. That's typical. The value of compile-time generation is rarely the cleverness of the output; it's that the output exists at all, before the program runs, where the runtime would otherwise need reflection (and pay its cost).

When to reach for a processor

A processor pays for itself when:

  • You'd otherwise be writing the same boilerplate by hand for every annotated class.
  • The work can be done from source signatures alone (no actual instance behaviour needed).
  • The runtime alternative would use reflection on every call, and that cost adds up.

A processor is the wrong tool when:

  • You want to modify an existing class. Standard processors can only add new source files; they don't rewrite the annotated class. (Lombok rewrites by hooking into javac's internal AST, which is unofficial and fragile.)
  • The metadata you need only exists at runtime (request scope, user identity, configuration loaded from disk).
  • A simple reflective lookup on startup would do the same job in 50 lines.

The decision is the same one as for any code generation: more compile-time work, less runtime work, and a build that's harder to debug. Trade carefully.

End of part 16

This concludes the Annotations part of the book. We've covered what an annotation is — pure metadata, distinct from code that runs — then the small set the standard library provides, the five meta-annotations that configure your own, the recipe for declaring a custom annotation, and finally the compile-time processing API that frameworks use to act on annotations during the build.

The mental model to carry forward: an annotation never does anything. Something else reads it and chooses to act. That "something else" is either the compiler (built-in lints), an annotation processor (compile-time code generation), or your own code via reflection (runtime frameworks). When an annotation isn't behaving as expected, the first question is always: who is supposed to be reading it?

The next part of the book is Reflection — the runtime side of the API you've already started using to read annotations.

Practice

Practice

An annotation processor generates a 'Module' source file aggregating every class annotated with `@Service`. Builds are slow because every edit to any source triggers a full reprocess. What's the most appropriate Gradle classification?