Lombok isn't necessarily the problem. Not understanding what it generates is.
Under the hood, @Data is essentially a combination of several Lombok annotations: @Getter, @Setter, @ToString, @EqualsAndHashCode, and @RequiredArgsConstructor.
@Getter, @Setter, and @RequiredArgsConstructor are generally not the source of this particular problem. The dangerous ones here are @ToString and @EqualsAndHashCode.
In a bidirectional relationship (@OneToMany / @ManyToOne), entities reference each other.
Let's use this code as an example:
On one side:
@Entity
@Data
@Table(name = "user")
public class User {
@OneToMany(mappedBy = "owner")
private List<House> houses;
}
And on the other side:
@Entity
@Data
@Table(name = "house")
public class House {
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User owner;
}
This is a bidirectional relationship. The user has houses and knows them, and each house knows who owns it.
Here's the trap: this happened to me on an enterprise project. The particularly frustrating part was that I wasn't even trying to access the user's houses, I was just logging in. With DEBUG logging enabled locally, one of the objects involved in the authentication flow was being logged. Because @Data generates a toString() method, logging the entity caused Lombok to traverse its fields. The User contained a list of House entities, and each House contained a reference back to its User. That created a recursive toString() call, an endless cycle where parent calls child and child calls parent. This eventually results in a StackOverflowError.
Locally, the application ran with DEBUG logging and crashed. In Docker, logging was set to WARN and everything worked. That difference eventually led me to the real culprit: Lombok's generated toString() combined with a bidirectional JPA relationship.
To fix this, I had to add the @ToString.Exclude and @EqualsAndHashCode.Exclude annotations to the child side of the relationship.
@ToString.Exclude prevents Lombok from including owner when generating toString().
@EqualsAndHashCode.Exclude does something similar for equals() and hashCode(): it tells Lombok not to use that field when determining whether two entities are equal or when calculating their hash code.
This is not only about preventing recursion. With JPA entities, using relationships in equals() and hashCode() can also be problematic because those relationships are mutable and managed by Hibernate. An entity's equality should not unexpectedly change just because one of its associations changed.
For that reason, excluding relationships from Lombok-generated equals() and hashCode() is generally a much safer default.
I also want to discuss another trap: Inheritance. If your entity extends a base class (like an AuditingEntity), @Data can cause hidden bugs. You should explicitly decide how @EqualsAndHashCode should handle the parent class fields.
Here's a short example:
Let's say you have an AuditingEntity so you don't need to rewrite your timestamps and your User class extends it now:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@SQLRestriction("deleted_at IS NULL")
public abstract class AuditingEntity {
@CreatedDate
@Column(name="created_at",nullable=false,updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name="updated_at")
private Instant updatedAt;
@Column(name="deleted_at")
private Instant deletedAt;
}
Instead of this User class,
@Entity
@Data
@Table(name = "user")
public class User extends AuditingEntity {
@OneToMany(mappedBy = "owner")
private List<House> houses;
}
I'd rather have this:
@Entity
@Data
@EqualsAndHashCode(callSuper = false)
public class User extends AuditingEntity {
@OneToMany(mappedBy = "owner")
private List<House> houses;
}
When a class extends another class, Lombok requires you to make an explicit choice about whether the superclass should participate in equals() and hashCode(). Setting callSuper = false tells Lombok not to call the superclass (AuditingEntity here) implementations.
This is particularly useful when the parent class contains technical or auditing fields such as createdAt, updatedAt, or deletedAt. If the superclass fields should not participate in equality, make that choice explicit with callSuper = false.
At the end of the day, if you have to use @Data, make sure you understand how to handle its generated methods. Otherwise, I highly recommend using only the Lombok annotations you actually need. Therefore, I would prefer having a User class that looks like this:
@Entity
@Table(name = "user")
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class User extends AuditingEntity {
@OneToMany(mappedBy = "owner")
private List<House> houses;
}
Explicit annotations make the generated behavior visible at the class level. @Data is convenient, but convenience can hide important behavior. Only generate what you actually need.
Always remember that boilerplate reducers are meant to save time, not to replace architectural thinking. Always know what your annotations compile into. Build clean!
Top comments (0)