Java Annotation Processing for Compile‑Time Metaprogramming
Category: java
Introduction
Annotation processing has become the backbone of many modern Java libraries, from Lombok’s boilerplate‑removal magic to Dagger’s dependency‑injection graph generation. By executing custom code at compile time, annotation processors let you turn simple metadata into fully fledged source files, validation warnings, or even new DSLs. This post dives into the mechanics of Java annotation processing, shows how to build a custom processor, explores DSL creation with annotations, demonstrates compile‑time code generation, and explains how to hook everything into popular build tools such as Maven and Gradle. By the end you’ll have a practical recipe you can adapt for your own projects.
1. Understanding Annotation Processors
1.1 What is an annotation processor?
An annotation processor is a class that implements javax.annotation.processing.Processor
(or extends javax.annotation.processing.AbstractProcessor
). The compiler (javac
) discovers these processors via the service provider file META-INF/services/javax.annotation.processing.Processor
. During the annotation processing round, the processor receives a RoundEnvironment
that contains all annotated elements and can generate additional source files, resources, or diagnostics.
Analogy: Think of the compiler as a construction crew and annotation processors as the architects that hand over blueprints (generated code) before the crew starts building.
1.2 Key concepts
Concept | Meaning |
---|---|
Round | Each compilation may run several rounds. Processors may generate new source files, which trigger further rounds. |
Supported annotations | Declared via @SupportedAnnotationTypes or getSupportedAnnotationTypes() . |
Processing options | Passed with -Akey=value (e.g., -AmyProcessor.debug=true ). |
Processing mode | -proc:none (skip processing), -proc:only (process only), -proc:full (process and compile). Since JDK 23, at least one processing option must be supplied; otherwise, the compiler defaults to -proc:none (Inside.java, 2024). |
Element & Type mirrors | Represent program elements (Element ) and types (TypeMirror ) in a language‑agnostic model. |
2. Building a Minimal Processor
Below is a “HelloWorld” processor that generates a class GeneratedHello
for every @Hello
annotation.
// src/main/java/com/example/processor/HelloProcessor.java
package com.example.processor;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.JavaFileObject;
import java.io.Writer;
import java.util.Set;
@SupportedAnnotationTypes("com.example.annotation.Hello")
@SupportedSourceVersion(SourceVersion.RELEASE_17) // adjust to your JDK
public class HelloProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
// No more annotations, nothing to do
if (roundEnv.processingOver() || annotations.isEmpty()) {
return false;
}
for (Element element : roundEnv.getElementsAnnotatedWith(
processingEnv.getElementUtils()
.getTypeElement("com.example.annotation.Hello"))) {
// Derive a class name based on the annotated element
String className = element.getSimpleName() + "GeneratedHello";
String packageName = processingEnv.getElementUtils()
.getPackageOf(element).getQualifiedName().toString();
try {
JavaFileObject file = processingEnv.getFiler()
.createSourceFile(packageName + "." + className, element);
try (Writer writer = file.openWriter()) {
writer.write("""
package %s;
/**
* Auto‑generated by HelloProcessor.
*/
public class %s {
public static void sayHello() {
System.out.println("Hello from generated class!");
}
}
""".formatted(packageName, className));
}
} catch (Exception e) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR, "Failed to generate class: " + e.getMessage(),
element);
}
}
// Returning true tells javac that the annotations are claimed.
return true;
}
}
And the accompanying annotation:
// src/main/java/com/example/annotation/Hello.java
package com.example.annotation;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE) // only needed at compile time
public @interface Hello { }
Compile the processor and package it as a JAR with the service descriptor:
src/main/resources/META-INF/services/javax.annotation.processing.Processor
File content:
com.example.processor.HelloProcessor
Now any class annotated with @Hello
automatically receives a sibling *GeneratedHello
class containing a sayHello()
method.
3. Custom DSLs with Annotations
3.1 Why use annotations for DSLs?
- Declarative syntax: Users write ordinary Java code embellished with metadata, avoiding external files.
- Static safety: The compiler checks the shape of the DSL (e.g., required properties).
- Tooling integration: IDEs understand the JSX‑like DSL because it’s plain Java.
3.2 Example: A tiny HTTP routing DSL
// src/main/java/com/example/web/Route.java
package com.example.web;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Route {
String path(); // e.g., "/users/{id}"
String method() default "GET";
}
Processor that generates a Router
implementation:
// src/main/java/com/example/web/processor/RouteProcessor.java
package com.example.web.processor;
import javax.annotation.processing.*;
import javax.lang.model.element.*;
import javax.tools.JavaFileObject;
import java.io.Writer;
import java.util.Set;
import java.util.stream.Collectors;
@SupportedAnnotationTypes("com.example.web.Route")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class RouteProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> ann,
RoundEnvironment round) {
if (round.processingOver()) return false;
var routes = round.getElementsAnnotatedWith(
processingEnv.getElementUtils()
.getTypeElement("com.example.web.Route"))
.stream()
.map(el -> (ExecutableElement) el)
.collect(Collectors.toList());
if (routes.isEmpty()) return false;
try {
JavaFileObject file = processingEnv.getFiler()
.createSourceFile("com.example.web.GeneratedRouter");
try (Writer w = file.openWriter()) {
w.write("package com.example.web;\n\n");
w.write("import java.util.*;\n");
w.write("public class GeneratedRouter {\n");
w.write(" private final Map<String, java.lang.reflect.Method> routes = new HashMap<>();\n");
w.write(" public GeneratedRouter() {\n");
for (ExecutableElement m : routes) {
var a = m.getAnnotation(Route.class);
String className = ((TypeElement) m.getEnclosingElement()).getQualifiedName();
w.write(String.format(
"routes.put(\"%s %s\", %s.class.getDeclaredMethod(\"%s\"));\n",
a.method(), a.path(), className, m.getSimpleName()));
}
w.write(" }\n");
w.write(" public java.lang.reflect.Method lookup(String method, String path) {\n");
w.write(" return routes.get(method + \" \" + path);\n");
w.write(" }\n");
w.write("}\n");
}
} catch (Exception e) {
processingEnv.getMessager()
.printMessage(Diagnostic.Kind.ERROR, e.toString());
}
return true;
}
}
Result: The developer writes only the annotated methods; the processor emits a GeneratedRouter
that maps HTTP verbs + paths to method references, ready for a minimal framework or for integration with larger servers like Spring or Micronaut.
4. Compile‑Time Code Generation in Practice
4.1 Common use‑cases
Use‑case | Typical library | What the processor generates |
---|---|---|
Builder / value objects | AutoValue, Immutables | Immutable POJOs with builders, equals , hashCode |
Dependency injection graph | Dagger, Guice (AOP) | Factory classes, component implementations |
JSON (de)serialization adapters | Moshi, Jackson (module generation) | JsonAdapter implementations |
Database mapping | Room (Android) | RoomDatabase implementation, DAO proxies |
Protocol buffers, gRPC | protobuf‑java, grpc‑java | Message and stub classes |
These libraries showcase how production‑grade processors balance performance, incremental builds, and IDE support.
4.2 Incremental processing (JDK 21+)
Since JDK 21, the compiler introduced the -proc:incremental
experimental mode and the @SupportedOptions
java.compiler.incremental
capability, allowing IDEs to re-run only affected processors when a source changes. Most modern processors (AutoValue, Dagger) already opt‑in via @IncrementalAnnotationProcessor
. When targeting JDK 23+, be aware of the default processing policy shift: a missing -proc
flag now defaults to -proc:none
, breaking silent processor execution (Inside.java, 2024). Always specify -proc:full
(or -proc:only
) in your build scripts.
5. Integrating Processors with Build Tools
5.1 Maven
Add the processor as an annotationProcessor dependency:
<dependency>
<groupId>com.example</groupId>
<artifactId>my-processor</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
Ensure the compiler plugin passes a processing option (required since JDK 23):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>com.example</groupId>
<artifactId>my-processor</artifactId>
<version>1.0.0</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-proc:full</arg> <!-- mandatory for JDK 23+ -->
</compilerArgs>
</configuration>
</plugin>
Maven also supports incremental compilation via the maven-compiler-plugin
incremental flag.
5.2 Gradle (Groovy DSL)
plugins {
id 'java'
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Add processor to the annotationProcessor configuration
dependencies {
annotationProcessor 'com.example:my-processor:1.0.0'
}
// Force -proc:full to satisfy JDK 23 default policy
tasks.withType(JavaCompile) {
options.compilerArgs += ['-proc:full']
}
Gradle’s incremental annotation processing (org.gradle.annotation.processing
) automatically detects @IncrementalAnnotationProcessor
implementations and runs only affected tasks.
5.3 IDE support
- IntelliJ IDEA: Detects
annotationProcessor
dependencies automatically and shows generated sources under Generated Sources (marked as source root). - Eclipse: Requires the Annotation Processing Tool (APT) plug‑in; set Annotation Processing > Factory Path to your processor JAR.
6. Debugging & Testing Processors
- Messager – Use
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "debug");
to emit compiler messages visible in the build log. - JUnit with
javac
– Compile a test source programmatically with theJavaCompiler
API and assert generated files:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fm = compiler.getStandardFileManager(null, null, null);
Iterable<? extends JavaFileObject> sources = fm.getJavaFileObjectsFromStrings(
List.of("src/test/java/com/example/TestAnnotated.java"));
CompilationTask task = compiler.getTask(null, fm, null,
List.of("-proc:full", "-processor", "com.example.processor.HelloProcessor"),
null, sources);
boolean success = task.call(); // success == true if compilation succeeded
-XprintRounds
– Pass this flag tojavac
to see how many processing rounds were executed, useful for diagnosing circular generation.
Conclusion
Java annotation processing empowers developers to move repetitive or error‑prone code out of the hand‑written space and into compile‑time generation. By mastering Processor APIs, building custom DSLs, and integrating with Maven/Gradle (especially under the new JDK 23 default policy), you can create robust, type‑safe abstractions that compile as fast as hand‑written code. The ecosystem—from AutoValue to Dagger—demonstrates how production‑grade annotation processors improve developer ergonomics while keeping runtime overhead near zero.
Takeaway: Write the metadata once, let a processor generate the boilerplate, and watch your codebase shrink while its reliability grows.