All posts

Project Lombok in Java: What It Generates, How It Hooks In, and Where It Bites

November 3, 2026Michał Woliński27 min read
Project Lombok in Java: What It Generates, How It Hooks In, and Where It Bites

A Java class with three fields needs a constructor, accessors, equals, hashCode and toString before it does anything useful. Project Lombok writes them for you at compile time. A ten-line class in this article becomes 80 lines of plain Java, and nobody has to read or maintain those 80 lines. That is why so many Java codebases use it.

It is also the reason this tutorial covers more than the annotations. Lombok is not an ordinary library. It reaches inside the compiler, and the code it writes is code you never see. So the bugs it causes are also ones you never see: a cart item that can no longer be found in its set, a log line that kills a request, a failed refund that commits to the database. This article starts from zero and then reproduces each of those traps.

Everything runs on Java 25 and Lombok 1.18.48, and it lives in the companion repository. Every block of source code below is a real file there, and its first line says which one; the only exception is a generated method, and the text points it out. Claims about behaviour are backed by its 66 tests or by the compatibility script. Where I rely on documentation alone, the text says so.

The most important result concerns the future rather than any annotation. Lombok 1.18.48 needs sun.misc.Unsafe, the JDK plans to take that away, and when I switched it off today, the compiler crashed and produced nothing. That comes right after the vocabulary.

The vocabulary

Come back to this table when a later section uses one of these words.

termwhat it means
annotation processorA plugin that javac runs during compilation. The standard API lets it read code and create new files.
processor pathWhere javac looks for annotation processors, set with --processor-path.
syntax tree (AST)The compiler's in-memory model of your source code. Lombok adds methods to it.
value objectAn object defined by its contents, not its identity. Two 10 PLN amounts are the same amount.
recordJava's own language feature for immutable data carriers, final since Java 16.
delombokA Lombok tool that writes the generated code out as ordinary Java source.
entityA class mapped to a database table by JPA. It has an identity and a lifecycle, and Hibernate may subclass it.
proxyA generated subclass that stands in for the real object, used by Hibernate for lazy loading and by Spring for transactions.
checked exceptionAn exception the Java compiler forces you to declare or catch. IOException is one.

How Lombok works

You add an annotation, and methods appear. There is no reflection and no code generation at run time. It all happens once, inside javac.

A pipeline inside javac. Customer.java with @Data and 3 fields is parsed into a syntax tree with 3 fields. Lombok edits the tree, adding getters, setters, equals, hashCode, toString and a constructor. The compiler then checks and generates Customer.class, which is plain Java with no @Data inside.
Figure 1. Lombok runs inside the compiler and changes the tree the compiler is working on.

Ordinary annotation processors can only add new files. They cannot change the class you are compiling, which is why MapStruct generates a separate OrderMapperImpl. Lombok does change that class, and the standard API offers no way to do so. It reaches into javac's internal classes instead. Three consequences follow, and I measured each one.

The class file is plain Java. @Data has source retention, so it is gone from the compiled class, and a test confirms that Customer.class does not mention it. Another test compiles a class using @Data, @Builder, @NonNull and @SneakyThrows, then runs it with no Lombok on the class path. It works, which is why the dependency scope is provided. One caveat: this is a property Lombok has to keep getting right. The 1.18.48 changelog fixes a bug where @SneakyThrows on JDK 26 produced class files that needed lombok.jar at run time.

A new JDK can break it. Internal classes carry no compatibility promise, so Lombok needs a new release when the compiler's internals change. Here is what happened with a small class on real javac, as recorded by compat.sh in the repository:

JDKLombokresult
25.0.4.11.18.38 (older than JDK 25 support)exit 1, Error during the transformation of 'Plain'
25.0.4.11.18.40 (added JDK 25 support)compiles
271.18.46 (older than JDK 27 support)exit 3, ExceptionInInitializerError
271.18.48 (added JDK 27 support)compiles

So your JDK upgrade depends on a Lombok release. The project has mostly been quick: according to its changelog, 1.18.40 shipped on September 4, 2025, ahead of JDK 25, and 1.18.48 on September 1, 2026, ahead of JDK 27 (the build I tested is dated September 15). JDK 26 reached general availability on March 17, 2026 and its support arrived with 1.18.46 on April 22, about five weeks later.

The next break is already announced. Every successful compile on JDK 25 and 27 prints this:

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called WARNING: sun.misc.Unsafe::objectFieldOffset has been called by lombok.permit.Permit WARNING: Please consider reporting this to the maintainers of class lombok.permit.Permit WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release

