Build a Custom Annotation Processor in Java

Java gives developers a quiet superpower that runs during compilation: the ability to inspect source code, generate new classes, and enforce project rules before bytecode is produced. This mechanism has powered libraries like Lombok and MapStruct for years. Yet most working Java engineers have never built one themselves.

The reason is mystique. Annotation processing lives at the intersection of compiler internals, the class file format, and standard services, so the barrier feels higher than it is. With a modern JDK, Maven multi-module setup, and around fifty lines of code, you can have a working processor.

For developers in Australian tech hubs from Surry Hills coworking spaces to Melbourne's Cremorne corridors, building internal tooling that catches mistakes at build time saves real money. Compile-time feedback shortens the loop between commit and review, which matters when your team is split across Sydney, Brisbane, and Perth on AEST.

How annotations interact with the Java compiler

Annotations are lightweight markers that attach metadata to packages, classes, methods, fields, and parameters. They become useful when something reads them. Reflection handles runtime discovery, while the compiler invokes processors during the build to react at compile time.

Every annotation has a retention policy. SOURCE annotations disappear after compilation, ideal for processors that generate code. CLASS annotations live in the class file but not at runtime. RUNTIME annotations are available through reflection, which Spring uses to wire beans. A processor only needs SOURCE or CLASS retention because it runs before the file is finalised.

The processor extends AbstractProcessor. The compiler finds it through ServiceLoader and feeds it a stream of round environments containing annotated elements. The processor decides what to do with each element and may request additional rounds until no new sources are produced.

Setting up a two-module Maven project

A clean processor lives in its own module so it can be added to a project as a build-time dependency. The first module holds the annotation. The second contains the processor implementation and depends on the first.

Most Australian enterprise teams I have worked with keep the JDK pinned to 17 or 21, the LTS versions supported by major cloud providers. Matching that choice keeps your processor compatible with Atlassian Bamboo or the GitHub Actions runners used by local consultancies. Add the maven-compiler-plugin and configure source and target to your chosen release.

Because annotation processing happens before your regular code is compiled, the processor module must avoid any dependency on application code. Keep it focused on javax.annotation.processing, optionally with JavaPoet for source generation.

Defining the builder annotation

Start with a small, useful annotation. A builder annotation is a classic choice because builders are repetitive and easy to validate. Declare the annotation with @Retention(RetentionPolicy.SOURCE) and @Target(ElementType.TYPE) so it only exists at compile time.

You can add members such as builderName or includeStaticBuilder that configure how the generated class behaves. Keep defaults sensible so a developer can write @GenerateBuilder on a POJO and receive a working builder without extra configuration. This style is popular in Melbourne fintech teams working on instalment flows.

Implementing the processor

Inside the processor module, write a class extending AbstractProcessor and overriding two methods. getSupportedAnnotationTypes returns the fully qualified name of the annotation. getSupportedSourceVersion returns the latest supported source version, which on JDK 21 is SourceVersion.RELEASE_21.

Work happens in the process method. Iterate over the TypeElement instances that match the annotation. For each annotated class, use the Elements utility to inspect fields and types. Build a specification in memory, then ask the Filer to create a source file. Guard against FilerException when the name already exists.

Use the Messager for diagnostics. When a developer applies your annotation to a record or final class, a clear warning helps them understand why the processor skipped it. Australian developers appreciate plain English messages, especially in multilingual teams.

Registering the processor and generating source

Without registration, javac ignores your processor entirely. Create a directory called META-INF/services in the processor module's resources folder. Inside, place a file named javax.annotation.processing.Processor containing the fully qualified name of your processor class, one per line.

If you ship multiple processors in the same artifact, list each on a separate line. The Java compiler iterates the file and instantiates each class. This indirection is intentional, the ServiceLoader lets third-party processors plug into any build.

Writing source files by hand works for simple cases but quickly becomes painful. JavaPoet, a Square library, gives you a fluent API for class skeletons, method signatures, and field declarations. Add it to the processor module only, never to the runtime classpath, otherwise your application ships with an unused dependency.

JavaPoet lets you describe the builder in a few lines, including constructors, fluent setters, and a build method. Generated code must respect the original class accessibility, otherwise downstream modules will see errors that look unrelated to the POJO.

Practical uses beyond builders

Annotation processors excel where validation, generation, and policy enforcement meet. Australian teams building software subject to the Privacy Act 1988 and the Australian Privacy Principles can use processors to verify that every entity touching personal information carries the right audit annotation. Build fails when a developer forgets the marker, catching compliance gaps before code review.

Applications also include generating equals and hashCode methods, typed query builders for JDBC, configuration object builders, and façade classes for legacy systems. The same technique used by Lombok powers these patterns, and once you understand it you can adapt the recipe to any boilerplate problem.

Habits that keep a processor project maintainable