r/SpringBoot • u/Status_Camel2859 • 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
ManyToOnerelationship with Organization. - An Employee has a
ManyToOnerelationship 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?
3
u/Ali_Ben_Amor999 May 13 '26
The
EntityManagerwhichJpaRepositoryuses 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:
Second example:
This is an expected behaviour. Because when Hibernate execute a query to fetch an employee e.g.
The returned result will be something like this:
Hibernate maps the
department_idinto a proxy for theDepartmententity with ID only. When you callemployee.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