lombok.permit.Permit is how Lombok gets past the module system to reach the compiler's internals. JEP 498 sets out the plan for these methods: a warning from JDK 24, and then "JDK 26 or later will throw an exception whenever a memory-access method is used". JDK 27 still only warned in my runs, so that step has not arrived. You can try it today with a flag, and I did:

JDK and Lombok 1.18.48, withresult
the defaultscompiles, prints the warning
-J--sun-misc-unsafe-memory-access=deny on JDK 25 and 27exit 3, ExceptionInInitializerError, caused by InaccessibleObjectException
the same flag plus ten -J--add-opens=jdk.compiler/... flags on JDK 27compiles

So there is an escape hatch, and the module system's own option is what opens it. I tried it on one small class, so treat it as evidence that a way out exists, not as a tested migration path; the list of packages is in compat.sh, and I did not minimise it. A real project can reproduce the failure with MAVEN_OPTS=--sun-misc-unsafe-memory-access=deny mvn compile, which also exits 1, because Maven compiles in its own process by default.

The practical advice is short. A job with deny would fail every day, so it tells you nothing new. Run a scheduled build on the JDK's newest early-access release instead: it goes red when the default changes or the internals move, while there is still time to plan. It will sometimes be red only because Lombok has not caught up yet, which is also what you want to know before upgrading. And read the Lombok changelog before each JDK upgrade.

Setting it up

This is the first place a tutorial written a few years ago will fail you. Adding the dependency used to be enough. On a current JDK it is not.

<!-- compat/no-processor-path/pom.xml --> <dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.48</version> <scope>provided</scope> </dependency> </dependencies>

With only that on JDK 25, Maven stops on the first generated member it needs:

[ERROR] /w/src/main/java/demo/Probe.java:[10,28] constructor Probe in class demo.Probe cannot be applied to given types;

Nothing in that message mentions Lombok. The reason is a change in javac's defaults. Its JDK 25 manual says annotation processing happens "if at least one other option is used to explicitly configure annotation processing", and having a processor on the class path does not count. JDK 21 still ran it, and printed a note warning that a future release might not.

Left panel, JDK 21: lombok.jar on the class path makes the processor run with a note, exit 0. Right panel, JDK 25: class path only means the processor is ignored and compilation fails. With --processor-path or -proc:full the processor runs.
Figure 2. The same jar on the class path: found on JDK 21, ignored on JDK 25.

The fix is what Lombok's Maven page calls "mandatory starting with JDK23": name it as a processor.

<!-- compat/with-processor-path/pom.xml --> <artifactId>maven-compiler-plugin</artifactId> <version>3.15.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.48</version> </path> </annotationProcessorPaths> </configuration>

Gradle needs the same two roles. Lombok's Gradle page uses compileOnly and annotationProcessor with the same coordinates, plus their test variants if your tests use Lombok. I took that from the documentation and did not run it. Your IDE also has to understand Lombok, or it will show errors on code that compiles. Check the setup page for your IDE.

Finally, put a lombok.config file in the project root. It holds settings that apply at compile time, and config.stopBubbling keeps them independent of whatever sits in the parent directories. The Jackson line matters only if you use Jackson 3, and it comes back later.

# lombok.config # Lombok reads this file at compile time. Commit it to the root of the project. config.stopBubbling = true # Jackson 3 builders for @Jacksonized (without this line Lombok warns and generates Jackson 2 annotations). lombok.jacksonized.jacksonVersion += 3

Your first class: @Data

// src/main/java/com/wolinski/shop/tour/Customer.java package com.wolinski.shop.tour; import lombok.Data; @Data public class Customer { private final long id; private String name; private String email; }

That is the whole class. The tests call new Customer(1), setName, getEmail, compare two instances with equals, and read Customer(id=1, name=Ann, email=ann@example.com) from toString. There is no setId, because the field is final, and the constructor takes only id, because it is the only final field.

@Data is shorthand for five annotations. Knowing the five matters, because every serious problem below comes from one of them doing its job on a class where you did not want it done.

@Data, in a class of 10 source lines, expands to five annotations: @Getter on every non-static field, @Setter on every non-static, non-final field, @RequiredArgsConstructor for final and @NonNull fields with no value yet, @ToString on every non-static field, and @EqualsAndHashCode on every non-static, non-transient field. Delomboked, the same class is 80 lines of plain Java.
Figure 3. @Data is five annotations. Each one is a choice, and @Data makes all five for you.

To see exactly what Lombok wrote, run delombok on the file. This is its equals for Customer, shortened to two of the fields. It compares every field, and it reads them through getters, which will matter when we get to Hibernate.

public boolean equals(final java.lang.Object o) { if (o == this) return true; if (!(o instanceof Customer)) return false; final Customer other = (Customer) o; if (!other.canEqual((java.lang.Object) this)) return false; if (this.getId() != other.getId()) return false; final java.lang.Object this$name = this.getName(); final java.lang.Object other$name = other.getName(); if (this$name == null ? other$name != null : !this$name.equals(other$name)) return false; return true; }

This block is the one exception to the rule about real files, since delombok generates it at build time. BuildTest runs that tool on Customer.java.

The annotations you will use

@Value and @With: immutable objects

@Value is the immutable sibling of @Data. The class becomes final, its fields become private and final, and there are no setters. @With adds a method that returns a changed copy.

// src/main/java/com/wolinski/shop/tour/Money.java package com.wolinski.shop.tour; import java.math.BigDecimal; import lombok.Value; import lombok.With; @Value public class Money { BigDecimal amount; @With String currency; }

zloty.withCurrency("EUR") returns a new object and leaves zloty alone. zloty.withCurrency("PLN"), the value it already has, returns the same instance. That is a sensible optimisation, and worth knowing if you ever compare with ==.

@Builder: construction by name

// src/main/java/com/wolinski/shop/tour/Order.java package com.wolinski.shop.tour; import java.util.List; import lombok.Builder; import lombok.NonNull; import lombok.Singular; import lombok.Value; @Value @Builder(toBuilder = true) public class Order { @NonNull String number; @Singular List<OrderLine> lines; @Builder.Default String status = "NEW"; }
// src/test/java/com/wolinski/shop/TourTest.java Order order = Order.builder() .number("A-1") .line(new OrderLine("BOOK", 1)) .line(new OrderLine("PEN", 2)) .build();

Three things in that class are worth learning together. @Singular generates line(...) for one element as well as lines(...) for many, and the built list rejects add with UnsupportedOperationException. @Builder.Default gives status a real default of NEW. toBuilder = true adds order.toBuilder().status("SHIPPED").build(), which copies and leaves the original intact.

@NonNull: a null check at the door

On a generated setter or constructor, or a method parameter, @NonNull inserts a check at the top. In the builder above it fires in build(), not when you forget the call, with the message number is marked non-null but is null. It is a run-time check that the code generator adds, and nothing more. It is not a static guarantee, and a later section shows where it does not reach.

Constructors and logging for services

This is the use I would defend the hardest, because it removes pure noise and hides no decision.

// src/main/java/com/wolinski/shop/tour/CheckoutService.java package com.wolinski.shop.tour; import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @RequiredArgsConstructor public class CheckoutService { private final Mailer mailer; private final Duration timeout = Duration.ofSeconds(5); public void place(Order order, Customer customer) { log.info("placing {}", order.getNumber()); mailer.send(customer.getEmail(), "Order " + order.getNumber() + " placed within " + timeout); } }

@RequiredArgsConstructor takes the final fields that have no value yet, so the constructor has one parameter, mailer, and timeout stays out. Spring uses a single constructor for injection automatically. @Slf4j adds a private static final field called log. There is also @NoArgsConstructor, and @AllArgsConstructor, which gets a warning from me below.

@SneakyThrows: the one to be suspicious of

It lets a method throw a checked exception without declaring it, by fooling the compiler. Lombok's page limits the use cases to strict interfaces such as Runnable and "impossible" exceptions. Keep it to those. A later section shows it committing a transaction that should have rolled back.

The rest of the box

You will meet these in other people's code. I did not test them for this article, so this table is my opinion plus Lombok's documentation.

annotationwhat it doesmy advice
val, varLocal type inference.Use Java's own var.
@CleanupCloses a resource at the end of the scope.Use try-with-resources.
@SuperBuilderBuilders across inheritance. Moved out of experimental in 1.18.48.Fine when you truly have the hierarchy.
@Accessors(fluent = true)Getters named name() instead of getName().If you want that, consider a record.
@UtilityClass, @Delegate, @ExtensionMethodExperimental features.Avoid in shared code: the experimental package is where features may change.
@Synchronized, @LockedLocking on private objects.Prefer an explicit lock, so readers see it.

Lombok or a record?

Before Java 16, the language had no answer to @Value. It has one now, and for a new data carrier a record is my default, since it needs no compiler hack and no dependency. The difference is in the details:

@Value classrecord
comes froma compiler pluginthe language
accessorgetNumber()number()
toStringMoney(amount=10.00, currency=PLN)Product[sku=BOOK, stock=3]
hide a field from the constructoryes, with an initializerno: the components are the state
can extend another classyesno
can be extendedno, unless you add @NonFinalno
pattern matching deconstructionnoyes
JavaBean frameworksworks by defaultdepends on the framework
deep immutabilityno, shallowno, shallow

They are not rivals in every respect, because Lombok works on records too. A record has no builder and no with methods, and Lombok can add them:

// src/main/java/com/wolinski/shop/tour/Product.java package com.wolinski.shop.tour; import lombok.Builder; import lombok.With; @Builder public record Product(String sku, @With int stock) {}

This is my favourite combination for a data carrier with many fields. The language owns the data, and Lombok writes only the convenience.

Typical problems

Each of these is a passing test in the repository. The column to read first is the middle one: most of them give you no signal at all.

problemwhat tells youwhat to do
Mutable key in a hash setnothingImmutable keys, or explicit equality fields
callSuper = falsenothing, once the warning is silencedcallSuper = true, if the parent has equals
Builder ignores an initializera warning@Builder.Default, and -Werror
Hand-written method replaces a generated onea compile error or a warningRename it, or write both equals and hashCode
toString leaks a secretnothingonlyExplicitlyIncluded
@NonNull gapsnothingDo not treat it as a guarantee
BigDecimal scalenothingNormalise the scale
Shallow @ValuenothingCopy collections on the way in
Sneaky exception in a transactionnothingthrows and a rollback rule
Lost @Qualifiera startup error, or nothinglombok.copyableAnnotations
Jackson namesnothingPlain field names, and a test on the JSON
MapStruct ordernothingThe binding

A mutable key in a hash set

// src/main/java/com/wolinski/shop/traps/CartItem.java package com.wolinski.shop.traps; import lombok.Data; @Data public class CartItem { private final String sku; private int quantity; }

@Data includes quantity in hashCode and generates its setter, a combination that does not belong together. Change the quantity of an item in a HashSet, and the set can no longer find it:

// src/test/java/com/wolinski/shop/TrapsTest.java CartItem book = new CartItem("BOOK"); Set<CartItem> cart = new HashSet<>(); cart.add(book); book.setQuantity(2); assertThat(cart.contains(book)).isFalse(); assertThat(cart.remove(book)).isFalse(); assertThat(cart).hasSize(1);
A HashSet of CartItem with three buckets. The item BOOK sits in bucket 3 with quantity 1 and is then set to 2. A contains call computes the new hash, looks in bucket 12 and finds it empty. The entry stays in bucket 3, where it cannot be found and cannot be removed.
Figure 4. The object did not move, but its hash did. The bucket numbers are illustrative.

The element is still in the set, it counts towards its size, and remove returns false. In a long-lived set this is also a memory leak. Use immutable objects as keys, or restrict equality to fields that never change, with @EqualsAndHashCode(onlyExplicitlyIncluded = true).

Inheritance and callSuper

Put @Data on a subclass and Lombok generates equals from the subclass's own fields. The compiler warns: Generating equals/hashCode implementation but without a call to superclass. The easy response is to silence it the way the message suggests:

// src/main/java/com/wolinski/shop/traps/Coupon.java package com.wolinski.shop.traps; import lombok.Data; import lombok.EqualsAndHashCode; // callSuper = false silences Lombok's warning and keeps exactly what the warning was about. @Data @EqualsAndHashCode(callSuper = false) public class Coupon extends Discount { private final String code; public Coupon(int percent, String code) { super(percent); this.code = code; } }

Now new Coupon(10, "WELCOME") equals new Coupon(50, "WELCOME"), because percent lives in the parent. A 10% coupon and a 50% coupon are "the same". The warning was correct, and callSuper = false only removed the evidence. Use callSuper = true, which FixedCoupon in the repository does, and which makes those two unequal.

That advice has a condition, though: the parent needs a value-based equals of its own. If it only inherits Object.equals, callSuper = true compares identity, and two identical objects are never equal. A sample with such a parent compiles without a warning and prints equal=false. If the parent has no equals, either give it one, or keep callSuper = false deliberately and accept that its fields do not count.

A builder ignores your initializer

// src/main/java/com/wolinski/shop/traps/RetryPolicy.java package com.wolinski.shop.traps; import lombok.Builder; import lombok.Getter; @Getter @Builder public class RetryPolicy { private final String name; private int maxAttempts = 3; @Builder.Default private int backoffMillis = 200; }

RetryPolicy.builder().name("mail").build() gives backoffMillis == 200 and maxAttempts == 0, not 3. Without @Builder.Default, the initializer is dropped. Lombok warns once at compile time (@Builder will ignore the initializing expression entirely), and a policy that makes zero attempts does not fail until a retry is needed. Treat warnings as errors in CI: a test confirms -Werror fails a class like this and leaves clean Lombok code alone, and the JVM's Unsafe message does not trip it, even with -Xlint:all.

Adding @Builder.Default has a side effect of its own. Lombok moves the initializer out of the field, so a constructor you wrote by hand no longer gets it, as its docs warn. In Invoice, the builder gives currency == "PLN", and new Invoice("F-1") gives null, from the same line of source that reads = "PLN".

Hand-written methods switch off generated ones

Lombok decides by name and number of parameters, not by type. The @Data page says of that match that "no warning or error is emitted". So a setQuantity(String) you added for a form removes the generated setQuantity(int), and setQuantity(5) then stops compiling. That one announces itself.

Equality is worse. Add a harmless-looking overload equals(HandWrittenEquals other), and Lombok generates neither equals(Object) nor hashCode. Here you do get a warning, Not generating hashCode: One of equals or hashCode exists, but the build passes, and two items with the same sku are then distinct in a HashSet: the test prints distinct=2.

toString prints your secrets

// src/main/java/com/wolinski/shop/traps/Account.java package com.wolinski.shop.traps; import lombok.Data; import lombok.ToString; @Data public class Account { private final String login; @ToString.Exclude private final String password; private final String apiKey; }

Somebody remembered password, and nobody remembered apiKey when it was added months later. toString() contains apiKey=sk-123, on its way to your logs. Exclusion lists fail open: a new field is printed by default. For a class that holds anything sensitive, use @ToString(onlyExplicitlyIncluded = true), so that a new field stays hidden until someone decides otherwise. A record's default toString prints every component too.

@NonNull has gaps

// src/main/java/com/wolinski/shop/traps/Shipment.java package com.wolinski.shop.traps; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class Shipment { @NonNull private String address; public static Shipment byHand(String address) { Shipment shipment = new Shipment(); shipment.address = address; return shipment; } }

new Shipment(null) and setAddress(null) both throw. new Shipment() does not, and leaves the "non-null" field null. Nor does byHand(null), because a direct field assignment contains no generated check. The annotation guards the code Lombok writes, not the field.

BigDecimal and the equality you did not choose

The vocabulary table says that two 10 PLN amounts are the same amount. Money from earlier disagrees: new Money(new BigDecimal("10.0"), "PLN") does not equal new Money(new BigDecimal("10.00"), "PLN"). Lombok defers to BigDecimal.equals, which counts the scale, whereas compareTo calls them equal. A record behaves the same way, since it also uses equals on its components. For money, normalise the scale in a factory, or write equals and hashCode by hand, both of them, for the reason given above. Generated equality is only as correct as the equals of each field.

@Value is only shallow

Basket is a @Value with a List<String> field. Add to the list you passed in, and the "immutable" basket changes, along with its hashCode. basket.getSkus().clear() empties it too. Copy collections on the way in, with List.copyOf in a hand-written constructor or through @Singular, which does it for you: a test clears the source list after build() and the order keeps its line. Records have exactly the same limit.

Sneaky exceptions commit transactions

I find this the most dangerous item on the list, since the code looks tidier than the correct version.

// src/main/java/com/wolinski/shop/tx/RefundService.java package com.wolinski.shop.tx; import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class RefundService { private final JdbcTemplate jdbc; private final PaymentGateway gateway; @Transactional @SneakyThrows public void refund(String order) { jdbc.update("insert into refund(order_number) values (?)", order); gateway.send(order); } @Transactional(rollbackFor = Exception.class) public void refundDeclared(String order) throws IOException { jdbc.update("insert into refund(order_number) values (?)", order); gateway.send(order); } public interface PaymentGateway { void send(String order) throws IOException; } }

Spring's documented default is that "Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration". The rule is applied at run time, to the real exception type, so hiding it from the compiler changes nothing. In a test against a real database with a gateway that throws IOException, the row is committed: a refund recorded for a payment that never went out.

The method annotated @SneakyThrows inserts a row and then throws IOException. The transaction advice asks whether it is a runtime exception: no, so it commits and the row stays. The exception continues through the CGLIB proxy, which wraps it, so the caller receives UndeclaredThrowableException wrapping the IOException.
Figure 5. Two surprises from one annotation: the commit, and the exception type the caller receives.

The test found a second surprise I had not planned for. My first assertion expected the caller to get an IOException, and it failed. The class-based proxy cannot rethrow a checked exception the method does not declare, so it wraps it in java.lang.reflect.UndeclaredThrowableException. A handler for IOException further up will not run. refundDeclared, with throws IOException and rollbackFor = Exception.class, gets both things right: no row, and the original exception. It needs both parts. The throws clause gives the caller the real exception, and the rollback rule is what undoes the row, since by the rule quoted above a declared IOException still commits.

If you want this safe across a whole application, Spring has a switch for it: @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS). A test confirms it rolls the sneaky case back, and that the caller still gets the wrapper. One caution about the example itself: it calls a payment gateway inside a database transaction to keep the demonstration short. In a real system that is a dual write, since a rollback cannot take back a payment that did go out. That is what the outbox pattern is for.

