Should You Use Lombok on JPA Entities? Pitfalls and Best Practices
Problem
When I added @Data to my JPA entity, I got a StackOverflowError:
Exception in thread "main" java.lang.StackOverflowError at com.example.entity.Order.equals(Order.java) at com.example.entity.OrderItem.equals(OrderItem.java) at com.example.entity.Order.equals(Order.java) ...And in another scenario, I hit LazyInitializationException when debugging:
org.hibernate.LazyInitializationException: could not initialize proxy - no Session at com.example.entity.Customer.toString(Customer.java)What I Did
I wanted to reduce boilerplate in my JPA entities, so I added Lombok’s @Data annotation:
@Entity@Table(name = "orders")@Data // I thought this would save me from writing getters/setterspublic class Order { @Id @GeneratedValue private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY) private Customer customer;
@OneToMany(mappedBy = "order") private List<OrderItem> items;}@Data combines @Getter, @Setter, @EqualsAndHashCode, and @ToString. I thought this was perfect—less code, same functionality. But I was wrong.
The Infinite Loop Trap
The StackOverflowError happened because of bidirectional relationships. @EqualsAndHashCode generates equals() and hashCode() methods that include all fields by default.
Here’s what happens:
Order.equals() calls OrderItem.equals() OrderItem.equals() calls Order.equals() Order.equals() calls OrderItem.equals() ... StackOverflowError!The Order entity has List<OrderItem> items, and each OrderItem has Order order. When Lombok generates equals(), it recursively traverses both sides of the relationship.
The Lazy Loading Trap
The LazyInitializationException happened because @ToString includes all fields in the generated toString() method. When I tried to print an Order for debugging, toString() tried to access the customer field.
But customer was lazy-loaded, and the Hibernate session was already closed. The toString() method triggered a database query outside the transaction boundary.
This also causes N+1 query problems—even if you’re in a transaction, calling toString() on a list of entities fires a separate query for each entity’s lazy-loaded associations.
Why @Data is Dangerous for JPA Entities
Let me break down what @Data actually does and why each part is problematic:
@EqualsAndHashCode Issues
- Bidirectional relationships cause infinite loops—as I experienced
- HashCode changes after persist—before persisting, the ID is null; after persisting, it has a value. This breaks
HashSetandHashMapbehavior - Proxies interfere with equality—Hibernate creates proxy objects that aren’t instances of your actual class
@ToString Issues
- Triggers lazy loading—accessing lazy-loaded fields in
toString()causes queries - N+1 queries—printing a list of entities fires N additional queries
- LazyInitializationException—fails completely outside session scope
What I Tried First
I tried excluding specific fields from @EqualsAndHashCode:
@Entity@Table(name = "orders")@Getter@Setter@NoArgsConstructor@EqualsAndHashCode(exclude = {"items", "customer"})public class Order { // ... fields}This worked for the infinite loop, but I had to remember to exclude every lazy-loaded field. And if I added a new association later, I might forget to exclude it.
A Better Approach: Explicit Exclusions
Lombok provides onlyExplicitlyIncluded for safer behavior:
@Entity@Table(name = "products")@Getter@Setter@NoArgsConstructor@EqualsAndHashCode(onlyExplicitlyIncluded = true)public class Product { @Id @GeneratedValue @EqualsAndHashCode.Include private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) @EqualsAndHashCode.Exclude private Category category;
@OneToMany(mappedBy = "product") @EqualsAndHashCode.Exclude private List<Review> reviews;}This way, only fields explicitly marked with @Include are used in equals() and hashCode(). New fields are excluded by default.
For @ToString, I use the same pattern:
@Entity@Table(name = "categories")@Getter@Setter@NoArgsConstructor@ToString(onlyExplicitlyIncluded = true)public class Category { @Id @GeneratedValue @ToString.Include private Long id;
@ToString.Include private String name;
@ManyToOne(fetch = FetchType.LAZY) @ToString.Exclude private Category parent;}The Safest Approach: Manual Implementation
After experiencing these issues, I realized the safest approach is to implement equals(), hashCode(), and toString() manually:
@Entity@Table(name = "orders")@Getter@Setter@NoArgsConstructorpublic class Order { @Id @GeneratedValue private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY) private Customer customer;
@OneToMany(mappedBy = "order") private List<OrderItem> items = new ArrayList<>();
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Order order)) return false; // Only use ID for equality - it's stable after persist return id != null && id.equals(order.id); }
@Override public int hashCode() { // Use class hashCode to stay consistent across entity lifecycle return getClass().hashCode(); }
@Override public String toString() { // Exclude lazy-loaded associations return "Order{id=%d, orderNumber='%s'}".formatted(id, orderNumber); }}Why Only ID for equals()?
- ID is stable—once assigned, it doesn’t change
- Avoids proxy issues—you can compare IDs without initializing proxies
- Business key independence—business keys might change; ID is permanent
Why getClass().hashCode()?
- Consistent across lifecycle—returns same value before and after persist
- Safe with proxies—proxy subclasses return same hashCode as the real class
- Works with HashSet/HashMap—entity stays in the same bucket
What About Java Records?
I considered using Java records for entities, but records don’t work well with JPA:
// This WON'T work properly with JPA/Hibernate@Entitypublic record Customer( @Id @GeneratedValue Long id, String name, String email) { // Problems: // 1. No default constructor - JPA requires it // 2. Fields are final - Hibernate can't proxy // 3. Can't use with lazy loading}A workaround exists but is discouraged:
@Entitypublic record Customer( @Id @GeneratedValue Long id, String name, String email) { public Customer() { this(null, null, null); }}This still doesn’t work well with Hibernate proxies and lazy loading. For JPA entities, stick with regular classes and Lombok’s @Getter, @Setter, @NoArgsConstructor.
Safe Lombok Annotations for JPA
Here’s my decision matrix:
| Annotation | Safe for JPA? | Why |
|---|---|---|
@Getter | Yes | No side effects |
@Setter | Yes | No side effects, required for mutability |
@NoArgsConstructor | Yes | Required by JPA specification |
@AllArgsConstructor | Caution | Useful for tests, conflicts with @NoArgsConstructor |
@Data | No | Combines unsafe @EqualsAndHashCode and @ToString |
@EqualsAndHashCode | Only with exclusions | Causes infinite loops and hashCode instability |
@ToString | Only with exclusions | Triggers lazy loading |
@Builder | Caution | Creates final fields, conflicts with JPA |
My Recommended Template
Here’s what I use now for all JPA entities:
@Entity@Table(name = "articles")@Getter@Setter@NoArgsConstructorpublic class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String title;
@Column(columnDefinition = "TEXT") private String content;
@ManyToOne(fetch = FetchType.LAZY) private Author author;
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL) private List<Comment> comments = new ArrayList<>();
// For testing only @Builder public Article(Long id, String title, String content, Author author) { this.id = id; this.title = title; this.content = content; this.author = author; }
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Article article)) return false; return id != null && id.equals(article.id); }
@Override public int hashCode() { return getClass().hashCode(); }
@Override public String toString() { return "Article{id=%d, title='%s'}".formatted(id, title); }}The @Builder annotation is on a separate constructor, not the class. This way I can use the builder pattern for tests while keeping the no-arg constructor for JPA.
Summary
After experiencing StackOverflowError and LazyInitializationException, I learned:
- Never use
@Dataon JPA entities—it combines dangerous annotations @Getter,@Setter,@NoArgsConstructorare safe and helpful- Implement
equals(),hashCode(),toString()manually—or use Lombok withonlyExplicitlyIncludedand explicit exclusions - Records are not for JPA entities—they lack mutability and default constructor requirements
- Use only ID for
equals()andhashCode()—it’s stable and works with proxies
Lombok is still useful for JPA entities, but you need to be selective about which annotations you use.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Lombok @Data Annotation Pitfalls
- 👨💻 JPA Entity Requirements
- 👨💻 Hibernate Proxy Objects and Lazy Loading
- 👨💻 Equals and HashCode in JPA Entities
- 👨💻 Java Records as JPA Entities
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments