Skip to main content

Custom Annotations and Annotation Processors in Java: Generate Code at Compile Time

Retention, targets and meta-annotations, runtime reflection versus compile-time processing, and a complete @GenerateBuilder annotation processor: Filer, Messager, compile errors with real javac output, in-memory tests with javax.tools, and the JDK 23+ trap where a processor on the classpath silently stops running (reproduced on JDK 25).

You add an annotation to a class, run the build, and a new class appears that nobody wrote. That is how MapStruct works, and Lombok enters the compiler through the same hook. It is not magic and it is not reflection — it is an annotation processor, ordinary Java code that javac calls while it compiles your program. This post builds one from nothing: a @GenerateBuilder annotation, a processor that writes a builder class for any record or class you put it on, compile errors with exact line numbers when someone misuses it, and tests that run the compiler in memory to prove all of it.

If you have only used annotations through frameworks — Spring Boot’s or Jackson’s — you have met the runtime half of the story: the framework reads annotations by reflection while the program runs. We start there, because the difference between that and compile-time processing is the whole point. The failure modes come first where they are cheap to show; the one that has changed most recently is on JDK 23 and later, where a processor sitting on the classpath silently does not run. We reproduce it on JDK 25 with real output further down.

Every command, listing and transcript in this post comes from a companion module that was built and run for it. Where I did not run something, the text says so.

Versions used in this post. JDK 25.0.5+8 (Temurin, the current LTS) · Maven 3.9.11 · maven-compiler-plugin 3.16.0 · maven-surefire-plugin 3.6.1 · JUnit Jupiter 6.1.3. Plugin and JUnit versions were read from maven-metadata.xml on Maven Central; the JDK and Maven versions are what java -version and mvn -v reported in the build environment. Companion code: the annotation-processor module of java-core-examples. Run everything with mvn -B clean test from that module, or scripts/run-all.sh to regenerate every transcript.

An annotation is only a label until something reads it

An annotation does nothing by itself. @Deprecated makes the compiler warn; @Test makes JUnit run a method; @Entity makes Hibernate map a class. In each case a reader — the compiler, a test engine, an ORM — looks for the label and acts on it. Declaring your own is a few lines: an @interface with optional members, each member optionally carrying a default.

Here is the one the demo module uses for its reflection examples (Tracked.java). Note that a member called value lets callers write @Tracked("billing") without naming it, and that members may only be primitives, String, Class, enums, annotations, or arrays of those.

/**
 * RUNTIME: readable through reflection. Shows member types and defaults, plus
 * {@code @Documented}, {@code @Inherited} and {@code @Target}.
 */
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
public @interface Tracked {
    String value() default "";          // "value" lets you write @Tracked("x")
    int priority() default 5;
    Level level() default Level.NORMAL;
    String[] tags() default {};
}

Two questions decide everything that follows: who is going to read this label, and when. The answer to “when” is the retention policy.

Retention decides who can see an annotation

@Retention takes one of three values. SOURCE keeps the annotation in the .java file only; javac discards it after compilation. CLASS writes it into the .class file but the JVM does not load it into memory. RUNTIME writes it and also makes it available through reflection. The diagram shows the three lifetimes side by side: read it top to bottom and notice that the reflection box can only ever reach the bottom row, while a processor, which reads source, can see all three.

.java source .class file running JVM SOURCE CLASS RUNTIME annotation processors reflection Green = the annotation is still there. Dashed = it is gone. A processor sees all three kinds, because it reads the source. Reflection only ever sees the bottom row.

The demo declares one annotation of each kind and puts all three on one class (RetentionDemo.java). Asking reflection what is on it:

    @SourceOnly @ClassOnly @Tracked
    static class Base { }
-- retention: annotations reflection can see on Base
[@com.ankurm.demo.annotations.Tracked(priority=5, value="", level=NORMAL, tags={})]

One annotation of three. That is output from 01-retention-reflection.txt. The other two are not lost — javap can see the CLASS one in the class file, and the SOURCE one is nowhere (03-javap-retention.txt):

javap -v -p -cp demo/target/classes com.ankurm.demo.RetentionDemo$Base  (filtered)
RuntimeVisibleAnnotations:
    com.ankurm.demo.annotations.Tracked
RuntimeInvisibleAnnotations:
    com.ankurm.demo.annotations.ClassOnly