A qualifier that disappears

// src/main/java/com/wolinski/shop/spring/PriceAlerts.java package com.wolinski.shop.spring; import com.wolinski.shop.tour.Mailer; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; @Getter @RequiredArgsConstructor public class PriceAlerts { @Qualifier("fast") private final Mailer mailer; }

The qualifier stays on the field, and the generated constructor parameter, which is what Spring reads, does not get it. What happens next depends on your bean names, and one of the two outcomes is much worse than the other:

beans of type Mailerresult
fast, slowStartup fails with NoUniqueBeanDefinitionException. Annoying, and safe.
fast, mailerStartup succeeds, and the class gets mailer, not the fast it asked for.

In the second row Spring falls back to matching the parameter name, which is available because the build compiles with -parameters. This repository turns that on in its POM, and Spring Boot's parent POM does it for you. So in a typical Boot project, a field that names one bean and qualifies another silently gets the wrong one. Adding one line to a lombok.config fixes both rows. It belongs in your root config; the repository has it in one package so that, since configuration applies per directory, it can show both behaviours side by side:

# src/main/java/com/wolinski/shop/spring/copying/lombok.config # A lombok.config applies to its directory and everything below it, and adds to the one at the project root. lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

Jackson sees different names

// src/main/java/com/wolinski/shop/jackson/Contact.java package com.wolinski.shop.jackson; import lombok.Data; @Data public class Contact { private String eMail; private boolean isActive; private Boolean isVip; }

Lombok has rules for accessor names, and Jackson has its own for turning them back into properties. Here is the same object serialized:

fieldLombok generatesJSON, Jackson 3.2.4JSON, Jackson 2.22.4
String eMailgetEMail()eMailemail
boolean isActiveisActive(), setActive()activeactive
Boolean isVipgetIsVip(), setIsVip()isVipisVip

Each row is a trap. A field named isActive loses its prefix in the JSON, while the same name on a Boolean keeps it. And eMail changes its public name when you upgrade Jackson, without a line of your code changing.

The obvious fix is to put @JsonProperty on the field, and that makes it worse. Since 1.18.40 Lombok no longer copies that annotation to the accessors, so Jackson sees the field and the getter as two different properties and writes both. Here is the same class with @JsonProperty("eMail") and @JsonProperty("isActive") on its fields:

variantJSON, Jackson 3.2.4JSON, Jackson 2.22.4
@JsonProperty on the fieldsEMail, eMail, active, isActiveemail, eMail, active, isActive
the same, with lombok.copyJacksonAnnotationsToAccessors = trueeMail, isActiveeMail, isActive
no annotations, fields renamed email and activeemail, activeemail, active

The last row is the one to prefer: avoid names like eMail, avoid is on a boolean, and both Jackson versions agree. If the wire name is already fixed and has to differ, use the config key from the middle row. Either way, pin the JSON with a test if it is a contract.

The other Jackson trap is @Data @Builder together. @Builder adds an all-arguments constructor, so @Data stops generating its own and no no-argument constructor exists. Deserializing Parcel fails with a "no Creators" error. @Jacksonized is the intended fix: it points Jackson at the builder. Since 1.18.44 it needs the jacksonVersion line from the lombok.config above if you are on Jackson 3.

// src/main/java/com/wolinski/shop/jackson/JacksonParcel.java package com.wolinski.shop.jackson; import lombok.Builder; import lombok.Value; import lombok.extern.jackson.Jacksonized; @Value @Builder @Jacksonized public class JacksonParcel { String id; int grams; }

MapStruct and processor order

MapStruct is also an annotation processor, and it has to see the getters that Lombok creates. Lombok's changelog says that since 1.18.16 "mapstruct users should now add a dependency to lombok-mapstruct-binding". I wanted to see what happens otherwise, so I compiled the same mapper with the processors in each order:

Four processor path orders, all exit 0. Lombok then MapStruct gives mapped=A-1. MapStruct then Lombok gives mapped=null. Lombok, MapStruct and the binding gives mapped=A-1. The binding, MapStruct and Lombok gives mapped=A-1.
Figure 6. Without the binding, reordering two lines in a POM silently produces a mapper that maps nothing.

Every build exits 0. The second one has no error in it at all, and at run time it returns an object with null fields. Add the binding, and both orders I tried work:

<!-- pom.xml --> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${mapstruct.version}</version> </path> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok-mapstruct-binding</artifactId> <version>${lombok-mapstruct-binding.version}</version> </path> </annotationProcessorPaths>

JPA entities: do not use @Data

Entities are where @Data does the most damage. Here is the pattern, with a relation in both directions:

// src/main/java/com/wolinski/shop/jpa/BadCustomer.java package com.wolinski.shop.jpa; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import java.util.ArrayList; import java.util.List; import lombok.Data; @Entity @Data public class BadCustomer { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) private List<BadOrder> orders = new ArrayList<>(); }

BadOrder mirrors it, with a lazy @ManyToOne back to the customer. I ran this on Hibernate 7.4 with H2, and each of these is a test:

BadCustomer and BadOrder, both @Data, call each other's toString in a loop. Three outcomes follow. With linked objects, StackOverflowError. With the session open, an extra SELECT from a log line. With the session closed, LazyInitializationException.
Figure 7. One generated toString on a mapped relation, three different failures depending on the state of the objects.
  • Linked objects overflow the stack. toString and hashCode each go customer to orders to customer until StackOverflowError.
  • A log line runs a query. With the session open, toString initialises the lazy collection. Hibernate's statistics count one collection load for that single call.
  • A log line throws. With the session closed, the same toString throws LazyInitializationException.
  • Persisting moves the hash. hashCode includes id, which goes from null to a value on persist. An entity added to a HashSet beforehand cannot be found afterwards.
  • Different customers are equal. Two new entities with the same name, both with a null id, are equal, so a set treats them as one.

@Value is no way out. A @Value entity persists, and then fails on the first load with No default constructor for entity: Hibernate's guide says the class needs a no-argument constructor and should not be final. The failure arrives after you have written the data.

What works is to choose each annotation, with equality and toString restricted to fields you name:

// src/main/java/com/wolinski/shop/jpa/GoodCustomer.java package com.wolinski.shop.jpa; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import java.util.ArrayList; import java.util.Collections; import java.util.List; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.NaturalId; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class GoodCustomer { @Id @GeneratedValue @ToString.Include private Long id; @NaturalId @Column(nullable = false, updatable = false) @EqualsAndHashCode.Include @ToString.Include private String number; @Setter private String name; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) private List<GoodOrder> orders = new ArrayList<>(); public GoodCustomer(String number, String name) { this.number = number; this.name = name; } public List<GoodOrder> getOrders() { return Collections.unmodifiableList(orders); } public void addOrder(GoodOrder order) { orders.add(order); order.assignTo(this); } }

getOrders is written by hand because @Getter would hand out the mutable list and let callers skip addOrder; Lombok leaves a method alone when it already exists. Equality rests on a natural key, the customer number, which is assigned before persist and never changes. Hibernate's user guide agrees that "using a natural-id is best for equals and hashCode". If you have no such key, the same section describes comparing by identifier with a constant hashCode. I did not test that variant.

The tests show this entity staying in a HashSet through persist, producing a short toString after the session closes, and comparing equal to a Hibernate proxy of itself in both directions. The last one works because Lombok's generated equals uses instanceof and reads through getters, as we saw earlier, so a proxy subclass passes and initialises itself.

That has a price, and it is the limit of this design. Hashing an uninitialised proxy runs one SQL statement to fetch the number. Hashing one after its session has closed throws LazyInitializationException, the same exception as before, now from a much smaller surface. So load what you intend to put in a set, and do so inside the session.

A puzzle: the diff with no logic in it

A colleague tidies up this class before a release. The field order now matches the order on the bank's form, nothing else changes, and the caller is not touched.

// src/test/resources/samples/reorder/before/Transfer.java import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor public class Transfer { private final String payer; private final String payee; private final long cents; }
// src/test/resources/samples/reorder/after/Transfer.java import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor public class Transfer { private final String payee; private final String payer; private final long cents; }
// src/test/resources/samples/reorder/Caller.java public class Caller { public static void main(String[] args) { Transfer transfer = new Transfer("alice", "bob", 100); System.out.println("payer=" + transfer.getPayer() + " payee=" + transfer.getPayee()); } }

The whole project compiles, with no warnings. Reflection is not involved, and you have all the code. What does the program print, compared with before?

The answer

Before the tidy-up it printed payer=alice payee=bob. After it, payer=bob payee=alice. The money goes the other way.

@AllArgsConstructor creates its parameters in field order (its documentation says so in a single sentence), so moving a field changes the constructor. Both are String, so the old call still compiles, now with its arguments meaning the opposite. In a hand-written class the constructor is separate code and the reorder would not touch it. With Lombok, field order is part of your public API, and nothing in the diff says so.

Four things to take away, and the tests cover the first two:

  • A builder survives it. Transfer.builder().payer("alice").payee("bob") gives the right answer after the same reorder, because the names travel with the values.
  • A record does not. Reordering components of record Transfer(String payer, String payee, long cents) swaps the values in the same way. So this is not specific to Lombok; it is a risk of any positional constructor with neighbouring parameters of one type.
  • Types fix it at the root. With separate Payer and Payee types the swapped call would not compile.
  • You can ban the annotation. lombok.allArgsConstructor.flagUsage = error stops the explicit form. By its docs that does not cover the constructor @Value generates, so such a class has the same risk.

