Fabrice Yopa
Software Engineer
Published on
10 min read

You Can't Be a Java Developer Without Lombok — Unless You've Been Living Under a Rock

Never touched @Data, @Getter, or @Slf4j? You've probably been coding under a rock. I traced how Lombok generates its boilerplate at compile time — going all the way back to the project's first commits, then trying to recreate @Getter and @Logger with my own hands.

You Can't Be a Java Developer Without Lombok — Unless You've Been Living Under a Rock

Introduction

If you've never used @Data, @Getter, or @Slf4j, you've probably spent years writing getters and setters by hand. Most modern Java projects rely on Lombok — but most developers never bother to find out how it actually works.

In this article, I dive into Lombok's internals: how it generates code at compile time, focusing on @Getter and @Logger. To really understand how it works, I traced the repository back to its first commits, built a minimal clone — and noted everything that broke along the way.

How Lombok Works Internally

The Lombok repository today is a maze: annotation handlers, AST transformers, separate execution paths for Eclipse and javac, fifteen years of patches stacked on top of each other. The master branch is not a good place to start.

So I went back to the very first commits. At that point, the structure was much simpler: a handful of annotation processors, a nascent separation between Eclipse and javac, and the first experiments with the AST — long before the list of supported annotations exploded.

To find those commits without spending an hour running git log --reverse, I built GitGenesis, a tool that isolates the first commits of any GitHub repository. For Lombok, the results point directly to Reinier Zwitserloot's initial scaffolding, back when @Getter was going from concept to working code.

Analyzing those first diffs taught me a lot: every Lombok annotation ultimately boils down to an annotation processor that rewrites the AST (the abstract syntax tree) before javac even finishes its job.

The Compilation Pipeline

Lombok hooks into javac's compilation process via the Annotation Processing API:

Source Code (.java) → Compiler → Annotation Processor → Generated Code → Bytecode (.class)

Before going further, here are the technical terms that come up constantly when reading the source code:

  • Annotation processors — classes implementing javax.annotation.processing.Processor.
  • Processing rounds — the compiler runs processors in a loop until no new annotations appear.
  • AST manipulation — instead of emitting separate .java files, Lombok directly grafts additional nodes (methods, fields) into the JCTree.
  • Bytecode — once the AST is modified, javac compiles it just like any other source.

This point is central: Lombok generates no PersonGetters.java file on disk. It inserts methods directly into the class's AST. They land in Person.class with correct line numbers and full type checking.

Building a Mini-Lombok

Reading the source code is a good start. Seeing what breaks when you try to reproduce the same mechanism is far more instructive.

So I decided to recreate two annotations:

  • @Getter — generates a public getter for an annotated field.
  • @Logger — injects the private static final Logger log declaration onto the class.

To structure this, I went with a Maven project split into two modules:

  • mini-lombok-processor — the annotation and processor logic.
  • mini-lombok-demo — a Person class that uses them, with no manually 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 manual getters)
        └── Main.java

The full code is available on my GitHub: lombok-internal.

On paper, the approach looks straightforward. That's where the walls started appearing.

The Obstacles

Obstacle #1: The Standard API Doesn't Let You Modify Existing Classes

My first idea was simple: extend AbstractProcessor, find the annotated fields, generate the getters. The standard API seemed like it would do the job:

@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)) {
            // Generating the getter is fine... but where does it go?
        }
        return true;
    }
}

The problem: AbstractProcessor only allows creating new files. The Filer interface offers createSourceFile() and createClassFile(), but there's no modifySourceFile(). Existing sources are read-only.

A naive processor would emit a side file like PersonGetters.java or Person_Generated.java. But Lombok injects its getters directly into Person.class. To do the same, you have to go lower.

The Solution: Manipulate the AST Directly

Libraries like ByteBuddy, ASM, or Javassist operate on .class files after compilation is done. Lombok, on the other hand, patches the AST mid-compilation — before a single byte of bytecode is generated. That's the trick that makes generated methods look like they were always in the source code.

So I followed the same path: direct manipulation of javac's AST.

Lombok infiltrates com.sun.tools.javac.* to mutate the AST directly in memory, right before javac emits the bytecode. If you add a node to that tree, javac compiles it. It's that simple.

ClassIts role
JCTree.JCClassDeclClass declaration node.
JCTree.JCMethodDeclMethod node.
JCTree.JCVariableDeclField or parameter node.
TreeMakerFactory for instantiating new AST nodes.
NamesHandles identifier interning (avoids using raw Strings).

The key line of this whole mechanism:

classNode.defs = classNode.defs.prepend(getter);