occurrences of SourceOnly in the class file: 0
The default is CLASS, and that is a classic trap. If you write a custom annotation and forget @Retention(RetentionPolicy.RUNTIME), the annotation compiles, the class file contains it, and getAnnotation(...) returns null at runtime. There is no warning and nothing is wrong with your reflection code. If a framework-style annotation seems to be ignored, check its retention first.
Going deeper: why CLASS exists at all

Bytecode tools can read RuntimeInvisibleAnnotations without paying the memory cost of loading them into every running JVM. That is the niche for CLASS: build-time and bytecode-level tools (shrinkers, static analysers, instrumentation) that run on .class files rather than sources. For application code you almost always want SOURCE (processors) or RUNTIME (reflection) — and the choice between them is the next two sections.

  • The class-file attributes we filtered for above are defined in the JVM specification, section 4.7.
  • Source for the three annotations: SourceOnly.java, ClassOnly.java.

@Target, @Documented, @Inherited and @Repeatable: the four meta-annotations

These are annotations you put on your annotation. @Target restricts where it may be applied; javac enforces it. @Documented asks Javadoc to include the annotation in generated documentation. @Inherited makes a class-level annotation visible on subclasses. @Repeatable lets you write the same annotation more than once.

The enforcement is easy to see. NotBlank is declared with @Target(ElementType.FIELD); putting it on a class gives (11-wrong-target.txt):

$ javac -cp demo/target/classes -d <tmp> bad/WrongTarget.java
bad/WrongTarget.java:4: error: annotation interface not applicable to this kind of declaration
@NotBlank
^
1 error
exit=1

The other two have behaviour worth seeing, because both involve surprises in what reflection returns. Child extends Base and declares nothing; Staff has two @Role annotations and Solo has one (01-retention-reflection.txt):

-- @Inherited: Child extends Base, Child itself has no annotations
getAnnotation(Tracked)         = true
getDeclaredAnnotation(Tracked) = false
-- @Repeatable
Staff direct getAnnotations().length   = 1 (Roles container)
Staff getAnnotationsByType(Role)       = [admin, auditor]
Solo  getAnnotation(Role) != null      = true
Solo  getAnnotation(Roles) != null     = false
Solo  getAnnotationsByType(Role)       = [admin]

Two things to take from that. @Inherited only affects class inheritance, and getDeclaredAnnotation still reports that the subclass itself declares nothing. And when an annotation is repeated, javac silently wraps the copies in the container annotation (Roles), so getAnnotation(Role.class) on Staff returns nothing while getAnnotationsByType(Role.class) unwraps it — but a single @Role is not wrapped, so the same getAnnotation call works on Solo. Use getAnnotationsByType whenever an annotation is repeatable.

Members, defaults and compatibility. A member with a default can be added to an annotation later without breaking existing users; a member without a default cannot. Annotation members also cannot be null — "" and an empty array are the usual stand-ins, which is why @Tracked above and @GenerateBuilder below both use empty-string defaults to mean “not set”.
Going deeper: the container annotation

The container (Roles) must have a value() member of the repeatable type’s array type, and its retention must be at least as long as the repeatable one. Here both are RUNTIME; the source is in Role.java and Roles.java. Our tests pin the behaviour in RetentionReflectionTest.java.

Reading annotations at runtime: the reflection way

The runtime approach is what Spring, Jackson and Hibernate do: look for the annotation on live objects, and act. Here is a tiny validator (RuntimeValidatorDemo.java) that finds every field marked @NotBlank:

    static List<String> validate(Object o) throws IllegalAccessException {
        List<String> problems = new ArrayList<>();
        for (Field f : o.getClass().getDeclaredFields()) {
            NotBlank nb = f.getAnnotation(NotBlank.class);
            if (nb == null) continue;
            f.setAccessible(true);
            Object v = f.get(o);
            if (v == null || v.toString().isBlank()) {
                problems.add(f.getName() + ": " + nb.message());
            }
        }
        return problems;
    }

It is a few lines, needs no build configuration, and works on any object including ones loaded from elsewhere. Output from the two sample objects (02-runtime-validator.txt):

[]
[email: must not be blank, name: name is required]

The costs show up at the edges. Reflection discovers problems when the code runs, so a typo is a production bug rather than a build failure. It can only inspect and call existing code — it cannot create a new class or add a method. And it needs setAccessible(true) to read private fields, which modules can forbid. Compile-time processing trades the other way: it runs before your program exists, can write new source files, and can fail the build.

