coderain blog

Java Annotation Processor: How to Get the Class Being Processed at Compile Time

Java Annotation Processors (JAP) are powerful tools that enable developers to inspect, validate, and generate code during compilation. They are widely used for tasks like generating boilerplate code (e.g., DTOs, builders), enforcing coding standards, or integrating with frameworks (e.g., Lombok, Dagger). A common requirement when building an annotation processor is accessing the class being processed—its name, modifiers, superclass, methods, fields, and other structural details.

In this blog, we’ll demystify how to extract information about the class being processed at compile time using the Java Annotation Processing API. We’ll cover setup, core concepts, step-by-step implementation, and best practices to avoid common pitfalls.

2025-12

Table of Contents#

  1. Introduction to Java Annotation Processors
  2. Setting Up the Annotation Processor Project
  3. Understanding the Processing Environment
  4. Key Components: Elements, Types, and Mirrors
  5. How to Get the Class Being Processed
  6. Handling Different Scenarios
  7. Debugging and Testing the Annotation Processor
  8. Common Pitfalls and Best Practices
  9. Conclusion
  10. References

Introduction to Java Annotation Processors#

Java Annotation Processors are part of the javax.annotation.processing API (now part of jakarta.annotation.processing in Jakarta EE). They run as part of the Java compiler (javac) and analyze annotations to perform compile-time tasks. The core class is AbstractProcessor, which developers extend to create custom processors.

Key use cases include:

  • Generating source code (e.g., adapters, serializers).
  • Validating code (e.g., ensuring a class implements a required interface).
  • Collecting metadata (e.g., mapping classes to database tables).

To interact with the class being processed, we need to understand how the compiler represents program elements (classes, methods, fields) and their types.

Setting Up the Annotation Processor Project#

We’ll use Maven for this tutorial, but the concepts apply to Gradle as well. We’ll also use auto-service (by Google) to simplify processor registration.

Project Structure#

src/
├── main/
│   ├── java/
│   │   └── com/
│   │       └── example/
│   │           ├── annotation/
│   │           │   └── ProcessMe.java       // Custom annotation
│   │           └── processor/
│   │               └── ClassProcessor.java   // Annotation processor
│   └── resources/
└── pom.xml

Maven Dependencies#

Add these dependencies to pom.xml:

<dependencies>
    <!-- Auto-service for processor registration -->
    <dependency>
        <groupId>com.google.auto.service</groupId>
        <artifactId>auto-service</artifactId>
        <version>1.0.1</version>
        <optional>true</optional>
    </dependency>
    <!-- Java Annotation Processing API -->
    <dependency>
        <groupId>jakarta.annotation.processing</groupId>
        <artifactId>jakarta.annotation.processing-api</artifactId>
        <version>1.1.0</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

Configure Maven Compiler Plugin#

Ensure the compiler recognizes the processor:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <source>17</source> <!-- Use your Java version -->
                <target>17</target>
                <annotationProcessorPaths>
                    <!-- Register the processor -->
                    <path>
                        <groupId>com.example</groupId>
                        <artifactId>annotation-processor-demo</artifactId>
                        <version>1.0-SNAPSHOT</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Understanding the Processing Environment#

When the processor runs, it receives a ProcessingEnvironment object, which provides utilities to interact with the compiler:

UtilityPurpose
ElementUtilsProvides methods to work with program elements (classes, methods, etc.).
TypeUtilsProvides type analysis (e.g., checking if two types are equal).
FilerUsed to generate new source files or resources.
MessagerUsed to report errors, warnings, or notes to the compiler.

We’ll use ElementUtils extensively to inspect the class being processed.

Key Components: Elements, Types, and Mirrors#

To work with classes at compile time, you need to understand three core abstractions:

1. Elements#

Represent program elements (classes, methods, fields, etc.). Common subinterfaces:

  • TypeElement: Represents a class or interface (e.g., com.example.User).
  • ExecutableElement: Represents a method or constructor.
  • VariableElement: Represents a field, parameter, or local variable.

2. Types#

Represent the types of elements (e.g., String, List<Integer>). The TypeMirror interface is the base type for all type representations.

3. Mirrors#

"Mirrors" are compiler-internal representations of types and elements. For example, TypeElement has a TypeMirror that represents its type.

How to Get the Class Being Processed#

Let’s walk through a step-by-step example to extract information from a class annotated with a custom annotation.

Step 1: Define a Custom Annotation#

First, create an annotation to mark classes for processing. We’ll call it @ProcessMe:

// com/example/annotation/ProcessMe.java
package com.example.annotation;
 
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
@Target(ElementType.TYPE) // Apply to classes/interfaces
@Retention(RetentionPolicy.SOURCE) // Retained only in source code (compile-time)
public @interface ProcessMe {
}
  • @Target(ElementType.TYPE) ensures the annotation is only applied to classes or interfaces.
  • @Retention(RetentionPolicy.SOURCE) ensures the annotation is not present at runtime (we only need it during compilation).

Step 2: Implement the Annotation Processor#

Next, create the processor by extending AbstractProcessor and overriding the process method. We’ll use @AutoService to auto-register the processor (avoids manual META-INF/services setup).

// com/example/processor/ClassProcessor.java
package com.example.processor;
 
import com.example.annotation.ProcessMe;
import com.google.auto.service.AutoService;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;
 
@AutoService(Processor.class) // Auto-register the processor
@SupportedAnnotationTypes("com.example.annotation.ProcessMe") // Annotations to process
@SupportedSourceVersion(SourceVersion.RELEASE_17) // Supported Java version
public class ClassProcessor extends AbstractProcessor {
 
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // Iterate over annotations we care about (here, only @ProcessMe)
        for (TypeElement annotation : annotations) {
            if (annotation.getQualifiedName().contentEquals(ProcessMe.class.getName())) {
                // Get all elements annotated with @ProcessMe
                Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation);
                
                for (Element element : annotatedElements) {
                    // Process each annotated element (class/interface)
                    processAnnotatedClass(element);
                }
            }
        }
        return true; // Claim the annotations (no other processor will process them)
    }
 
    private void processAnnotatedClass(Element element) {
        // TODO: Extract class information here
    }
}

Step 3: Access the Annotated Class Element#

In processAnnotatedClass, we first verify the element is a class/interface (since @ProcessMe targets TYPE). We cast it to TypeElement to access class-specific details:

private void processAnnotatedClass(Element element) {
    // Ensure the element is a class or interface
    if (element.getKind() != ElementKind.CLASS && element.getKind() != ElementKind.INTERFACE) {
        processingEnv.getMessager().printMessage(
            Diagnostic.Kind.ERROR, 
            "@ProcessMe can only be applied to classes or interfaces."
        );
        return;
    }
 
    // Cast to TypeElement to access class details
    TypeElement classElement = (TypeElement) element;
    processingEnv.getMessager().printMessage(
        Diagnostic.Kind.NOTE, 
        "Processing class: " + classElement.getQualifiedName()
    );
}

Step 4: Extract Class Information#

From TypeElement, we can extract rich details about the class:

Example: Extract Basic Class Info#

private void processAnnotatedClass(Element element) {
    if (!(element instanceof TypeElement classElement)) {
        // Handle error (as above)
        return;
    }
 
    // 1. Class name (qualified)
    String className = classElement.getQualifiedName().toString();
    processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Class name: " + className);
 
    // 2. Modifiers (public, abstract, final, etc.)
    Set<Modifier> modifiers = classElement.getModifiers();
    processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Modifiers: " + modifiers);
 
    // 3. Superclass
    TypeMirror superclass = classElement.getSuperclass();
    String superclassName = superclass.toString();
    processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Superclass: " + superclassName);
 
    // 4. Implemented interfaces
    List<? extends TypeMirror> interfaces = classElement.getInterfaces();
    processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Interfaces: " + interfaces);
}

Example: Extract Fields and Methods#

To get fields and methods, iterate over classElement.getEnclosedElements() and filter by ElementKind:

// Extract fields
for (Element enclosedElement : classElement.getEnclosedElements()) {
    if (enclosedElement.getKind() == ElementKind.FIELD) {
        VariableElement field = (VariableElement) enclosedElement;
        String fieldName = field.getSimpleName().toString();
        String fieldType = field.asType().toString();
        processingEnv.getMessager().printMessage(
            Diagnostic.Kind.NOTE, 
            "Field: " + fieldType + " " + fieldName
        );
    }
}
 
// Extract methods
for (Element enclosedElement : classElement.getEnclosedElements()) {
    if (enclosedElement.getKind() == ElementKind.METHOD) {
        ExecutableElement method = (ExecutableElement) enclosedElement;
        String methodName = method.getSimpleName().toString();
        String returnType = method.getReturnType().toString();
        processingEnv.getMessager().printMessage(
            Diagnostic.Kind.NOTE, 
            "Method: " + returnType + " " + methodName + "()"
        );
    }
}

Handling Different Scenarios#

Processing Annotated Classes vs. Other Elements#

If your annotation targets methods or fields (via @Target(ElementType.METHOD)), you’ll need to check the ElementKind and cast to ExecutableElement or VariableElement instead of TypeElement.

Dealing with Generic Types#

To handle generics (e.g., List<String>), use TypeUtils to inspect type arguments:

TypeMirror listType = ...; // e.g., from a field's type
if (processingEnv.getTypeUtils().isAssignable(listType, processingEnv.getElementUtils().getTypeElement("java.util.List").asType())) {
    DeclaredType declaredType = (DeclaredType) listType;
    List<? extends TypeMirror> typeArgs = declaredType.getTypeArguments();
    // typeArgs will contain String's TypeMirror for List<String>
}

Handling Dependencies Between Classes#

Annotation processing runs in rounds. If your processor depends on another class that hasn’t been processed yet, you may need to defer processing to a later round. Check RoundEnvironment.processingOver() to avoid infinite loops.

Debugging and Testing the Annotation Processor#

Debugging#

  • Use Messager to print messages (e.g., processingEnv.getMessager().printMessage(...)).
  • Run javac with flags:
    javac -XprintProcessorInfo -XprintRounds to see processor activity.
  • Attach a debugger to the compiler:
    mvn compile -Dmaven.compiler.debug=true -Dmaven.compiler.debuglevel=source,lines,vars

Testing#

Use the Java Compiler API to run the processor programmatically in unit tests:

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.io.File;
import java.util.Arrays;
 
public class ProcessorTest {
    public static void main(String[] args) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        int result = compiler.run(
            null, null, null,
            "-processor", "com.example.processor.ClassProcessor",
            new File("src/test/java/com/example/User.java").getAbsolutePath()
        );
        System.out.println("Compilation result: " + result); // 0 = success
    }
}

Common Pitfalls and Best Practices#

Pitfalls#

  • Forgetting to Register the Processor: Use auto-service or manually create META-INF/services/javax.annotation.processing.Processor.
  • Casting Without Checking ElementKind: Leads to ClassCastException (e.g., casting a method to TypeElement).
  • Ignoring Processing Rounds: Failing to handle deferred processing can lead to incomplete data.

Best Practices#

  • Limit Scope: Only process annotations you care about (use @SupportedAnnotationTypes).
  • Generate Code Safely: Use Filer to generate files (never write directly to disk).
  • Be Incremental: Support incremental compilation by checking RoundEnvironment for changes.
  • Report Errors Clearly: Use Messager to guide users when annotations are misused.

Conclusion#

Java Annotation Processors are a powerful way to inspect and manipulate code at compile time. By leveraging TypeElement and ElementUtils, you can extract detailed information about classes, enabling tasks like code generation and validation. With the setup and techniques outlined here, you’ll be able to build robust, maintainable processors.

References#