You open a new Spring Boot project and you create a DTO, then an entity, and you’re staring at getters, setters, constructors, equals(), hashCode(), toString(). Someone on the team suggests to put lombok’s @Data, or “just slap @Builder on it, it’ll be cleaner.” Forty lines become five and it looks great in the PR.
Then it hits a real codebase, OpenAPI generation doesn't behave the way the build expects. Hibernate meets an auto-generated equals() and gets confused about identity. Something throws through a generated builder hierarchy at 2 AM, and the method you need to inspect doesn't exist in any file you can open.
Eleven years into enterprise Java, my rule is simple: Lombok doesn't touch core application behavior. Not because writing a getter is interesting - it isn't, but because the handful of lines it saves rarely covers the compiler magic, tooling friction, and debugging problems it adds to something that has to survive for years after you've moved on to another project.
"It just removes boilerplate"
Worth asking what's actually being removed, though. A getter is part of your public API. A setter is a mutation point someone decided to expose. A constructor defines what states an object is allowed to enter. equals() and hashCode() define identity. toString() is what shows up in your logs when things go wrong at 3 AM.
Write those by hand and they live in the source - visible, searchable, debuggable, owned by whoever's reading the file. Generate them with Lombok and the behavior is still there, it's just moved somewhere you can't see it without a separate step.
The annotation most people reach for first time is @Data:
@Data
@Entity
public class CustomerEntity {
@Id
@GeneratedValue
private Long id;
private String email;
@OneToMany(mappedBy = "customer")
private List<OrderEntity> orders;
}
One line and you get getters, setters, toString(), equals(), hashCode() across every field. For a JPA entity that's already a problem before you've written any business logic - an entity carries persistence identity, lazy proxies, mutable state, none of which behaves like a plain data bag. Field-based equality ignores identity semantics entirely. A toString() that walks relationships is a logging hazard waiting for the wrong moment. Public setters on every field quietly throw out encapsulation.
I've had this exact line take down a support investigation:
log.warn("Could not process customer: {}", customer);
If toString() walks a lazy relationship, that log statement fires a database call. If both sides of a bidirectional association reference each other, you get recursion in your logs - I've seen this crash a logging pipeline, not just produce ugly output. And if the persistence context is already closed by the time you log, you can get a LazyInitializationException thrown while you're trying to log an unrelated failure. A ten-minute incident turns into two hours because the stack trace points at logging code, not the actual bug.
The usual patch is a pile of exclusions bolted back on:
@ToString(exclude = "orders")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
At which point we've added a code generator to save typing, then added more annotations to undo part of what it generated, and now every future developer has to understand the interaction between Lombok, Hibernate proxies, equality semantics, and logging before they can touch this class safely. That's not less work. It's the same work, deferred and hidden.
Builders aren't free either
@Builder is probably the most defended Lombok annotation, and I get the appeal - long constructors are ugly, named construction reads well at the call site. But a nice call site doesn't automatically mean good architecture underneath it.
For immutable request models, a record usually does the job better:
public record CreateCustomerRequest(
String firstName,
String lastName,
String email
) {
}
No annotation processor, no generated builder class sitting somewhere you'll never look, nothing ambiguous about what exists at compile time - constructor, accessors, equals(), hashCode(), toString(), all from the language itself. Where a builder genuinely earns its place - lots of optional parameters, a real construction protocol - write it by hand. It's not that much typing.
@SuperBuilder I'd flag as the one to be most careful with. Inheritance already makes domain models harder to follow. Add generated nested builders and field shadowing on top of ORM concerns, and you get bugs that compile fine and fail much later. A field ends up null nobody set on purpose, an update silently drops a value, and the row in the database stays technically valid while the business state underneath it is quietly wrong. I've debugged exactly this on a service and it took longer than it should have to even locate which builder was involved.
The toolchain cost nobody budgets for
Lombok isn't standard Java. It works by rewriting the compiler's view of your source during annotation processing. That matters because your compiler isn't the only thing reading that code. IDE indexing, static analysis, coverage tools, Javadoc, OpenAPI generation, MapStruct, QueryDSL, whatever other annotation processors are in the build, CI running on a different JDK than your laptop.
Most of the time it works fine, and that's the trap. It's not that Lombok breaks on day one, it's that it adds one more thing every tool in the pipeline has to keep agreeing on correctly. Springdoc and MapStruct can need specific processor ordering. Lombok's own extensions, like chained accessors, can quietly break JavaBean conventions that some framework tooling assumes without checking. The build accumulates workarounds to compensate, and eventually someone bumps a JDK or an IDE version, and a developer burns half a day figuring out why the generated code and the compiler disagree about what a class looks like.
Debugging wants source code, not a description of generated behavior
When something breaks in production I want a straight line: stack trace, source line, method body, state change, fix. Generated code adds fog to every step of that. The file in front of you tells you an annotation exists, not what it expands to. Builders become generated nested types you've never opened. Generated equals() and toString() pull behavior into an incident that nobody in the room actually wrote.
You can delombok a class to see what it really compiles to, but needing a second, generated copy of your own source just to understand what the first one does isn't a workaround. It's the actual problem, restated.
Java 25 already solves this
Lombok got popular in an older Java - verbose syntax, no records, weaker IDE support. We're not in that world anymore, the language itself covers most of what people reach for Lombok to fix:
public record CustomerResponse(
UUID id,
String fullName,
String email
) {
}
public class CustomerEntity {
private UUID id;
private String email;
public CustomerEntity(UUID id, String email) {
this.id = id;
this.email = email;
}
public UUID getId() {
return id;
}
public String getEmail() {
return email;
}
public void changeEmail(String email) {
this.email = email;
}
}
changeEmail() tells you more than a generated setEmail() ever would - it names intent, and naming intent is domain modeling, not boilerplate. For the rare getter or constructor that really is trivial, let the IDE generate it once. That costs less time than a Maven exclusion you'll be maintaining six months from now.
Where I've landed
Records for immutable DTOs like requests, responses, query results. Explicit classes for JPA entities and anything with mutable infrastructure state. Constructors written by hand to protect valid state. Explicit equality on entities where identity actually matters, not field-by-field comparison. No blanket @Data, and nothing generated that the next engineer has to reverse-engineer just to trust the class.
A dependency that shrinks your code but makes the build, the debugger, and the IDE more fragile isn't really improving developer experience, it's borrowing against it, and the interest comes due later. Small at first, then the module count grows, tooling gets stricter, JDK versions move on, and someone's maintaining a pile of invisible generated behavior that nobody currently on the team signed up to own.
What's Next
Stripping out the bytecode magic keeps the codebase predictable, debuggable, and aligned with the standard toolchain instead of fighting it.
Next up, Chapter 6: Implementing the DAO Layer - wiring up Spring Data JPA and Hibernate behind clean data access interfaces, with explicit, reflection-free transformation strategies that respect the database boundary and keep the domain layer pristine.
Codebase & Architecture Blueprint
The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 5, use the following link:
-
GitHub Repository (Tag:
chapter-05-lombok): advanced-spring-multimodule
Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.
◀️ Read Chapter 4: Mapping Strategies Without the Magic
▶️ Read Chapter 6: The Dao Implementation Layer (Coming soon)
Join the Masterclass Journey
This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!
Top comments (1)
The @data + JPA entity example is the part I agree with most. equals(), hashCode() and toString() are not really boilerplate once Hibernate proxies, lazy relationships and entity identity are involved.
I’m a little less strict about Lombok for simple DTOs or internal classes, but with modern Java records have definitely removed a lot of the reason to use it there too.
The changeEmail() vs generated setEmail() example is a good point as well. That’s not about saving or adding a few lines of code — one exposes mutation, the other expresses intent.