You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,10 @@
2
2
3
3
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.
4
4
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
+
5
9
## [5.0.0] - 2026-06-15
6
10
7
11
> **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.
Copy file name to clipboardExpand all lines: MIGRATION.md
+8-6Lines changed: 8 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -168,20 +168,22 @@ CREATE UNIQUE INDEX ux_privilege_name ON privilege (name);
168
168
169
169
### Lazy fetching of roles and privileges
170
170
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**.
172
172
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.
174
174
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.
176
176
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:
178
180
179
181
-**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.
180
182
-**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.
181
183
-**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).
183
185
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.
0 commit comments