Going deeper: what each approach is good at
Runtime reflectionCompile-time processor
Needs RUNTIME retentionYesNo (SOURCE is enough)
When mistakes surfaceWhen the code path runsAt compile time, with a source position
Can generate new classesNo (only via bytecode tricks)Yes, through Filer
Runtime costLookup on each use unless cachedNone: the output is plain Java
Build configurationNoneProcessor path, registration, tests
Sees code from other jarsYes, at runtimeOnly what is on the compiler’s path

This is a qualitative comparison, not a benchmark; I did not measure runtime cost for this post.

Compile-time processing: javac asks your code questions

An annotation processor is a class that implements javax.annotation.processing.Processor, almost always by extending AbstractProcessor. javac finds it, calls init once with a ProcessingEnvironment, then calls process once per round. In round 1, the input is the source files you gave javac. If a processor generates new source files, javac parses them and runs another round with those as input. When a round generates nothing new, javac runs one final round with processingOver() true and then compiles everything.

The diagram below is not theoretical. Those three boxes are the three notes the processor printed in the harness test, when run with the -Abuilder.debug=true option on a one-record program. Note round 2: the processor is called again even though no annotation is left to look at, because the generated PersonBuilder is a new root element.

Round 1 roots=[Person] annotations=[GenerateBuilder] writes PersonBuilder.java Round 2 roots=[PersonBuilder] annotations=[] writes nothing Final round roots=[] processingOver=true last chance to report Whatever a round generates becomes the input of the next round. Rounds stop when one produces no new files. Your process() method is called every round once it has been called once, even with an empty annotation set. Only after the final round does javac compile and write the .class files.

The transcript, as written by the test (09-processor-harness.txt):

== 3. rounds, with -Abuilder.debug=true
builder round 1: annotations=[com.ankurm.builder.GenerateBuilder] roots=[com.acme.Person] processingOver=false
builder round 2: annotations=[] roots=[com.acme.PersonBuilder] processingOver=false
builder round 3: annotations=[] roots=[] processingOver=true

Four helpers come with the ProcessingEnvironment, and between them they are the whole API surface you need for most processors:

HelperWhat you ask itUsed in our processor for
ElementsFind types and packages by name; read membersPackage of the annotated type, java.util.List lookup
TypesCompare, erase and convert TypeMirrors“Is this parameter a List?” via isAssignable on erasures
FilerCreate new source, class or resource filesWriting PersonBuilder.java
MessagerReport notes, warnings and errorsErrors attached to the offending element
Processors see a model, not text. Inside process() you do not read source code; you walk Element objects (a type, a constructor, a parameter) and TypeMirrors. That is why a processor can see the types of things that do not exist yet as classes. It is also why you cannot call annotation.value() when a member is a Class: the class may not be compiled, so you get a MirroredTypeException instead. Our annotation avoids that by using only String members.
Going deeper: claiming annotations, and what the return value means

process returns true to claim the annotations it was handed, so later processors in the same round do not receive them. Our processor claims GenerateBuilder, since nothing else should care. @SupportedAnnotationTypes("*") would claim every annotation in the program if it returned true — usually a mistake. Returning false from a catch-all processor is the polite default.

Write the processor: a @GenerateBuilder annotation

The annotation is the contract. It is SOURCE-retained because only javac needs it, and @Target(TYPE) because it describes a class or record (GenerateBuilder.java):

@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateBuilder {

    /** Name of the generated class. Empty means {@code <SimpleName>Builder}. */
    String name() default "";

    /** Prefix for setter-style methods, e.g. {@code "with"} gives {@code withName(..)}. Empty means none. */
    String prefix() default "";
}

The processor itself (BuilderProcessor.java) starts as a class with three annotations that say what it handles. @SupportedAnnotationTypes lists the annotation by its fully qualified name, and init grabs the four helpers:

@SupportedAnnotationTypes("com.ankurm.builder.GenerateBuilder")
@SupportedOptions("builder.debug")
public final class BuilderProcessor extends AbstractProcessor {

    private Elements elements;
    private Types types;
    private Filer filer;
    private Messager messager;
    private boolean debug;
    private int round;

