Skip to content

Commit 2ef75b1

Browse files
committed
fix(model): revert Role.privileges to EAGER (keep User.roles LAZY)
The 5.0.0 change made Role.privileges LAZY along with User.roles. That introduced a LazyInitializationException footgun: role.getPrivileges() called outside an open transaction/session now threw — the most natural navigation on a Role — for a negligible gain, since privileges are small, static reference data with no bulk-load path. Revert Role.privileges to EAGER. User.roles stays LAZY (that is where the real per-user N+1 win is), and the authentication path still loads the full User -> roles -> privileges graph in one query via UserRepository.findWithRolesByEmail. This is a non-breaking relaxation: code written against 5.0.0 continues to work unchanged. Updates CHANGELOG ([5.0.1]) and MIGRATION (lazy-fetch section) accordingly.
1 parent 37b506a commit 2ef75b1

4 files changed

Lines changed: 25 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/) for its own public API; the supported Spring Boot versions are tracked separately (see the README compatibility matrix) and are **not** tied to this library's major version.
44

5+
## [5.0.1] - Unreleased
6+
### Changed
7+
- Reverted `Role.privileges` to `FetchType.EAGER` (it was changed to `LAZY` in 5.0.0). The 5.0.0 change made `role.getPrivileges()` throw `LazyInitializationException` when accessed outside an open transaction/session — a surprising footgun for consumers — in exchange for a negligible gain, since privileges are small, static reference data with no bulk-load path. `User.roles` remains `LAZY` (that is where the real N+1 win is), and the authentication path still loads the full `User → roles → privileges` graph in one query via `UserRepository.findWithRolesByEmail`. This is a **non-breaking relaxation**: code written against 5.0.0 continues to work unchanged. See `MIGRATION.md` ("Lazy fetching of roles and privileges").
8+
59
## [5.0.0] - 2026-06-15
610

711
> **Major release — contains breaking changes** across the Java API, HTTP/response contracts, database schema, required configuration, and bean/auto-configuration structure. Read [`MIGRATION.md`](MIGRATION.md) ("Migrating to 5.0.x") before upgrading.

MIGRATION.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,20 +168,22 @@ CREATE UNIQUE INDEX ux_privilege_name ON privilege (name);
168168
169169
### Lazy fetching of roles and privileges
170170

171-
**What changed:** The `roles` collection on `User` and the `privileges` collection on `Role` were previously `FetchType.EAGER`. They are now `FetchType.LAZY`. The authentication path (`DSUserDetailsService.loadUserByUsername`) now loads the full `User` → `roles` → `privileges` graph via a new `@EntityGraph` repository finder, `UserRepository.findWithRolesByEmail(String email)`, which fetches everything in a **single query**.
171+
**What changed:** The `roles` collection on `User` was previously `FetchType.EAGER` and is now `FetchType.LAZY`. The authentication path (`DSUserDetailsService.loadUserByUsername`) loads the full `User` → `roles` → `privileges` graph via a new `@EntityGraph` repository finder, `UserRepository.findWithRolesByEmail(String email)`, which fetches everything in a **single query**.
172172

173-
**Why:** The old two-level eager fetch loaded every role and every privilege on *every* `User` load — even for operations that never touch authorities (token lookups, lockout-counter updates, existence checks) — and caused an N+1 query pattern. Making the collections lazy and loading them explicitly only where they are needed removes that overhead while keeping authentication behavior identical.
173+
> **Note (5.0.0 → 5.0.1):** `Role.privileges` was also switched to `LAZY` in 5.0.0, but that was reverted to `EAGER` in **5.0.1** because it made `role.getPrivileges()` throw outside a transaction for marginal benefit. If you are on 5.0.1 or later, only `User.roles` is lazy — `role.getPrivileges()` is safe everywhere. The guidance below is written for 5.0.1+; on 5.0.0 specifically, the privilege caveats also apply.
174174
175-
**Impact / risk:** Because the collections are now lazy, **any code that accesses `user.getRoles()` (or iterates `role.getPrivileges()`) on a detached entity — i.e. outside an open Hibernate session/transaction — will throw `LazyInitializationException`.** Code that accesses these collections *within* an active transaction (the common case for service methods) is unaffected. The framework's own authentication, OAuth2/OIDC, and GDPR-export paths have been updated to initialize the graph correctly.
175+
**Why:** The old eager fetch loaded every role (and, transitively, every privilege) on *every* `User` load — even for operations that never touch authorities (token lookups, lockout-counter updates, existence checks) — and caused an N+1 query pattern across user loads. Making `User.roles` lazy and loading it explicitly only where it is needed removes that overhead while keeping authentication behavior identical. `Role.privileges` stays eager because privileges are small, static reference data and there is no path that bulk-loads `Role`s.
176176

