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

ConceptMeaning
RoundEach compilation may run several rounds. Processors may generate new source files, which trigger further rounds.
Supported annotationsDeclared via @SupportedAnnotationTypes or getSupportedAnnotationTypes().
Processing optionsPassed 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 mirrorsRepresent 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‑caseTypical libraryWhat the processor generates
Builder / value objectsAutoValue, ImmutablesImmutable POJOs with builders, equals, hashCode
Dependency injection graphDagger, Guice (AOP)Factory classes, component implementations
JSON (de)serialization adaptersMoshi, Jackson (module generation)JsonAdapter implementations
Database mappingRoom (Android)RoomDatabase implementation, DAO proxies
Protocol buffers, gRPCprotobuf‑java, grpc‑javaMessage 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

  1. Messager – Use processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "debug"); to emit compiler messages visible in the build log.
  2. JUnit with javac – Compile a test source programmatically with the JavaCompiler 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
  1. -XprintRounds – Pass this flag to javac 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.

← Back to java tutorials

Author

Efe Omoregie

Efe Omoregie

Software engineer with a passion for computer science, programming and cloud computing