Back to blog
Java 12 min read

Lombok and JPA Entities: Safe equals, hashCode, and toString

Why @Data on @Entity is dangerous, how LazyInitializationException comes from toString, and which fields to exclude from equals/hashCode.

LombokJPAHibernateEquals
Lombok and JPA Entities: Safe equals, hashCode, and toString

Why Lombok on @Entity is not free sugar

Lombok removes boilerplate, but blind @Data / @EqualsAndHashCode / @ToString on JPA entities often breaks the Hibernate session, caches, and debugging. Classic symptoms: LazyInitializationException while logging, infinite recursion in toString, duplicates in a Set, weird dirty checking.

Simple rule: generate only what you consciously need, and exclude associations and lazy fields from equals/hashCode/toString.

Anti-pattern: @Data on an entity

@Entity
@Data // getters/setters + equals/hashCode + toString on ALL fields
public class Order {
  @Id
  @GeneratedValue
  private Long id;

  private String number;

  @ManyToOne(fetch = FetchType.LAZY)
  private Customer customer;

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  private List<OrderItem> items = new ArrayList<>();
}

What goes wrong:

  • toString() touches customer and items → queries outside the session or N+1;
  • equals/hashCode depend on mutable collections and proxies;
  • bidirectional links easily cause StackOverflowError in toString/equals.

Example 1: toString and LazyInitializationException

// After the transaction is closed:
log.info("Saved order {}", order);
// Lombok toString() touches items → LazyInitializationException

Exclude associations from toString:

@Entity
@Getter
@Setter
@ToString(onlyExplicitlyIncluded = true)
public class Order {
  @Id
  @GeneratedValue
  @ToString.Include
  private Long id;

  @ToString.Include
  private String number;

  @ManyToOne(fetch = FetchType.LAZY)
  private Customer customer; // not in toString

  @OneToMany(mappedBy = "order")
  @ToString.Exclude
  private List<OrderItem> items = new ArrayList<>();
}

Alternative: @ToString(exclude = {"customer", "items"}).

Example 2: equals/hashCode and Set<Entity>

If equals uses all fields or an id that is null before flush:

Set<OrderItem> items = new HashSet<>();
OrderItem a = new OrderItem(); // id == null
OrderItem b = new OrderItem(); // id == null
items.add(a);
items.add(b); // id-only equals treats both as equal → element lost

A practical JPA approach is a business key or a UUID assigned in the constructor:

@Entity
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class OrderItem {
  @Id
  @GeneratedValue
  private Long id;

  @EqualsAndHashCode.Include
  @Column(nullable = false, unique = true, updatable = false)
  private UUID businessId = UUID.randomUUID();

  @ManyToOne(fetch = FetchType.LAZY)
  @EqualsAndHashCode.Exclude
  private Order order;

  private BigDecimal price;
}

Do not include lazy @ManyToOne/@OneToMany in equals — comparison will load the graph or compare proxies unpredictably.

Example 3: bidirectional links and recursion

@Entity
@Data
public class Customer {
  @OneToMany(mappedBy = "customer")
  private List<Order> orders;
}

@Entity
@Data
public class Order {
  @ManyToOne
  private Customer customer;
}

System.out.println(customer);
// customer.toString → orders → order.toString → customer → StackOverflowError

Always mark the inverse side:

@ToString.Exclude
@EqualsAndHashCode.Exclude
@OneToMany(mappedBy = "customer")
private List<Order> orders;

Example 4: @Builder and the required no-args constructor

JPA needs a no-args constructor. Pair it with Lombok like this:

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Product {
  @Id
  @GeneratedValue
  private Long id;
  private String sku;
  private String name;
}

Without @NoArgsConstructor, Hibernate cannot instantiate the entity. PROTECTED keeps the constructor for the ORM without exposing it in the public API.

Example 5: what to use instead of @Data

@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Order {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @ToString.Include
  @EqualsAndHashCode.Include
  private Long id; // fine only for already-persisted instances;
                   // otherwise prefer a UUID business key

  @ToString.Include
  private String number;

  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  private Customer customer;

  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
  @ToString.Exclude
  @EqualsAndHashCode.Exclude
  private List<OrderItem> items = new ArrayList<>();
}

Stricter option: skip Lombok equals/hashCode and write them manually on a business key — easier to review.

Checklist before merging an entity class

  • No @Data on @Entity / @MappedSuperclass.
  • @ToString.Exclude (or onlyExplicitlyIncluded) on every association.
  • equals/hashCode do not touch collections or LAZY fields.
  • A no-args constructor exists for JPA.
  • Logging/debug do not accidentally traverse the full object graph.

Takeaway

Lombok is fine on entities when used deliberately: getters/setters, a careful toString, equals on a stable key. Trouble starts when equals/hashCode/toString include fields that must not be there — associations, lazy collections, bidirectional references. Exclude them explicitly (@ToString.Exclude, @EqualsAndHashCode.Exclude); it is cheaper than chasing LazyInitializationException and StackOverflow in production.

Alexey Prybytkou

Senior Software Developer

© 2026 All rights reserved