177-
**Remediation patterns for consumers** that traverse roles/privileges on a `User` they obtained outside a transaction:
177+
**Impact / risk:** Because `User.roles` is now lazy, **any code that accesses `user.getRoles()` on a detached entity — i.e. outside an open Hibernate session/transaction — will throw `LazyInitializationException`.** Code that accesses it *within* an active transaction (the common case for service methods) is unaffected. Once the roles collection is loaded, `role.getPrivileges()` works regardless of transaction state (privileges are eager, as of 5.0.1). The framework's own authentication, OAuth2/OIDC, and GDPR-export paths have been updated to initialize the graph correctly.
178+
179+
**Remediation patterns for consumers** that traverse a user's roles on a `User` they obtained outside a transaction:
178180

179181
- **Load through the authentication path or the entity-graph finder.** Use `UserRepository.findWithRolesByEmail(email)` (it initializes roles and privileges in one query) instead of the plain `findByEmail(email)` when you need authorities.
180182
- **Access the collections inside a transaction.** Annotate the method that reads `user.getRoles()` with `@Transactional` so the persistence session is still open when the lazy collection is first touched.
181183
- **Use a DTO projection.** Map the roles/privileges you need into a DTO while still inside the session, then pass the DTO around the detached boundary.
182-
- **Initialize before detaching.** If you must hand a `User` to detached code, call `Hibernate.initialize(user.getRolesAsSet())` (and the nested privileges) while the session is open.
184+
- **Initialize before detaching.** If you must hand a `User` to detached code, call `Hibernate.initialize(user.getRolesAsSet())` while the session is open (privileges come along eagerly once the roles are loaded).
183185

184-
The plain `UserRepository.findByEmail(String)` finder is retained unchanged for callers that do not need the authority graph (token lookups, existence checks, lockout counters); it intentionally leaves `roles`/`privileges` uninitialized.
186+
The plain `UserRepository.findByEmail(String)` finder is retained unchanged for callers that do not need the authority graph (token lookups, existence checks, lockout counters); it intentionally leaves the user's `roles` collection uninitialized.
185187

186188
### Entity equals/hashCode now identity-based
187189

src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ public class Role implements Serializable {
4444
private Set<User> users = new HashSet<>();
4545

4646
/** The privileges. */
47+
// EAGER by design: privileges are small, static reference data and there is no hot path that loads many Roles at
48+
// once, so eager-loading them is cheap. Keeping this EAGER lets consumers call role.getPrivileges() outside a
49+
// transaction without a LazyInitializationException. The user-load N+1 is addressed by User.roles being LAZY (see
50+
// User), and the authentication path still fetches the full graph in one query via
51+
// UserRepository.findWithRolesByEmail. (Role.privileges was briefly LAZY in 5.0.0; reverted in 5.0.1.)
4752
@ToString.Exclude
48-
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
53+
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.EAGER)
4954
@JoinTable(name = "roles_privileges", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"),
5055
inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id"))
5156
private Set<Privilege> privileges = new HashSet<>();

src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ public interface UserRepository extends JpaRepository<User, Long> {
2525
* Find by email, eagerly loading the user's roles and each role's privileges in a single query via an entity graph.
2626
*
2727
* <p>This is the finder used on the authentication path (see {@code DSUserDetailsService}). Because {@code User.roles}
28-
* and {@code Role.privileges} are now {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse
29-
* roles/privileges after the persistence session closes (e.g. building Spring Security authorities for a detached
30-
* principal) must load the user through this method. The {@code @EntityGraph} ensures the full
31-
* User &rarr; roles &rarr; privileges graph is initialized in one round trip, avoiding both the N+1 problem and a
32-
* {@code LazyInitializationException}. The plain {@link #findByEmail(String)} remains for callers (token lookups,
33-
* existence checks, lockout counters) that do not need the authority graph.</p>
28+
* is {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse a user's roles (and their privileges)
29+
* after the persistence session closes (e.g. building Spring Security authorities for a detached principal) must load
30+
* the user through this method. ({@code Role.privileges} is {@code EAGER}, but the plain finder never loads the
31+
* roles collection itself.) The {@code @EntityGraph} ensures the full User &rarr; roles &rarr; privileges graph is
32+
* initialized in one round trip, avoiding both the N+1 problem and a {@code LazyInitializationException}. The plain
33+
* {@link #findByEmail(String)} remains for callers (token lookups, existence checks, lockout counters) that do not
34+
* need the authority graph.</p>
3435
*
3536
* @param email the email
3637
* @return the user with roles and privileges initialized, or {@code null} if none found

0 commit comments

Comments
 (0)