Skip to content

5.0.1: revert Role.privileges to EAGER (fix LazyInitializationException footgun)#319

Merged
devondragon merged 1 commit into
mainfrom
fix/role-privileges-eager-5.0.1
Jun 15, 2026
Merged

5.0.1: revert Role.privileges to EAGER (fix LazyInitializationException footgun)#319
devondragon merged 1 commit into
mainfrom
fix/role-privileges-eager-5.0.1

Conversation

@devondragon

Copy link
Copy Markdown
Owner

Summary

  • Reverts Role.privileges to FetchType.EAGER (it was changed to LAZY in 5.0.0).
  • User.roles stays LAZY + the @EntityGraph auth query (UserRepository.findWithRolesByEmail) — that is where the meaningful per-user N+1 win is.
  • Updates CHANGELOG.md ([5.0.1]) and MIGRATION.md (lazy-fetch section).

Why

The 5.0.0 LAZY change made role.getPrivileges() throw LazyInitializationException when accessed outside an open transaction/session — the single most natural navigation on a Role — 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

  • Library ./gradlew clean test green (incl. UserRepositoryEntityGraphTestUser.roles still asserts LAZY; query-count bound unaffected).
  • Demo app full ./gradlew test green 3/3 against 5.0.1-SNAPSHOT; AdminRoleAccessControlTest (out-of-transaction getPrivileges()) passes.

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.
Copilot AI review requested due to automatic review settings June 15, 2026 22:49
@devondragon devondragon merged commit 47c6626 into main Jun 15, 2026
4 checks passed
@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown

Code Review

Overall: This is a clean, well-reasoned patch. The revert decision is correct — Role.privileges is genuinely static reference data, so the LazyInitializationException footgun wasn't worth the marginal benefit. The docs are excellent. A few items worth flagging:


Comment length in Role.java (style violation)

The added comment block in Role.java is 5 lines:

// EAGER by design: privileges are small, static reference data and there is no hot path that loads many Roles at
// once, so eager-loading them is cheap. Keeping this EAGER lets consumers call role.getPrivileges() outside a
// transaction without a LazyInitializationException. The user-load N+1 is addressed by User.roles being LAZY (see
// User), and the authentication path still fetches the full graph in one query via
// UserRepository.findWithRolesByEmail. (Role.privileges was briefly LAZY in 5.0.0; reverted in 5.0.1.)

CLAUDE.md is explicit: "one short line max" and "Don't write multi-paragraph docstrings or multi-line comment blocks." The reasoning itself is worth capturing (it's a non-obvious design decision), but it should be condensed to a single line, e.g.:

// EAGER: privileges are static reference data; no bulk-load path exists, and consumers need getPrivileges() outside transactions.

The historical note about 5.0.0/5.0.1 is already in MIGRATION.md and the git log — no need to repeat it inline.


Stale test class Javadoc

UserRepositoryEntityGraphTest.java line 24 still reads:

"This is the regression guard for the EAGER → LAZY switch on User.roles and Role.privileges."

Since Role.privileges is back to EAGER, this is now inaccurate. Should be updated to "...on User.roles" only.

Similarly, the shouldNotInitializeRolesWhenLoadedViaPlainFindByEmail test comment says "proving the EAGER → LAZY switch took effect" — still correct for User.roles, but ambiguous now. Worth tightening to name the collection explicitly.


Missing regression test for the bug being fixed

The existing tests (shouldAccessRolesAndPrivilegesWithoutLazyInitializationExceptionAfterDetach, etc.) all go through findWithRolesByEmail (the entity graph path). None of them verify the specific scenario this PR fixes: role.getPrivileges() working when the role itself was loaded by any plain repository call (not via the entity graph).

A minimal regression test would be:

@Test
void shouldLoadPrivilegesEagerlyOnRoleOutsideTransaction() {
    persistUserWithRolesAndPrivileges("eager-priv@test.com");

    // Load role directly (not via the entity graph user finder)
    User user = userRepository.findByEmail("eager-priv@test.com");
    // Detach before accessing roles (open transaction makes this available)
    Role role = userRepository.findById(/* role id */)...;
    entityManager.getEntityManager().detach(role);

    // Must not throw LazyInitializationException — this was the 5.0.0 bug
    assertThatCode(() -> role.getPrivileges().size()).doesNotThrowAnyException();
    assertThat(Hibernate.isInitialized(role.getPrivileges())).isTrue();
}

Without a dedicated test, the regression can silently re-appear if FetchType.EAGER is changed back again. The demo app's AdminRoleAccessControlTest covers this end-to-end, but the library's own test suite should own the regression.


Minor: roles.privileges in @EntityGraph is now redundant

With Role.privileges back to EAGER, the attributePaths = {"roles", "roles.privileges"} in findWithRolesByEmail loads privileges twice (once from EAGER, once from the entity graph hint). Hibernate handles this gracefully (it deduplicates), but the roles.privileges path is now misleading — it implies privileges require explicit fetching. Keeping it is harmless and preserves explicitness, but dropping it to {"roles"} would be more accurate.


What's working well

  • The fix itself is correct. Reverting to EAGER for Role.privileges while keeping LAZY for User.roles is the right tradeoff split.
  • MIGRATION.md is excellent — the 5.0.0 → 5.0.1 callout note is exactly what consumers upgrading in two steps need.
  • CHANGELOG.md entry is clear and self-contained.
  • The JavaDoc in UserRepository is accurate after the update.
  • Test plan in the PR description is thorough and transparent about what was and wasn't tested in the library vs. demo app.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.privileges from LAZY back to EAGER and documented the design rationale in Role.
  • Updated UserRepository Javadoc to reflect the intended usage with User.roles still being LAZY.
  • Updated MIGRATION.md and CHANGELOG.md to 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.

Comment on lines 25 to +28
* 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 &rarr; roles &rarr; 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)
Comment thread MIGRATION.md
### 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` &rarr; `roles` &rarr; `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` &rarr; `roles` &rarr; `privileges` graph via a new `@EntityGraph` repository finder, `UserRepository.findWithRolesByEmail(String email)`, which fetches everything in a **single query**.
Comment thread MIGRATION.md

**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.
Comment thread CHANGELOG.md

## [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").
@devondragon devondragon deleted the fix/role-privileges-eager-5.0.1 branch June 16, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants