Exploring Java's Annotation Processing for Advanced Tooling
Build custom code generation tools and frameworks using Java’s annotation processing API
Annotations in Java are often used for configuration (like @Override
or @Autowired
), but they also power some of the most sophisticated frameworks in the Java ecosystem. What many developers don’t realize is that you can build your own tools using Java’s annotation processing API.
In this post, we’ll explore how to implement custom annotation processors to generate code, enforce compile-time constraints, and enhance developer productivity — all without runtime overhead.
What Is Annotation Processing?
Annotation Processing is a feature of the Java compiler (javac
) that lets you inspect and react to annotations at compile time, rather than at runtime like reflection.
Use cases include:
- Generating boilerplate code
- Creating builder patterns
- Validating configuration metadata
- Generating REST APIs, DTOs, or mappers
Frameworks like Lombok, MapStruct, and Dagger are all powered by annotation processors.
Getting Started with Annotation Processor
First, create a new Java project with a separate module (or sub-project) that contains the processor.
Add this to your pom.xml
:
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
<scope>provided</scope>
</dependency>
This simplifies processor registration.
Creating a Custom Annotation
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface AutoLog {
}
This annotation will trigger code generation in the processor.
Writing the Annotation Processor
@AutoService(Processor.class)
public class AutoLogProcessor extends AbstractProcessor {
@Override
public Set<String> getSupportedAnnotationTypes() {
return Set.of(AutoLog.class.getCanonicalName());
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
for (Element element : env.getElementsAnnotatedWith(AutoLog.class)) {
if (element.getKind() == ElementKind.CLASS) {
generateLogger((TypeElement) element);
}
}
return true;
}
private void generateLogger(TypeElement type) {
String className = type.getSimpleName() + "Logger";
String packageName = processingEnv.getElementUtils().getPackageOf(type).toString();
String source = String.format("""
package %s;
public class %s {
public static void log(String msg) {
System.out.println("[LOG] " + msg);
}
}
""", packageName, className);
try {
JavaFileObject file = processingEnv.getFiler().createSourceFile(packageName + "." + className);
try (Writer writer = file.openWriter()) {
writer.write(source);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
This processor generates a logger class at compile time.
Testing the Processor
Annotate a test class:
@AutoLog
public class Service {
// After build, ServiceLogger.java is created automatically
}
Compile the project and inspect the generated files in the target/generated-sources/annotations
directory.
Integration with IDEs and Build Tools
To ensure generated code appears in your IDE:
- Add the generated folder to your source set
- Use annotation processors in Maven with
maven-compiler-plugin
- In Gradle, enable annotation processing via
kapt
orannotationProcessor
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Advanced Use Cases
You can go beyond simple code generation:
- Validate custom DSLs at compile time
- Generate OpenAPI specs or GraphQL schemas
- Build domain-specific frameworks (like a DI container)
You can also inspect annotations recursively and read annotation values:
AnnotationMirror mirror = element.getAnnotationMirrors().get(0);
Map<? extends ExecutableElement, ? extends AnnotationValue> values = mirror.getElementValues();
Best Practices
- Keep annotation processors fast and stateless
- Log warnings or errors using
processingEnv.getMessager()
- Avoid depending on runtime classes; use
javax.lang.model.*
APIs - Use
RoundEnvironment.processingOver()
to perform final validations
Conclusion
Java’s annotation processing opens the door to powerful tooling, custom frameworks, and compile-time safety. Whether you’re building a library, improving developer ergonomics, or eliminating boilerplate, annotation processors provide a scalable way to extend the Java language.
If you’ve ever wished Java had macros or metaprogramming — this is the next best thing, and it’s production-safe.