Skip to content

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:

Order.java
@Entity
@Table(name = "orders")
@Data // I thought this would save me from writing getters/setters
public 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&lt;OrderItem&gt; 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

  1. Bidirectional relationships cause infinite loops—as I experienced
  2. HashCode changes after persist—before persisting, the ID is null; after persisting, it has a value. This breaks HashSet and HashMap behavior
  3. Proxies interfere with equality—Hibernate creates proxy objects that aren’t instances of your actual class

@ToString Issues

  1. Triggers lazy loading—accessing lazy-loaded fields in toString() causes queries
  2. N+1 queries—printing a list of entities fires N additional queries
  3. LazyInitializationException—fails completely outside session scope

What I Tried First

I tried excluding specific fields from @EqualsAndHashCode:

Order.java
@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:

Product.java
@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&lt;Review&gt; 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:

Category.java
@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:

Order.java
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order")
private List&lt;OrderItem&gt; items = new ArrayList&lt;&gt;();
@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()?

  1. ID is stable—once assigned, it doesn’t change
  2. Avoids proxy issues—you can compare IDs without initializing proxies
  3. Business key independence—business keys might change; ID is permanent

Why getClass().hashCode()?

  1. Consistent across lifecycle—returns same value before and after persist
  2. Safe with proxies—proxy subclasses return same hashCode as the real class
  3. 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:

Customer.java
// This WON'T work properly with JPA/Hibernate
@Entity
public 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:

Customer.java
@Entity
public 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:

AnnotationSafe for JPA?Why
@GetterYesNo side effects
@SetterYesNo side effects, required for mutability
@NoArgsConstructorYesRequired by JPA specification
@AllArgsConstructorCautionUseful for tests, conflicts with @NoArgsConstructor
@DataNoCombines unsafe @EqualsAndHashCode and @ToString
@EqualsAndHashCodeOnly with exclusionsCauses infinite loops and hashCode instability
@ToStringOnly with exclusionsTriggers lazy loading
@BuilderCautionCreates final fields, conflicts with JPA

Here’s what I use now for all JPA entities:

Article.java
@Entity
@Table(name = "articles")
@Getter
@Setter
@NoArgsConstructor
public 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&lt;Comment&gt; comments = new ArrayList&lt;&gt;();
// 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:

  1. Never use @Data on JPA entities—it combines dangerous annotations
  2. @Getter, @Setter, @NoArgsConstructor are safe and helpful
  3. Implement equals(), hashCode(), toString() manually—or use Lombok with onlyExplicitlyIncluded and explicit exclusions
  4. Records are not for JPA entities—they lack mutability and default constructor requirements
  5. Use only ID for equals() and hashCode()—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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments