5.0.1: revert Role.privileges to EAGER (fix LazyInitializationException footgun)#319
Conversation
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.
Code ReviewOverall: This is a clean, well-reasoned patch. The revert decision is correct — Comment length in
|
There was a problem hiding this comment.
Pull request overview
This PR reverts the Role.privileges association back to FetchType.EAGER to eliminate a common LazyInitializationException hazard introduced by the 5.0.0 switch to LAZY, while keeping the main performance win of User.roles remaining LAZY and being explicitly fetched on the authentication path via an @EntityGraph.
Changes:
- Reverted
Role.privilegesfromLAZYback toEAGERand documented the design rationale inRole. - Updated
UserRepositoryJavadoc to reflect the intended usage withUser.rolesstill beingLAZY. - Updated
MIGRATION.mdandCHANGELOG.mdto document the 5.0.1 behavior and guidance.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java | Updates Javadoc guidance around lazy roles vs eager privileges and the entity-graph finder. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java | Reverts privileges fetch to EAGER and adds rationale explaining the tradeoff. |
| MIGRATION.md | Updates upgrade guidance to reflect User.roles lazy behavior and the 5.0.0 → 5.0.1 reversal for privileges. |
| CHANGELOG.md | Adds 5.0.1 entry documenting the revert and migration reference. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Find by email, eagerly loading the user's roles and each role's privileges in a single query via an entity graph. | ||
| * | ||
| * <p>This is the finder used on the authentication path (see {@code DSUserDetailsService}). Because {@code User.roles} | ||
| * and {@code Role.privileges} are now {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse | ||
| * roles/privileges after the persistence session closes (e.g. building Spring Security authorities for a detached | ||
| * principal) must load the user through this method. The {@code @EntityGraph} ensures the full | ||
| * User → roles → privileges graph is initialized in one round trip, avoiding both the N+1 problem and a | ||
| * {@code LazyInitializationException}. The plain {@link #findByEmail(String)} remains for callers (token lookups, | ||
| * existence checks, lockout counters) that do not need the authority graph.</p> | ||
| * is {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse a user's roles (and their privileges) |
| ### Lazy fetching of roles and privileges | ||
|
|
||
| **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**. | ||
| **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**. |
|
|
||
| **Remediation patterns for consumers** that traverse a user's roles on a `User` they obtained outside a transaction: | ||
|
|
||
| - **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. |
|
|
||
| ## [5.0.1] - Unreleased | ||
| ### Changed | ||
| - 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"). |
Summary
Role.privilegestoFetchType.EAGER(it was changed toLAZYin 5.0.0).User.rolesstaysLAZY+ the@EntityGraphauth query (UserRepository.findWithRolesByEmail) — that is where the meaningful per-user N+1 win is.CHANGELOG.md([5.0.1]) andMIGRATION.md(lazy-fetch section).Why
The 5.0.0
LAZYchange maderole.getPrivileges()throwLazyInitializationExceptionwhen accessed outside an open transaction/session — the single most natural navigation on aRole— for a negligible gain (privileges are small, static reference data with no bulk-load path). Reverting removes the footgun while keeping the real performance improvement. This is a non-breaking relaxation: code written against 5.0.0 continues to work unchanged.Test Plan
./gradlew clean testgreen (incl.UserRepositoryEntityGraphTest—User.rolesstill asserts LAZY; query-count bound unaffected)../gradlew testgreen 3/3 against5.0.1-SNAPSHOT;AdminRoleAccessControlTest(out-of-transactiongetPrivileges()) passes.