annotation processing
code that runs inside javac. how lombok and mapstruct work.
- chapter
- chapter 11.03
- reading time
- 8 min read
an annotation processor is a plugin for javac. it runs during compilation, looks at your declarations, and can generate new source files, which javac then compiles too, in another round. it can also fail the build with a proper error message.
what it officially can't do is change the code you wrote. lombok does that anyway, by reaching into javac's internals.
the picture
reflection reads your code while it runs, every time it runs. an annotation processor reads it once, at compile time, and writes ordinary java that does the same job. no reflection at runtime, no surprises at startup, and you can open the generated file and read it.
think of a manuscript with notes in the margin, going through a publisher. the processor is an editor who reads the notes and is allowed to add new chapters (new files), as many as they like, but never to touch a page you wrote. lombok is the editor who takes out a pen and writes on your pages anyway.
a processor you can actually run
the goal: put @Builder on a record and get a PersonBuilder class for free. first the annotation, SOURCE retention because nothing after javac needs to see it (annotations):
package gen;
import java.lang.annotation.*;
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Builder {}then the processor. it extends AbstractProcessor, says which annotation it handles, and gets a process call for every round:
package gen;
import java.io.Writer;
import java.util.Set;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
@SupportedAnnotationTypes("gen.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_25)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment round) {
for (Element e : round.getElementsAnnotatedWith(Builder.class)) {
if (e.getKind() != ElementKind.RECORD) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Builder only works on records", e);
continue;
}
TypeElement rec = (TypeElement) e;
String name = rec.getSimpleName() + "Builder";
StringBuilder src = new StringBuilder("public class " + name + " {\n");
StringBuilder args = new StringBuilder();
for (RecordComponentElement c : rec.getRecordComponents()) {
String t = c.asType().toString(), n = c.getSimpleName().toString();
src.append(" private ").append(t).append(" ").append(n).append(";\n");
src.append(" public ").append(name).append(" ").append(n).append("(").append(t)
.append(" v) { this.").append(n).append(" = v; return this; }\n");
args.append(args.isEmpty() ? "" : ", ").append(n);
}
src.append(" public ").append(rec.getSimpleName()).append(" build() { return new ")
.append(rec.getSimpleName()).append("(").append(args).append("); }\n}\n");
try (Writer w = processingEnv.getFiler().createSourceFile(name, rec).openWriter()) {
w.write(src.toString());
} catch (Exception ex) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, ex.toString());
}
}
return true;
}
}notice what it works with: TypeElement, RecordComponentElement, asType(). that's javax.lang.model, javac's read-only view of declarations: classes, members, types, annotations. method bodies aren't in it. and the only way to produce anything is the Filer, which creates new files.
the app uses a class that doesn't exist yet:
@Builder
public record Person(String name, int age) {}
public class Main {
public static void main(String[] args) {
Person p = new PersonBuilder().name("ada").age(36).build();
System.out.println(p);
}
}compile the processor, then compile the app with it, asking javac to narrate its rounds:
$ javac -d procout gen/Builder.java gen/BuilderProcessor.java
$ javac -cp procout -processor gen.BuilderProcessor -s gensrc -d out -XprintRounds Main.java Person.java
Round 1:
input files: {Main, Person}
annotations: [gen.Builder]
last round: false
Round 2:
input files: {PersonBuilder}
annotations: []
last round: false
Round 3:
input files: {}
annotations: []
last round: true
$ java -cp out Main
Person[name=ada, age=36]
$ cat gensrc/PersonBuilder.java
public class PersonBuilder {
private java.lang.String name;
public PersonBuilder name(java.lang.String v) { this.name = v; return this; }
private int age;
public PersonBuilder age(int v) { this.age = v; return this; }
public Person build() { return new Person(name, age); }
}rounds
why three rounds for one generated file? because generated files can carry annotations too, which might need processing, which might generate more files. javac keeps going until a round produces nothing new.
- parse→
- enter→
- process→
- attribute + flow→
- generate
(processing hasn't started)javac parses both files into syntax trees. Main mentions PersonBuilder, which doesn't exist anywhere. javac notices, and keeps quiet for now.
that ordering is the whole trick. javac parses and enters everything first, runs the processors, and only type-checks method bodies afterwards. so code can depend on generated classes, as long as a processor generates them.
errors are a feature
a processor can fail the build, pointing at the exact element:
$ javac -cp builder.jar -proc:full -d out Car.java
Car.java:4: error: @Builder only works on records
public class Car {}
^
1 errorthat's the real payoff over runtime reflection. MapStruct checks every property name in your mappings while compiling. misspell one, and instead of a mapper that silently copies 0 in production:
CarMapper.java:9: error: No property named "numberOfSeat" exists in source parameter(s). Did you mean "numberOfSeats"?
@Mapping(target = "seatCount", source = "numberOfSeat")
^mapstruct by the rules, lombok around them
both are "annotation processors", and both run inside javac. that's where the similarity ends.
@Mapper
public interface CarMapper {
@Mapping(target = "seatCount", source = "numberOfSeats")
CarDto toDto(Car car);
}public class CarMapperImpl implements CarMapper {
@Override
public CarDto toDto(Car car) {
if ( car == null ) {
return null;
}
int seatCount = 0;
String make = null;
seatCount = car.numberOfSeats();
make = car.make();
CarDto carDto = new CarDto( make, seatCount );
return carDto;
}
}CarMapper.class CarMapperImpl.class Car.class CarDto.class
the processor API lets you create new files and nothing else. mapstruct turns your interface into an implementation you can open, read and step through in a debugger. a typo in a property name is a compile error, not a runtime surprise.
lombok's @Getter, @Builder, @Data and friends add members to your class. the processor API can't do that, so lombok's processor casts the environment javac hands it to javac's internal implementation class, gets the actual syntax tree (com.sun.tools.javac.tree.JCTree) and inserts method nodes into it before code generation. since strong encapsulation closed jdk.compiler's internals, it gets in with sun.misc.Unsafe, which is the warning javac 25 printed above.
it works, millions of projects use it, and the trade-off is honest to state: your source isn't the whole truth anymore, every tool that reads java (IDEs, static analyzers, coverage) needs a lombok plugin to understand it, and each new JDK can break it until lombok catches up. records quietly replaced one of its most popular annotations, @Value, with a language feature.
the processors that play by the rules are everywhere, often invisibly: Dagger and Micronaut generate dependency injection wiring, MapStruct generates mappers, AutoValue and Immutables generate value classes, Hibernate's metamodel generator writes Person_ classes for type-safe queries, and JMH generates the harness around your benchmarks.
how javac finds processors
hover a stage.
- a processor runs inside javac, reads declarations, and writes new source files.
- every round that generates files triggers another round. type-checking waits until processing is done.
- processors can fail the build with real errors: mistakes surface at compile time, not in prod.
- the standard API can't edit your classes. lombok edits javac's syntax tree instead.
- since java 23, declare processors explicitly. class-path discovery needs
-proc:full.