JCClassDecl.defs holds all members of a class — fields, methods, constructors. Since it's an immutable instance of com.sun.tools.javac.util.List, the trick is to replace it with a new list that includes the generated method. Once annotation processing is done, javac compiles the modified AST, and the getter ends up in the .class file as if it had always been there.

To access these low-level features, you first need to unwrap the standard ProcessingEnvironment to get its javac-specific internal 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);
}

The unwrap() call is necessary because build tools like IntelliJ or Gradle often wrap the ProcessingEnvironment in a dynamic proxy. Lombok handles this by recursively walking up a field named delegate through the class hierarchy using Java reflection.

Once you have the TreeMaker instance, building a getter is like assembling a tree:

private JCTree.JCMethodDecl createGetter(JCTree.JCVariableDecl field) {
    // Equivalent to: 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
    );
}

One important detail: call treeMaker.at(classNode.pos) before instantiating your new nodes. This sets the correct positions in the source file. Without it, the compiler points errors at completely random locations.

All these internal APIs live in the jdk.compiler module, which has been locked down by default since Java 9. You'll need to add the following --add-exports directives:

<!-- 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 would try to process its own sources recursively, and you'd get a beautiful infinite loop.


Obstacle #2: IntelliJ Shows Red Errors on Generated Code

mvn install goes through without a hitch. But opening the demo in IntelliJ brings a surprise: the Person class tries to use the log variable injected by @Logger, and the IDE responds:

Cannot resolve symbol 'log'

Maven compiles correctly. The editor is lost.

The reason: IntelliJ doesn't use javac for its real-time analysis. It relies on its own engine, the PSI (Program Structure Interface), which reads strictly from the raw text of source files. Since log isn't written anywhere in Person.java, PSI flags it as missing.

Classic processors that call Filer.createSourceFile() produce real .java files on disk that PSI can read. But processors that modify the AST on the fly leave no trace on disk — their changes exist only in memory, for the duration of the compilation.

That's exactly why Lombok ships such a massive IntelliJ plugin. It doesn't run the annotation processor in the background — it explicitly tells the PSI engine what each annotation will produce. It whispers to it: @Getter present here → expect a getXxx() method at runtime. It's an entirely separate codebase, kept in perfect sync with the main processor.

Faced with this problem in mini-Lombok, three options:

  • Live with the red squiggles — the project compiles and runs correctly from the command line, and it illustrates the underlying problem very concretely.
  • Delegate IDE builds to Maven (see obstacle #3) — the Run button works fine, even if the editor keeps complaining visually.
  • Build a real IntelliJ plugin — but that's a project in its own right, well beyond the scope of this experiment.

Obstacle #3: IntelliJ's Internal Compiler and --add-exports Refuse to Cooperate

Trying to run Main from IntelliJ's Run button hits a new error:

java: exporting a package from system module jdk.compiler is not allowed with --release

IntelliJ uses its own internal compiler by default, not Maven. In that process, it automatically passes --release 21, which conflicts with --add-exports on system modules. And the flags defined in pom.xml are ignored entirely.

The Fix: Delegate to Maven

Go to Settings > Build, Execution, Deployment > Build Tools > Maven > Runner > check "Delegate IDE build/run actions to Maven".

The Run action then invokes mvn exec:java in the background, and all flags defined in pom.xml are applied correctly.

A subtle detail: in mini-lombok-demo/pom.xml, the --add-exports argument must be prefixed with -J:

<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>

This prefix passes the flag directly to the JVM running javac, rather than to the compiler itself. Our annotation processor runs inside that host JVM — it needs these access rights at runtime. Without the -J, the flag would only affect the compilation of user code, not javac's execution environment.

That's also why --release is absent from the pom — it's incompatible with --add-exports on internal modules. We use instead:

<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

The Final Result

Once those three obstacles are cleared, here's what the clone looks like in action:

@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 getter written by hand.
}

No generated source file on disk, no parasitic utility class. The injected methods and fields live inside Person.class — invisible in the sources, but there at runtime.


What This Experiment Reveals About Lombok

Lombok is not a standard annotation processor. JSR-269 was designed to generate new files, full stop. Lombok chooses to modify existing AST nodes on the fly — which completely subverts the API from its original purpose. It's brilliant, and a little crazy.

The IntelliJ plugin represents an entirely separate maintenance burden. It forces a reimplementation of all the generation logic, this time for the PSI engine. Every new annotation in Lombok requires a double update: the Java processor on one side, the editor plugin on the other.

javac's internal APIs are unstable by nature. The packages under com.sun.tools.javac.* offer no compatibility guarantees between Java versions. That's why the Lombok team maintains a collection of shims for each major Java release. Building a tool on these foundations means a permanent maintenance burden.

See you soon and happy coding! 🔥