Team rules with lombok.config

Lombok has a feature that is easy to miss: it can ban itself, one annotation at a time.

# src/test/resources/samples/policy/lombok.config config.stopBubbling = true lombok.data.flagUsage = error lombok.sneakyThrows.flagUsage = warning

With that file in place, a class using @Data fails to compile with Use of @Data is flagged according to lombok configuration. The warning on @SneakyThrows is a warning like any other, so with -Werror it stops the build too. Scope matters here: in the root config this bans @Data everywhere, so to stop the entity problem coming back while you keep it elsewhere, put the file in your entity package, since a config applies to its directory and below. If the choice deserves an explanation, write it down as an architecture decision record. Note that this is the right tool, and ArchUnit is not: ArchUnit reads compiled classes, and a test here confirms that @Data does not survive compilation.

One catch when you roll such a rule out: lombok.config is not an input Maven tracks. In compat.sh, I add that ban to a compiled project: mvn compile exits 0, because it sees nothing to do, and mvn clean compile exits 1. Make sure CI builds clean, and run clean yourself after changing the config.

There is one more setting worth knowing about. Since 1.18.34, Lombok marks everything it generates with @lombok.Generated, which javap confirms in the class file. The JVM does not load that annotation at run time, so it does not contradict the earlier test that ran without Lombok. Per Lombok's docs, that is useful for JaCoCo and similar tools, so generated equals does not count against your coverage. That cuts both ways: the coverage report will not point you to a generated method nobody ever tested.

Getting out: delombok

Adopting Lombok is reversible, and that should make the decision easier. java -jar lombok.jar delombok src -d out writes plain Java for every class, and a real project also passes its dependencies with --classpath. I tried it on Customer.java, which has none, and there is a catch. The default output does not compile without Lombok:

error: package lombok does not exist

The generated methods carry the same @lombok.Generated annotation. Set lombok.addLombokGeneratedAnnotation = false in a config file first, run delombok again, and the result compiles with -proc:none. Customer.java goes from 10 lines to 80.

The output is mechanical, though. equals keeps names such as this$name, so plan to replace the generated equals with something readable, or to turn the class into a record, rather than keeping everything it wrote.

What to reach for

A decision flow. If a class holds data and never changes, use a record, with @Builder if it has many fields. Otherwise, if it is a JPA entity, use @Getter with explicit equals and toString. Otherwise, if it is a Spring bean or service, use @RequiredArgsConstructor and @Slf4j. Otherwise use @Getter and think before adding @Setter.
Figure 8. The short version. @Data does not appear in it.

Should you use Lombok at all? It is a real trade, and I would put it this way:

  • If you are starting today on a current JDK, records cover much of what Lombok was first adopted for. What is left is mostly builders, service constructors and logging. Consider whether that is worth a dependency on compiler internals, and an IDE plugin. If you mainly want generated value classes, Immutables and AutoValue use the standard processor API, generating new classes rather than editing yours. I did not test those here.
  • If you already have it, there is no emergency. Set the rules in lombok.config, ban @Data in your entity packages, fail the build on warnings, and move new data carriers to records.
  • In either case, build on the JDK's early-access releases, because that is the risk you will not see in day-to-day work.

There is also a cost that no test here shows: anything that reads your source instead of your class files sees a smaller program than the compiler does. Lombok's own delombok page says "lombok cannot plug into javadoc", which is the reason delombok exists. The same applies to other source-based tools, so if you use one, such as a Kotlin compiler in a mixed project, check how it handles Lombok first.

Scope and how this was checked

The test suite (./run.sh, 66 tests) runs on Temurin 25 in a Docker image pinned by digest, using Lombok 1.18.48, Hibernate 7.4.14, H2 2.5.252, Spring 7.0.10, Jackson 3.2.4 and 2.22.4, and MapStruct 1.6.3 with binding 0.2.0. Claims about the build are tested with real javac and java processes rather than an in-process compiler, and compat.sh repeats them across JDK 21, 25 and 27; its output is committed in results/compat.txt.

Some limits. The compatibility rows come from compiling one small class, so a green row does not prove your project will build. I did not measure compile-time cost, and I did not test Gradle or any IDE. H2 stands in for a production database; the entity behaviour tested here comes from Hibernate and the generated methods rather than from SQL. The Spring test uses a class-based proxy, and I did not run an interface-based one. And @Data is fine on a small, private, mutable class that never goes into a set or a database; this article picks on it because that is rarely where it ends up.

Related Articles