    @Override
    public synchronized void init(ProcessingEnvironment env) {
        super.init(env);
        this.elements = env.getElementUtils();
        this.types = env.getTypeUtils();
        this.filer = env.getFiler();
        this.messager = env.getMessager();
        this.debug = "true".equals(env.getOptions().get("builder.debug"));

One detail there will save you a compile cycle: the source version is overridden rather than annotated; if you do neither, AbstractProcessor answers RELEASE_6 and javac warns, which the test harness captured (09-processor-harness.txt):

== 6. a processor that forgot @SupportedSourceVersion
WARNING: No SupportedSourceVersion annotation found on com.ankurm.builder.LazyProcessor, returning RELEASE_6.
WARNING: Supported source version 'RELEASE_6' from annotation processor 'com.ankurm.builder.LazyProcessor' less than -source '25'

The core of process loops over annotated elements and validates before generating. Every guard is an error the user can act on (again BuilderProcessor.java):

    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        round++;
        if (debug) {
            messager.printMessage(Diagnostic.Kind.NOTE,
                    "builder round " + round + ": annotations=" + annotations
                            + " roots=" + roundEnv.getRootElements()
                            + " processingOver=" + roundEnv.processingOver());
        }
        for (Element element : roundEnv.getElementsAnnotatedWith(GenerateBuilder.class)) {
            generate(element);
        }
        return true; // we claim GenerateBuilder: no other processor needs to see it
    }
    private void generate(Element element) {
        if (element.getKind() != ElementKind.CLASS && element.getKind() != ElementKind.RECORD) {
            error(element, "@GenerateBuilder can only be applied to a class or a record, not to "
                    + element.getKind().toString().toLowerCase());
            return;
        }
        TypeElement type = (TypeElement) element;
        if (type.getModifiers().contains(Modifier.ABSTRACT)) {
            error(type, "@GenerateBuilder cannot be applied to abstract class " + type.getSimpleName());
            return;
        }
        if (!type.getTypeParameters().isEmpty()) {
            error(type, "@GenerateBuilder does not support generic types such as " + type.getSimpleName());
            return;
        }
        if (type.getNestingKind().isNested()) {
            error(type, "@GenerateBuilder supports top-level types only; " + type.getSimpleName() + " is nested");
            return;
        }
        List<ExecutableElement> ctors = ElementFilter.constructorsIn(type.getEnclosedElements()).stream()
                .filter(c -> !c.getModifiers().contains(Modifier.PRIVATE))
                .toList();
        if (ctors.size() != 1) {
            error(type, type.getSimpleName() + " must have exactly one non-private constructor, but has "
                    + ctors.size());
            return;
        }
        ExecutableElement ctor = ctors.get(0);

Then registration: the processor is found through a plain text file listing its class name (javax.annotation.processing.Processor), the same ServiceLoader mechanism the JDK uses everywhere. No annotation library is needed:

com.ankurm.builder.BuilderProcessor
Full listing: the code-generation half of BuilderProcessor

The generator walks the constructor’s parameters and appends Java source. Everything it emits comes from model objects, so a parameter’s type prints as its fully qualified name; that is why the generated file below says java.lang.String rather than String. Source: BuilderProcessor.java.

        GenerateBuilder config = type.getAnnotation(GenerateBuilder.class);
        String pkg = elements.getPackageOf(type).getQualifiedName().toString();
        String builderName = config.name().isEmpty() ? type.getSimpleName() + "Builder" : config.name();
        String source = render(pkg, builderName, type, ctor, config.prefix());

        String qualified = pkg.isEmpty() ? builderName : pkg + "." + builderName;
        try {
            JavaFileObject file = filer.createSourceFile(qualified, type); // originating element
            try (Writer w = file.openWriter()) {
                w.write(source);
            }
        } catch (IOException e) {
            error(type, "could not write " + qualified + ": " + e.getMessage());
        }
    }

