Назад к блогу
Java 12 мин чтения

Lombok и JPA-сущности: equals, hashCode, toString без сюрпризов

Почему @Data на @Entity опасен, как LazyInitializationException появляется из toString и какие поля исключать из equals/hashCode.

LombokJPAHibernateEquals
Lombok и JPA-сущности: equals, hashCode, toString без сюрпризов

Почему Lombok на @Entity — не «бесплатный сахар»

Lombok ускоряет бойлерплейт, но на JPA-сущностях слепое @Data / @EqualsAndHashCode / @ToString часто ломает сессию Hibernate, кэш и отладку. Классические симптомы: LazyInitializationException при логировании, бесконечная рекурсия в toString, дубликаты в Set, странное поведение dirty checking.

Правило простое: генерируйте только то, что осознанно нужно, и исключайте ассоциации и ленивые поля из equals/hashCode/toString.

Антипример: @Data на сущности

@Entity
@Data // = getter/setter + equals/hashCode + toString по ВСЕМ полям
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<>();
}

Что пойдёт не так:

  • toString() трогает customer и items → запросы вне сессии или N+1;
  • equals/hashCode зависят от мутабельных коллекций и прокси;
  • двусторонние связи легко дают StackOverflowError в toString/equals.

Пример 1: toString и LazyInitializationException

// В сервисе после закрытия транзакции:
log.info("Saved order {}", order);
// Lombok toString() лезет в items → LazyInitializationException

Исключите ассоциации из 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; // не в toString

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

Альтернатива: @ToString(exclude = {"customer", "items"}).

Пример 2: equals/hashCode и Set<Entity>

Если equals строится по всем полям или по id, который null до flush:

Set<OrderItem> items = new HashSet<>();
OrderItem a = new OrderItem(); // id == null
OrderItem b = new OrderItem(); // id == null
items.add(a);
items.add(b); // при equals только по id оба «равны» → потеря элемента

Практичный вариант для JPA — бизнес-ключ или стабильный UUID, назначенный в конструкторе:

@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;
}

Не включайте в equals ленивые @ManyToOne/@OneToMany — сравнение начнёт грузить граф или сравнивать прокси непредсказуемо.

Пример 3: двусторонняя связь и рекурсия

@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

Всегда помечайте обратную сторону:

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

Пример 4: @Builder и обязательный no-args конструктор

JPA требует конструктор без аргументов. Связка с Lombok:

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

Без @NoArgsConstructor Hibernate не сможет инстанцировать entity. PROTECTED оставляет конструктор для ORM, но не светит его в публичном API.

Пример 5: что ставить вместо @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; // ок, если equals нужен только для уже persisted;
                   // иначе лучше 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<>();
}

Ещё жёстче: вообще не генерировать equals/hashCode Lombok'ом и написать их вручную по бизнес-ключу — так проще ревьюить.

Чеклист перед merge entity-класса

  • Нет @Data на @Entity / @MappedSuperclass.
  • Есть @ToString.Exclude (или onlyExplicitlyIncluded) на всех ассоциациях.
  • equals/hashCode не тянут коллекции и LAZY-поля.
  • Есть no-args конструктор для JPA.
  • Логи и отладка не вызывают полный граф объекта «случайно».

Вывод

Lombok полезен на entity, если включать его точечно: getters/setters, аккуратный toString, equals по стабильному ключу. Проблемы начинаются, когда в equals/hashCode/toString попадают поля, которые не должны туда попадать — ассоциации, ленивые коллекции, двусторонние ссылки. Делайте исключения явно (@ToString.Exclude, @EqualsAndHashCode.Exclude) — это дешевле, чем ловить LazyInitializationException и StackOverflow на проде.

Алексей Прыбытков

Senior Software Developer

© 2026 Все права защищены