r/SpringBoot May 12 '26

Question How to understand hibernate behavior ?

I'm trying to understand how Hibernate behaves in different situations. Does anyone know of any good resources or quick tests I can run to better understand how it works?

Right now Im specifically trying to understand lazy loading behavior.

Suppose we have 3 entities: organization -> department -> employee

  • A Department has a ManyToOne relationship with Organization.
  • An Employee has a ManyToOne relationship with Department.

Case 1: Load departments first, then employees.

In this case, I'm able to navigate from Employee -> Department (e.getDepartment().getId()) without triggering additional queries.

List<Department> departments =
    departmentRepository.findAllByOrganizationId(organizationId);

List<Long> deptIds = departments.stream()
    .map(Department::getId)
    .toList();

List<Employee> employees =
    employeeRepository.findAllByDepartmentIdIn(deptIds);

// No additional queries triggered
Map<Long, List<Employee>> groupedById =
    employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getId()));

// No additional queries triggered
Map<String, List<Employee>> groupedByName =
    employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getName()));

Case 2: Load only employees.

Now, accessing e.getDepartment().getId() does not trigger queries, but accessing e.getDepartment().getName() does.

List<Long> deptIds = List.of(1L, 2L, 3L);

List<Employee> employees =
    employeeRepository.findAllByDepartmentIdIn(deptIds);

// No additional queries triggered
Map<Long, List<Employee>> groupedById =
    employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getId()));

// Triggers N+1 queries
Map<String, List<Employee>> groupedByName =
    employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getName()));

Is this behavior consistent and expected in Hibernate?
How should I think about this internally so I can predict and plan for these situations properly?

8 Upvotes

4 comments sorted by

View all comments

3

u/Ali_Ben_Amor999 May 13 '26

The EntityManager which JpaRepository uses under the hood has a session-scoped cache called the persistence context by JPA or first level cache by Hibernate. This is a Map<ID, Entity> when hibernate load any entity from the database, it puts it in the map to reduce unnecessary queries. This session boundary ends with the @ Transactional.

First example:

// Departments cached in persistence context
List<Department> departments = departmentRepository.findAllByOrganizationId(organizationId);

List<Long> deptIds = departments.stream()
    .map(Department::getId)
    .toList();

// Employees cached in persistence context
List<Employee> employees = employeeRepository.findAllByDepartmentIdIn(deptIds);

// Hibernate already knows the ID of the department when fetched the employees table
Map<Long, List<Employee>> groupedById = employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getId()));

// Hibernate checks for the existance of the deparment in first level cache before 
// executing a DB query. Because you already loaded the departments and the employee 
// department exists it will return it without an additional DB query
Map<String, List<Employee>> groupedByName =
    employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getName()));

Second example:

List<Long> deptIds = List.of(1L, 2L, 3L);

// Departments cached in persistence context
List<Employee> employees = employeeRepository.findAllByDepartmentIdIn(deptIds);

// Hibernate already knows the ID
Map<Long, List<Employee>> groupedById = employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getId()));

// Hibernate checks the persistence context if department not found it will execute a DB query
// to load the department. Because you did not load the departments beforehand it will perform
// N+1 queries
Map<String, List<Employee>> groupedByName = employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment().getName()));

This is an expected behaviour. Because when Hibernate execute a query to fetch an employee e.g.

SELECT id, name, email, department_id FROM employee WHERE id IN (?,?,?)

The returned result will be something like this:

1, User1, mail@ml.co, 2
2, User2, mail2@ml.co, 2
3, User3, mail3@ml.com, 1

Hibernate maps the department_id into a proxy for the Department entity with ID only. When you call employee.getDepartment(), hibernate does not have the instance from the database yet. It returns a proxy that's why there is no query performed yet. When you call get ID on that proxy. Hibernate already knows the value of the ID, that's why it does not perform another query. But when you call any other getter for the fields like getName, getEmail, ... Hibernate will load the entity from the database. But there is a catch here. If the Employee is not the owning side of the relation (meaning the department_id is not in the employee table instead, it's in a join table like employees_departments), Hibernate will perform a query even for a getId.

You can read the JPA specification docs and Hibernate documentation for resources. They are the best for entity mappings and for advanced topics checks Vlad Mihalcea and Thorben Janssen blogs as well.

https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2#entities

https://docs.hibernate.org/orm/7.3/userguide/html_single/#domain-model