    private String render(String pkg, String builderName, TypeElement type, ExecutableElement ctor, String prefix) {
        TypeMirror listType = types.erasure(elements.getTypeElement("java.util.List").asType());
        StringBuilder sb = new StringBuilder();
        if (!pkg.isEmpty()) {
            sb.append("package ").append(pkg).append(";\n\n");
        }
        sb.append("// Generated by ").append(BuilderProcessor.class.getName()).append(" - do not edit.\n");
        sb.append("public final class ").append(builderName).append(" {\n\n");

        for (VariableElement p : ctor.getParameters()) {
            sb.append("    private ").append(p.asType()).append(' ').append(p.getSimpleName()).append(";\n");
        }
        for (VariableElement p : ctor.getParameters()) {
            String n = p.getSimpleName().toString();
            sb.append("\n    public ").append(builderName).append(' ').append(methodName(prefix, n))
              .append('(').append(p.asType()).append(' ').append(n).append(") {\n")
              .append("        this.").append(n).append(" = ").append(n).append(";\n")
              .append("        return this;\n    }\n");

            // Types in action: for a List<E> parameter, also generate addTo<Name>(E).
            if (p.asType().getKind() == TypeKind.DECLARED
                    && types.isAssignable(types.erasure(p.asType()), listType)) {
                List<? extends TypeMirror> args = ((DeclaredType) p.asType()).getTypeArguments();
                if (args.size() == 1 && args.get(0).getKind() == TypeKind.DECLARED) {
                    String e = args.get(0).toString();
                    sb.append("\n    public ").append(builderName).append(" addTo").append(capitalize(n))
                      .append('(').append(e).append(" element) {\n")
                      .append("        java.util.List<").append(e).append("> copy = this.").append(n)
                      .append(" == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(this.")
                      .append(n).append(");\n")
                      .append("        copy.add(element);\n")
                      .append("        this.").append(n).append(" = copy;\n")
                      .append("        return this;\n    }\n");
                }
            }
        }

        sb.append("\n    public ").append(type.getQualifiedName()).append(" build() {\n")
          .append("        return new ").append(type.getQualifiedName()).append('(');
        String sep = "";
        for (VariableElement p : ctor.getParameters()) {
            sb.append(sep).append(p.getSimpleName());
            sep = ", ";
        }
        sb.append(");\n    }\n}\n");
        return sb.toString();
    }

    private static String methodName(String prefix, String property) {
        return prefix.isEmpty() ? property : prefix + capitalize(property);
    }

    private static String capitalize(String s) {
        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
    }

