- Published on
- •8 min read
You Can't Be a Java Developer Without Lombok — Unless You Lived in a Cave
Never touched @Data, @Getter, or @Slf4j? You've been coding in a cave. I traced how Lombok generates boilerplate at compile time — starting from the project's first commits, then rebuilding @Getter and @Logger myself.
- Authors

- Name
- Fabrice Yopa
- @yopafabrice
You Can't Be a Java Developer Without Lombok, Unless You Lived in a Cave
Introduction
If you've never used @Data, @Getter, or @Slf4j, you probably spent years writing getters and setters by hand. Most Java projects use Lombok. Most developers never look at how it works.
This article covers compile-time code generation in Lombok, focused on @Getter and @Logger. I read the early source, built a minimal clone, and wrote down what broke along the way.
- Introduction
- How Lombok Works Internally
- Practical Examples
- Debugging and Troubleshooting
- What this reveals about Lombok
How Lombok Works Internally
The current Lombok repo is hard to navigate — annotation handlers, AST transformers, separate Eclipse and javac paths, fifteen years of patches on top of patches. master is not a good starting point.
I went back to the earliest commits instead. The structure is simpler there: a handful of annotation processors, an early split between Eclipse and javac, and the first AST experiments before the annotation surface grew.
Finding those commits manually means cloning the repo, running git log --reverse, and scrolling. I built GitGenesis to skip that — it lists the first commits of any GitHub repo. For Lombok, the results point straight at Reinier Zwitserloot's initial scaffolding, when @Getter went from idea to working code.
Those early diffs helped: every Lombok annotation still boils down to an annotation processor rewriting the AST before javac finishes.
The compile pipeline
Lombok hooks into javac during compilation via the Annotation Processing API:
Source Code (.java) → Compiler → Annotation Processor → Generated Code → Bytecode (.class)
A few terms that show up everywhere when you read the source or try to replicate it:
- Annotation processors — classes implementing
javax.annotation.processing.Processor - Processing rounds — the compiler runs processors in rounds until no new annotations appear
- AST manipulation — Lombok adds nodes to
JCTree(methods, fields) instead of emitting separate.javafiles - Bytecode — once the AST is patched, javac compiles it like any other source
Lombok does not generate a companion PersonGetters.java. It inserts methods directly into the class AST, so they land in Person.class with correct line numbers and full type-checking.
Practical Examples
Reading the source only gets you part of the way. I wanted to see what broke when I tried to build it myself.
I recreated two annotations:
@Getter— public getter for an annotated field@Logger—private static final Logger logon the class
Two-module Maven layout:
mini-lombok-processor— annotations and processorsmini-lombok-demo— aPersonclass using them, no hand-written getters
lombok-internal/
├── mini-lombok-processor/
│ └── src/main/java/me/fayolabs/minilombok/
│ ├── Getter.java
│ ├── GetterProcessor.java
│ ├── Logger.java
│ └── LoggerProcessor.java
└── mini-lombok-demo/
└── src/main/java/me/fayolabs/demo/
├── Person.java (@Logger + @Getter, no getters written)
└── Main.java
Code on GitHub: lombok-internal.
Simple enough. Then I started hitting walls.
Debugging and Troubleshooting
Blocker #1: The standard API won't let you modify existing classes
First attempt: extend AbstractProcessor, find annotated fields, generate getters. The API looked sufficient.
@SupportedAnnotationTypes("me.fayolabs.minilombok.Getter")
public class GetterProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (Element element : roundEnv.getElementsAnnotatedWith(Getter.class)) {
// generate getter... but where?
}
return true;
}
}
AbstractProcessor can only create new files. Filer exposes createSourceFile() and createClassFile(). There is no modifySourceFile(). Existing sources are read-only.
A naive processor would emit PersonGetters.java or Person_Generated.java. Lombok puts getters inside Person.class.
The fix: go below the standard API
Bytecode libraries (ByteBuddy, ASM, Javassist, cglib) work on .class files after compilation. Lombok patches the AST during compilation, before bytecode exists. Generated methods look like they were in the source from the start. ByteBuddy or ASM could do something similar via a Java agent at class-load time, but that's a different integration point.
For this demo I followed Lombok's path: javac AST manipulation.
Lombok reaches into com.sun.tools.javac.* and mutates the AST — the in-memory tree javac builds before emitting bytecode. Add a node, javac compiles it.
| Class | Role |
|---|---|
JCTree.JCClassDecl | Class declaration node |
JCTree.JCMethodDecl | Method node |
JCTree.JCVariableDecl | Field or parameter node |
TreeMaker | Factory for new AST nodes |
Names | Interns identifiers (not raw String) |
The important line:
classNode.defs = classNode.defs.prepend(getter);
JCClassDecl.defs holds everything inside a class — fields, methods, constructors. It's an immutable com.sun.tools.javac.util.List, so you replace it with a new list that includes the generated method. After annotation processing, javac compiles the modified AST. The getter ends up in .class as if it had been in the source.
To get there, unwrap ProcessingEnvironment to the javac-specific implementation:
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
JavacProcessingEnvironment javacEnv =
(JavacProcessingEnvironment) unwrap(processingEnv);
Context context = javacEnv.getContext();
this.treeMaker = TreeMaker.instance(context);
this.names = Names.instance(context);
this.trees = Trees.instance(processingEnv);
}
unwrap() is needed because IntelliJ and Gradle sometimes wrap ProcessingEnvironment in a proxy. Lombok walks a delegate field up the class hierarchy via reflection.
With TreeMaker in hand, building a getter is tree construction:
private JCTree.JCMethodDecl createGetter(JCTree.JCVariableDecl field) {
// public FieldType getFieldName() { return this.fieldName; }
return treeMaker.MethodDef(
treeMaker.Modifiers(Flags.PUBLIC),
names.fromString(toGetterName(field.name.toString())),
field.vartype,
List.nil(), List.nil(), List.nil(),
treeMaker.Block(0L, List.of(
treeMaker.Return(
treeMaker.Select(treeMaker.Ident(names._this), field.name)
)
)),
null
);
}
Call treeMaker.at(classNode.pos) before creating nodes. That sets source positions for error reporting. Without it, compiler errors point at wrong locations.
These APIs live in jdk.compiler, closed by default since Java 9. You need --add-exports:
<!-- mini-lombok-processor/pom.xml -->
<compilerArgs>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
</compilerArgs>
Also add -proc:none when compiling the processor module itself. Otherwise it tries to process its own sources and loops.
Blocker #2: IntelliJ shows red errors on generated code
mvn install passed. I opened the demo in IntelliJ. Person uses log from @Logger:
Cannot resolve symbol 'log'
Maven builds fine. The editor does not.
IntelliJ does not use javac for in-editor analysis. It runs PSI (Program Structure Interface) on the source files as written. log is not in Person.java, so PSI reports it missing.
Processors that call Filer.createSourceFile() produce .java files PSI can read. AST-modifying processors leave no file — the change exists only in javac's memory during a build.
That is why Lombok ships an IntelliJ plugin. It does not run the processor. It tells PSI what each annotation will generate. @Getter means "expect a getXxx() here." Separate code path, maintained alongside the processor.
For mini-Lombok, the realistic options:
- Live with the red squiggles — compiles and runs, and it shows the problem clearly
- Delegate builds to Maven (see blocker #3) — Run works even if the editor still complains
- Write an IntelliJ plugin — another project entirely
Blocker #3: IntelliJ's compiler and --add-exports don't get along
Running Main from IntelliJ:
java: exporting a package from system module jdk.compiler is not allowed with --release
IntelliJ compiles with its own integration, not Maven. It passes --release 21, which conflicts with --add-exports on system modules. The flags in pom.xml are ignored on this path.
The fix: delegate the build to Maven
Settings > Build, Execution, Deployment > Build Tools > Maven > Runner > "Delegate IDE build/run actions to Maven"
Run then invokes mvn exec:java. The pom.xml flags apply.
One flag detail: in mini-lombok-demo/pom.xml, --add-exports uses the -J prefix:
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
-J passes the flag to the JVM running javac, not to javac itself. The processor runs inside that JVM and needs runtime module access. Without -J, the flag only affects compilation of user code.
Same reason --release is absent from the pom — incompatible with --add-exports on system modules:
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
The result
After the three blockers:
@Logger
public class Person {
@Getter private String name;
@Getter private int age;
@Getter private String email;
public Person(String name, int age, String email) { ... }
public void introduce() {
log.info("Hi, I'm " + name); // 'log' injected by @Logger
}
// no getters written
}
No generated source files. No helper classes. Methods and fields live in Person.class.
What this reveals about Lombok
Lombok is not a standard annotation processor. JSR-269 was built for generating new files. Lombok modifies existing AST nodes — outside what the API was designed for.
The IntelliJ plugin is separate work. It reimplements generation logic in PSI terms. New Lombok annotations need processor and plugin updates.
The javac APIs are unstable. com.sun.tools.javac.* has no compatibility guarantee. Lombok maintains version-specific shims for a reason. Building on these APIs means ongoing maintenance.
Peace and see you next time! 🔥