LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 2720 min read

Common Mistakes & Best Practices

A consolidated tour of the most common Hibernate mistakes — overusing eager fetching, not closing sessions, entity equals/hashCode pitfalls — and how to avoid each one.

Introduction

You have now covered every major piece of Hibernate: mapping, CRUD, HQL, relationships, caching, transactions, validation, and performance. This lesson steps back and consolidates the mistakes that trip up almost everyone at some point, and the habits that prevent them — before you build the final real-world project.

What You Will Learn
  • Why overusing eager fetching quietly hurts performance
  • Why forgetting to close sessions causes connection leaks
  • The correct way to implement equals() and hashCode() on entities
  • How careless cascading can delete more than you intended
  • A practical pre-flight checklist for Hibernate code reviews

Overusing Eager Fetching

It is tempting to mark every association FetchType.EAGER "just to be safe" after running into LazyInitializationException once. This backfires: every load of that entity now pulls in every eager association, whether you need it or not, and eager collections on multiple associations can trigger cartesian-product joins.

// Avoid this as a default
@OneToMany(mappedBy = "department", fetch = FetchType.EAGER)
private List<Employee> employees;
// Prefer this, and fetch eagerly only per-query when needed
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<Employee> employees;
The Real Fix Is Per-Query

The lesson on the N+1 problem covered the actual fix: keep associations LAZY by default, and reach for JOIN FETCH, entity graphs, or batch fetching on the specific queries that need the data eagerly.

Not Closing Sessions

A Session wraps a JDBC connection. If you open one and never close it — for example, because an exception skipped past a manual session.close() call — that connection stays checked out of the pool. Enough of these and your application runs out of connections entirely.

// Risky: close() is skipped if persist() throws
Session session = factory.openSession();
session.beginTransaction();
session.persist(new Employee());
session.getTransaction().commit();
session.close();
// Safer: try-with-resources guarantees close() runs
try (Session session = factory.openSession()) {
Transaction tx = session.beginTransaction();
try {
session.persist(new Employee());
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
}
}

equals() and hashCode() Pitfalls

Using Lombok's @Data or an IDE-generated equals()/hashCode() based on every field is a common trap for entities — mutable fields change an object's hash code after it has already been placed in a HashSet, silently breaking lookups. The safe, standard pattern bases equality on the business/database identity, handling transient (unsaved, id == null) entities carefully.

import java.util.Objects;
import javax.persistence.*;
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee other = (Employee) o;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // Stable even before id is assigned
}
}
Why hashCode() Uses getClass(), Not id

Basing hashCode() on a mutable or initially-null id means an object's hash code changes after it is persisted (id goes from null to a real value), which can move it to the wrong bucket in a HashSet it was already added to. A constant hashCode() per class avoids that entirely, at a small cost to hash distribution.

Misusing Cascade

CascadeType.ALL (or CascadeType.REMOVE specifically) propagates deletes to associated entities. That is exactly right for a true parent-owns-child relationship — like an Order and its OrderItems — and a serious data-loss bug for a shared reference, like a Student and the Courses they enroll in.

// Correct: OrderItems only exist as part of their Order
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
// Dangerous if used the same way: deleting one Student
// should not delete Courses that other Students are enrolled in
@ManyToMany
@JoinTable(name = "student_course")
private List<Course> courses; // No cascade = ALL here

Ignoring the Persistence Context

A recurring source of confusion throughout this course has been forgetting that a Session is also a persistence context — every managed entity it holds is automatically dirty-checked and flushed at commit, with or without an explicit update() call. Developers coming from a plain-JDBC background sometimes call session.update() unnecessarily on an entity that was already managed, or are surprised when a field change gets saved without any save call at all.

A Practical Checklist

  • Are associations LAZY by default, with eager fetching applied only per-query where genuinely needed?
  • Is every Session guaranteed to close, even on the exception path (try-with-resources or finally)?
  • Does every entity have an identity-based equals()/hashCode(), not a field-by-field one?
  • Is CascadeType.ALL / REMOVE only applied to true ownership relationships, never shared references?
  • Is SQL logging available in development to catch N+1 and unexpected queries early?

Common Mistakes

Avoid These Mistakes
  • Defaulting to FetchType.EAGER everywhere to avoid ever seeing LazyInitializationException.
  • Not wrapping Session usage in try-with-resources or an equivalent guaranteed-close pattern.
  • Generating equals()/hashCode() from every field on a mutable entity.
  • Applying CascadeType.ALL to a @ManyToMany association shared across multiple owners.
  • Calling session.update() on entities that are already managed, out of habit rather than necessity.

Best Practices

  • Keep FetchType.LAZY as the default and reach for JOIN FETCH/entity graphs/batch fetching per query.
  • Always guarantee Session closure with try-with-resources.
  • Base entity equals()/hashCode() on the identifier, guarding against a null id, with a stable hashCode().
  • Reserve CascadeType.ALL and orphanRemoval for genuine parent-owns-child relationships only.
  • Review generated SQL regularly during development, not just when something is already slow.

Frequently Asked Questions

Yes, for an association that is genuinely needed on almost every load of that entity, such as a required @ManyToOne that is small and always accessed together with its parent.

Because Hibernate entities are mutable and their id can be null before persistence, a field-based equals/hashCode is unstable in collections and can behave inconsistently before and after saving.

CascadeType.REMOVE deletes children when the parent is deleted. orphanRemoval also deletes a child the moment it is removed from the parent's collection, even if the parent itself is not deleted.

Ask whether the child entity has any meaning or lifecycle independent of the parent. If yes (like a Course that exists independent of any one Student), cascade should not delete it.

Key Takeaways

  • Default to LAZY fetching; fetch eagerly per-query only where truly needed.
  • Guarantee Session closure with try-with-resources to avoid connection leaks.
  • Use an identifier-based equals()/hashCode() with a stable hashCode() implementation.
  • Apply cascading deletes only to genuine ownership relationships, never shared references.
  • Understand that a managed entity is dirty-checked automatically — you do not always need an explicit save call.

Summary

Every mistake in this lesson comes from the same root cause: treating Hibernate like a thin wrapper over SQL instead of understanding the persistence context, fetch strategy, and object identity model underneath it. With these habits in place, you are ready to put everything together in a real project.

Next Lesson →

Real-World Project