Java Boilerplate After Records: Where Lombok Still Fits
Published Updated Java 12 min read
Records took the value-class case Lombok was famous for. What remains is builders, logging and JPA entities, plus a real cost: annotation processing, a delombok step and generated equals that surprises Hibernate.
Lombok answered a real complaint. A class with six fields needed six getters, a constructor,
equals, hashCode and toString, and the interesting part was six lines out of two hundred.
@Data removed the noise.
Then Java grew records, and the headline case disappeared. A record gives accessors, a canonical
constructor, equals, hashCode and toString in the language itself, with no dependency and no
annotation processor. The reasonable question is no longer “how do I use Lombok” but “what is still
worth using it for”.
Written against Java 21, Lombok 1.18.x and Spring Boot 3.5.
What a record already does
public record Point(int x, int y) { }
That is the whole class. It compiles to a final class with two private final fields, accessors named
x() and y(), a canonical constructor, and value-based equals, hashCode and toString.
Compact constructors handle validation without repeating the assignments:
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) throw new IllegalArgumentException("negative coordinate");
}
}
For an immutable carrier of data, a DTO, a coordinate, a parsed configuration value, a result
object, there is no argument left for @Value or @Data. The record is shorter, needs no build
plugin, and every tool in the ecosystem understands it without help.
Where records stop
Three constraints decide most of the remaining cases.
A record is final and cannot extend anything. Inheritance hierarchies are out. This is rarely a loss, but it is absolute.
Every component is final. A record cannot model something that mutates, which rules out JPA entities, most form-backing objects and anything built up in stages.
The canonical constructor takes every component positionally. With four components that is fine.
With fourteen, half of them optional, a call site becomes an unreadable row of nulls and the
compiler cannot tell two adjacent String parameters apart.
That third constraint is where Lombok still earns its place.
What Lombok is still good at
@Builder
Records and builders are not in competition, and the two combine well. A record with many optional components gets a readable construction site without giving up immutability:
@Builder
public record HttpRequest(String url, String method, Duration timeout,
Map<String, String> headers, boolean followRedirects) { }
var req = HttpRequest.builder()
.url("https://example.com")
.method("POST")
.timeout(Duration.ofSeconds(5))
.build();
@Builder.Default supplies defaults for anything omitted, and on a record it applies to the
canonical constructor. Writing that builder by hand is forty lines that say nothing.
@Slf4j
@Slf4j
public class OrderService { }
replaces private static final Logger log = LoggerFactory.getLogger(OrderService.class);. It is one
line either way, but the hand-written version is one line that gets copied between classes and
occasionally keeps the wrong class literal, which then silently misroutes the logging configuration.
The annotation cannot make that mistake.
@RequiredArgsConstructor and constructor injection
The most-used Lombok annotation in a Spring codebase is not @Data. It is
@RequiredArgsConstructor, which generates a constructor over every final field:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orders;
private final PaymentGateway payments;
private final Clock clock;
}
Spring injects through the single constructor without an @Autowired annotation, so adding a
dependency is one line rather than three. This case has no record equivalent, because a Spring bean
is a managed component rather than a value, and it is the one annotation that survives almost every
argument against Lombok.
It also carries a quiet benefit. Because the constructor covers exactly the final fields, a
dependency that is not final does not get injected, which turns a mutable-collaborator mistake into
a null at startup rather than a bug at runtime.
JPA entities
An entity is mutable by requirement: the persistence provider needs a no-arg constructor and
setters, and the identity changes when the row is written. Records cannot express that.
@Getter, @Setter and @NoArgsConstructor still remove genuine noise here.
What must not be used on an entity is @Data or @EqualsAndHashCode, for reasons worth their
own section.
@SneakyThrows, and why it divides teams
@SneakyThrows lets a method throw a checked exception without declaring it. Lombok does this by
exploiting the fact that the checked-exception rule is enforced by the compiler and not by the JVM.
It genuinely helps in one place: a lambda passed to an interface whose signature declares no checked
exception, where the alternative is wrapping every call in a try that rethrows as
RuntimeException. It hurts everywhere else, because a caller reading the signature has no way to
know what can come out of the method, and no compiler will tell them.
Treat it as a local escape hatch inside a lambda, never as a way to avoid an exception strategy.
The costs, stated plainly
Annotation processing. Lombok rewrites the AST during compilation using internal compiler APIs. It works, and it has needed a compatibility release for essentially every JDK bump. On a new JDK the question is not whether the code compiles but whether Lombok has shipped support yet.
Tooling needs to know. IDEs need a plugin to see the generated members. Coverage tools count
generated methods against the total unless configured with a lombok.config carrying
lombok.addLombokGeneratedAnnotation = true.
Debuggers step through code that is not there. A breakpoint in a generated equals has no
source line, so stepping lands in the caller and the values have to be inspected from the frame.
For most teams this is a curiosity; for anyone debugging an equality bug it is an hour.
Delombok for anything that reads source. Javadoc and some static analysers see the original source, not the generated members, and need a delombok step in the build.
None of these is fatal. Together they are a real tax, and it is only worth paying where the generated code is substantial.
The three traps
@Data on a JPA entity
@Data generates equals and hashCode over every field. On an entity that is wrong in two
directions at once. Lazy associations are touched by the generated methods, so putting an entity in
a HashSet triggers loading, and outside a session that is a LazyInitializationException. And
because the identifier is null before the row is inserted, an entity’s hash changes after
persisting, so it can no longer be found in the set that already contains it.
The remedy is to write equals and hashCode by hand on the business key, or on the identifier
with a stable hash:
@Entity
@Getter @Setter @NoArgsConstructor
public class Customer {
@Id @GeneratedValue private Long id;
private String email;
@Override public boolean equals(Object o) {
return o instanceof Customer c && id != null && id.equals(c.id);
}
@Override public int hashCode() { return getClass().hashCode(); }
}
The constant hashCode looks wrong and is deliberate: it is stable across the transition from
transient to persistent, which is the property a hash set needs.
@Builder without @Builder.Default
A field with an initialiser plus @Builder loses the initialiser when the builder is used. The
field is silently null or zero, because the generated builder assigns every field and never runs the
declaration. Lombok warns about this, and the warning is easy to lose in a large build.
@EqualsAndHashCode and inheritance
On a subclass, the generated methods ignore superclass fields unless callSuper = true. Two
instances differing only in inherited state compare equal.
A short decision rule
Immutable data carrier, few components: record. Immutable, many or optional components:
record plus @Builder. Mutable by requirement, such as a JPA entity: @Getter/@Setter, and
hand-written equality. Logging: @Slf4j. Anything else the language now covers: prefer the
language.
The direction of travel matters more than any single rule. Every Java release moves another Lombok feature into the language, and code that leans on the language ages better than code that leans on a compiler plugin.
Frequently asked questions
Do records make Lombok obsolete?
No, they remove its most common use. Builders, logging annotations and mutable entities are still real cases.
Can a record use Lombok annotations?
Some. @Builder and @Slf4j work. Anything generating
setters or a no-arg constructor cannot, because a record’s components are final.
Why does my entity vanish from a HashSet after saving?
@Data generated a hashCode over all
fields including the generated identifier, so the hash changed when the row was inserted. Use a
constant hashCode and identifier equality.
What is delombok?
A build step that writes out the source with the generated members expanded, for tools that read source rather than bytecode, such as Javadoc.
Is Lombok safe to add to a new project in 2026?
It works and is widely used. The question worth asking is whether the project’s remaining boilerplate justifies an annotation processor tied to compiler internals, when a record covers the common case with no dependency.
Does @Value still have a use?
Rarely. It was the immutable-class annotation, and that is what a record is. It survives where a class must extend something, which a record cannot.
Why is my field null when built?
A field initialiser plus @Builder without @Builder.Default.
The builder assigns every field and never runs the declaration.
Does @Data include @ToString?
Yes, along with @Getter, @Setter, @EqualsAndHashCode and
@RequiredArgsConstructor. On entities that combination is the source of most Lombok incidents.
Do I need @NoArgsConstructor on a JPA entity?
The provider needs a no-arg constructor. It may be
protected rather than public, which @NoArgsConstructor(access = AccessLevel.PROTECTED)
expresses.
Does Lombok slow the build?
Marginally. The measurable cost is the extra compilation pass, not the runtime, since the generated code is ordinary bytecode.