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()touchescustomeranditems→ queries outside the session or N+1;equals/hashCodedepend on mutable collections and proxies;- bidirectional links easily cause
StackOverflowErrorin 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
@Dataon@Entity/@MappedSuperclass. @ToString.Exclude(or onlyExplicitlyIncluded) on every association.equals/hashCodedo 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.