    private void error(Element where, String message) {

Generating source by string-building is fine for a builder; for anything with real structure, a library such as JavaPoet is the usual step up. I did not use or test one here.

Wire it into Maven: the processor path, not just the classpath

The processor module and its consumer must be compiled separately: the consumer’s compilation needs the processor already built. That is why the companion is a two-module Maven build (processor and demo), run with one command from the parent. Two settings matter. In the processor module the compiler is told not to run processors on itself, since its own META-INF/services file names a class that is still being compiled (pom.xml):

        <configuration>
          <!-- This module ships META-INF/services/javax.annotation.processing.Processor, which
               names a class that does not exist yet while we are compiling it. Switch annotation
               processing off for this module's own compilation. -->
          <proc>none</proc>
        </configuration>

In the consumer module the processor appears twice, on purpose (pom.xml): as a provided dependency so javac can resolve the @GenerateBuilder type, and under annotationProcessorPaths so javac is told to run it:

    <dependency>
      <groupId>com.ankurm</groupId>
      <artifactId>builder-processor</artifactId>
      <version>${project.version}</version>
      <scope>provided</scope>
    </dependency>
          <annotationProcessorPaths>
            <path>
              <groupId>com.ankurm</groupId>
              <artifactId>builder-processor</artifactId>
              <version>${project.version}</version>
            </path>
          </annotationProcessorPaths>

The second block is the one that matters on modern JDKs, and a later section shows what happens without it. Generated sources land in demo/target/generated-sources/annotations, which Maven adds to the source roots automatically.

Use it: a builder that nobody wrote

Annotate a record (Person.java) and a plain class with options (Pizza.java):

@GenerateBuilder
public record Person(String name, int age, List<String> nicknames) {
}
@GenerateBuilder(name = "PizzaMaker", prefix = "with")
public final class Pizza {
    private final String size;
    private final boolean extraCheese;
    private final List<String> toppings;

    public Pizza(String size, boolean extraCheese, List<String> toppings) {
        this.size = size;
        this.extraCheese = extraCheese;
        this.toppings = toppings == null ? List.of() : List.copyOf(toppings);
    }

    @Override
    public String toString() {
        return "Pizza[size=" + size + ", extraCheese=" + extraCheese + ", toppings=" + toppings + "]";
    }
}

After mvn -B clean test, this is what the processor wrote for Person (04-generated-PersonBuilder.java.txt, copied from target/generated-sources):

package com.ankurm.demo;

// Generated by com.ankurm.builder.BuilderProcessor - do not edit.
public final class PersonBuilder {

    private java.lang.String name;
    private int age;
    private java.util.List<java.lang.String> nicknames;

    public PersonBuilder name(java.lang.String name) {
        this.name = name;
        return this;
    }

    public PersonBuilder age(int age) {
        this.age = age;
        return this;
    }

    public PersonBuilder nicknames(java.util.List<java.lang.String> nicknames) {
        this.nicknames = nicknames;
        return this;
    }

    public PersonBuilder addToNicknames(java.lang.String element) {
        java.util.List<java.lang.String> copy = this.nicknames == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(this.nicknames);
        copy.add(element);
        this.nicknames = copy;
        return this;
    }

    public com.ankurm.demo.Person build() {
        return new com.ankurm.demo.Person(name, age, nicknames);
    }
}

Because the nicknames parameter is a List, the processor also generated addToNicknames — that is the Types.isAssignable check at work. Using both builders (BuilderUsageDemo.java; neither PersonBuilder nor PizzaMaker exists in src/main/java):

        Person p = new PersonBuilder()
                .name("Asha")
                .age(34)
                .addToNicknames("Ash")
                .addToNicknames("A")
                .build();
        System.out.println(p);

        Pizza pizza = new PizzaMaker()
                .withSize("large")
                .withExtraCheese(true)
                .addToToppings("olives")
                .build();
        System.out.println(pizza);

        // Unset properties take the Java default for their type: null for references, 0 for int.
        System.out.println(new PersonBuilder().build());
Person[name=Asha, age=34, nicknames=[Ash, A]]
Pizza[size=large, extraCheese=true, toppings=[olives]]
Person[name=null, age=0, nicknames=null]

(05-builder-usage.txt.) The last line shows the honest limit of this builder: unset properties take Java defaults, so age is 0 and the rest are null. A real one would validate in build().

Fail the build with Messager, not with an exception

A processor that throws an exception makes javac print a stack trace and crash. A processor that calls Messager.printMessage(ERROR, text, element) makes javac print a normal compiler error at the source position of that element and fail the build cleanly. Passing the element is what produces the file name, line number and caret. Here is the real output of the processor run by plain javac against an interface that someone annotated by mistake (Shape.java; 06-compile-error.txt):

$ javac -processorpath processor/target/classes -d <tmp> bad/Shape.java
bad/Shape.java:5: error: @GenerateBuilder can only be applied to a class or a record, not to interface
public interface Shape {
       ^
1 error
exit=1

The harness test (ProcessorCompileTest.java) exercises four such cases in memory — interface, abstract class, two constructors, generic class — and asserts each gives exactly one error on line 4 and generates nothing (09-processor-harness.txt):

== 2. error cases: javac diagnostics produced via Messager
interface -> ERROR /com/acme/Shape.java:4: @GenerateBuilder can only be applied to a class or a record, not to interface
abstract class -> ERROR /com/acme/Animal.java:4: @GenerateBuilder cannot be applied to abstract class Animal
two constructors -> ERROR /com/acme/Point.java:4: Point must have exactly one non-private constructor, but has 2
generic class -> ERROR /com/acme/Box.java:4: @GenerateBuilder does not support generic types such as Box
Report, then return. After an ERROR message javac will still finish the round and fail at the end, but your code keeps running. Return from the method after reporting (as generate does) so you do not go on to generate half a class from an invalid element — that produces a second, confusing error that hides the first.

Test the processor by running javac in memory

You do not need a build to test a processor. javax.tools.ToolProvider.getSystemJavaCompiler() is the same compiler, callable from a test. Hand it source strings, a DiagnosticCollector to catch errors, and a file manager that keeps output in memory; set your processor with setProcessors; call the task. Then assert on the generated source and on the diagnostics.

The harness is (Compilation.java) about 100 lines, most of it the in-memory file manager. One detail cost me a failing run while writing it: javac reads generated sources back in the next round, so the in-memory file object must implement getCharContent, not just openOutputStream; without it the first run died with an UnsupportedOperationException inside JavacTaskImpl. The key lines:

        JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diags, options, null, units);
        if (processors != null) {
            task.setProcessors(processors);
        }
        boolean ok = task.call();

A test then looks like this (ProcessorCompileTest.java):

    void generatesBuilderAndTheGeneratedSourceCompilesInTheNextRound() throws Exception {
        Compilation c = Compilation.compile(
                Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
                List.of(new BuilderProcessor()), List.of());
        assertTrue(c.success, () -> c.diagnostics.toString());
        assertEquals(List.of("com.acme.PersonBuilder"), List.copyOf(c.generatedSources.keySet()));
        String src = c.generatedSources.get("com.acme.PersonBuilder");
        assertTrue(src.contains("public final class PersonBuilder"));
        assertTrue(src.contains("public PersonBuilder name(java.lang.String name)"));
        assertTrue(src.contains("public PersonBuilder addToNicknames(java.lang.String element)"));
        assertTrue(src.contains("public com.acme.Person build()"));
        assertTrue(c.classes.containsKey("com.acme.PersonBuilder"));
        assertTrue(c.classes.containsKey("com.acme.Main"), "Main uses a class that only exists after round 1");
        say("== 1. in-memory compile: Person + Main, BuilderProcessor supplied directly");
        say("success=" + c.success + " generated=" + c.generatedSources.keySet());
        say("classes=" + new java.util.TreeSet<>(c.classes.keySet()));
        say("");
    }

The same file also checks that META-INF/services discovery works with -processorpath and no setProcessors call, and that -proc:none gives no generated class and a cannot find symbol. Those results (09-processor-harness.txt):

== 4. no setProcessors(): discovered through META-INF/services via -processorpath
success=true generated=[com.acme.PersonBuilder]

== 5. -proc:none: the processor never runs
success=false generated=[]
ERROR /com/acme/Main.java:5: cannot find symbol

That covers the processor. The generated code needs an ordinary runtime test, and here the trick is that the test is in the consumer module: it would not even compile if the processor had not run (GeneratedBuilderTest.java):

    void recordBuilderSetsEveryComponent() {
        Person p = new PersonBuilder().name("Asha").age(34).addToNicknames("Ash").addToNicknames("A").build();
        assertEquals("Asha", p.name());
        assertEquals(34, p.age());
        assertEquals(List.of("Ash", "A"), p.nicknames());
    }

Surefire results for the whole build, 16 tests in three classes (10-tests.txt):

-------------------------------------------------------------------------------
Test set: com.ankurm.builder.ProcessorCompileTest
-------------------------------------------------------------------------------
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.268 s -- in com.ankurm.builder.ProcessorCompileTest

-------------------------------------------------------------------------------
Test set: com.ankurm.demo.GeneratedBuilderTest
-------------------------------------------------------------------------------
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 s -- in com.ankurm.demo.GeneratedBuilderTest

-------------------------------------------------------------------------------
Test set: com.ankurm.demo.RetentionReflectionTest
-------------------------------------------------------------------------------
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.351 s -- in com.ankurm.demo.RetentionReflectionTest
Test the error paths as hard as the happy path. A processor is a compiler plugin: the day it breaks, every developer on the team sees a confusing failure. The in-memory tests are fast (the six processor tests took seconds, see the output above) and let you assert exact error messages and line numbers, which is the user interface of a processor.

The JDK 23+ trap: a processor on the classpath no longer runs

Until JDK 21, putting a processor jar on the classpath was enough: javac discovered it and ran it. That changed. The JDK 23 release notes (Annotation processing in javac disabled by default, JDK-8321314) say annotation processing now only runs with explicit configuration, and that JDK 21 and 22 printed a note for builds that relied on the old behaviour. I only have JDK 25 here, so I did not observe that note on 21 or 22; what follows is what JDK 25 actually does.

The same processor and the same sources, differing only in how the processor is named. The diagram shows the four outcomes; the transcript below is the real thing (07-jdk25-proc-default.txt, produced by run-all.sh):

-cp processor.jar and no -proc / -processorpath processor skipped no note, no warning cannot find symbol PersonBuilder -processorpath … or -proc:full processor runs rounds as before compiles PersonBuilder exists Same processor, same sources, same javac 25.0.5. The only difference is how the processor was named. The missing-symbol error is the only symptom; it points at your code, not at the processor.

Classpath only: a flood of cannot find symbol errors for classes that were supposed to be generated. A one-class compile with -Xlint:all prints nothing at all, which is the scary part — no note, no warning that a processor was skipped. Then the two fixes, -proc:full and -processorpath, both compile:

$ javac -version
javac 25.0.5

$ javac -cp processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:6: error: cannot find symbol
        Person p = new PersonBuilder()
                       ^
  symbol:   class PersonBuilder
  location: class BuilderUsageDemo
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:14: error: cannot find symbol
        Pizza pizza = new PizzaMaker()
                          ^
  symbol:   class PizzaMaker
  location: class BuilderUsageDemo
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:22: error: cannot find symbol
        System.out.println(new PersonBuilder().build());
                               ^
  symbol:   class PersonBuilder
  location: class BuilderUsageDemo
3 errors
exit=1

$ javac -Xlint:all -cp processor/target/classes -d <tmp> Person.java
exit=0  (no output at all: nothing warned that a processor was skipped)

$ javac -cp processor/target/classes -proc:full -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
exit=0

$ javac -cp processor/target/classes -processorpath processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
exit=0

The same thing happens through Maven. With the processor available only as a dependency and no annotationProcessorPaths, the build fails; adding <proc>full</proc> makes it pass (08-maven-classpath-only.txt; the two throwaway profiles are in pom.xml):

$ mvn -B clean compile -Pclasspath-only        # no annotationProcessorPaths
[INFO] Compiling 2 source files with javac [debug release 25] to target/classes
[INFO] Compiling 12 source files with javac [debug release 25] to target/classes
[ERROR] COMPILATION ERROR : 
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[6,24] cannot find symbol
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[14,27] cannot find symbol
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[22,32] cannot find symbol
[INFO] BUILD FAILURE

$ mvn -B clean compile -Pclasspath-proc-full   # same, plus <proc>full</proc>
[INFO] Compiling 2 source files with javac [debug release 25] to target/classes
[INFO] Compiling 12 source files with javac [debug release 25] to target/classes
[INFO] BUILD SUCCESS
The fingerprint of this bug. cannot find symbol for a class named XxxBuilder or XxxMapper or QXxx, that exists in neither your source nor the previous build, right after a JDK upgrade. Your code did not change and the processor did not change; javac simply never called it. Fix: name the processor on the processor path (annotationProcessorPaths in Maven, annotationProcessor configuration in Gradle) — preferred, because it also keeps processors out of your runtime classpath — or opt back in with -proc:full.

Other pitfalls, with what I verified and what I did not

  • Processor path versus classpath. Verified above. Treat them as different things: the classpath is what your code compiles against; the processor path is what javac loads as plugins. Annotation types (@GenerateBuilder) must be on the classpath, the processor on the processor path.
  • Do not process the processor. Verified: the processor module sets <proc>none</proc> because its own META-INF/services file points at a class not yet compiled. I did not record what happens without it.
  • Missing @SupportedSourceVersion. Verified: the two-line warning shown earlier. Override getSupportedSourceVersion and return SourceVersion.latestSupported().
  • Generating the same file twice. Filer.createSourceFile throws FilerException if a file of that name was already created in this compilation, so do not regenerate in later rounds. Our processor only touches elements annotated in the current round. This is documented API behaviour; I did not trigger it.
  • Incremental builds. Not exercised in this module. Gradle distinguishes “isolating” and “aggregating” processors and needs a registration file to know which you are; as far as I know Maven’s compiler plugin recompiles the whole module when it detects changes. If a stale generated class appears after you remove an annotation, mvn clean is the first thing to try.
  • IDEs. IntelliJ and Eclipse have their own switch for annotation processing and may run it separately from your build; if the IDE shows red for generated classes while Maven is green, check that switch. Not tested here.
Modules (JPMS): what changes, not executed here

This module is not a named Java module and I did not compile a module-info.java variant, so what follows comes from the documentation, not from a run. In a modular build, javac can load processors from the module path (--processor-module-path) and a processor module declares itself with provides javax.annotation.processing.Processor with ... in module-info.java instead of the META-INF/services file. The consumer module must requires whichever module exports the annotation type. The Maven annotationProcessorPaths mechanism and the -processorpath option shown above still work for non-modular processor jars.

Should you write your own annotation processor? Usually not. If you want builders, records, Lombok, or a record-plus-with pattern already cover the need; if you want mappers or DI wiring, MapStruct and Dagger exist and are maintained. A hand-written processor earns its place when the rule is specific to your codebase — “every @Endpoint must have a matching handler” — and you want it enforced at compile time. Be ready to own it: it is a compiler plugin with its own tests, its own JDK-upgrade failures (as the previous section showed), and an IDE story to maintain. For simple checks, a reflection-based test that scans your classes at test time is often a cheaper first step.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.