perf(document): EAGER→LAZY migration with @EntityGraph + @BatchSize (#467) #622

Merged
marcel merged 19 commits from worktree-feat+issue-467-lazy-fetch-entity-graph into main 2026-05-19 09:23:32 +02:00
Owner

Summary

  • Switches Document.receivers, tags, trainingLabels, and sender from FetchType.EAGER to FetchType.LAZY, eliminating the N+1 collection queries identified in the pre-prod audit (2,733 queries / 24 HTTP requests)
  • Adds @NamedEntityGraph(\"Document.full\") and @NamedEntityGraph(\"Document.list\") to Document, and @EntityGraph overrides on DocumentRepository so each code path loads exactly what it needs
  • Adds @BatchSize(50) on receivers, tags, sender, and trainingLabels as a fallback for any lazy path not covered by an entity graph
  • Adds @Transactional(readOnly=true) to getDocumentById and getRecentActivity to keep the Hibernate session open for post-return collection access
  • Adds @JsonIgnoreProperties({\"hibernateLazyInitializer\",\"handler\"}) to Person and Tag to prevent Jackson errors on uninitialized lazy proxies

Test plan

  • DocumentRepositoryTest — query-count assertions: findAll(Spec, Pageable) ≤5 statements for 10 docs (was 31); findById ≤2 statements; findAll(Pageable) ≤5 statements (getRecentActivity path)
  • DocumentLazyLoadingTest — 5 @SpringBootTest smoke tests: getDocumentById, getRecentActivity (accesses sender + tags post-return), searchDocuments (receiver + sender sort), dashboardService.getResume — all must not throw LazyInitializationException
  • Full CI suite green

Closes #467

🤖 Generated with Claude Code

## Summary - Switches `Document.receivers`, `tags`, `trainingLabels`, and `sender` from `FetchType.EAGER` to `FetchType.LAZY`, eliminating the N+1 collection queries identified in the pre-prod audit (2,733 queries / 24 HTTP requests) - Adds `@NamedEntityGraph(\"Document.full\")` and `@NamedEntityGraph(\"Document.list\")` to `Document`, and `@EntityGraph` overrides on `DocumentRepository` so each code path loads exactly what it needs - Adds `@BatchSize(50)` on `receivers`, `tags`, `sender`, and `trainingLabels` as a fallback for any lazy path not covered by an entity graph - Adds `@Transactional(readOnly=true)` to `getDocumentById` and `getRecentActivity` to keep the Hibernate session open for post-return collection access - Adds `@JsonIgnoreProperties({\"hibernateLazyInitializer\",\"handler\"})` to `Person` and `Tag` to prevent Jackson errors on uninitialized lazy proxies ## Test plan - [x] `DocumentRepositoryTest` — query-count assertions: `findAll(Spec, Pageable)` ≤5 statements for 10 docs (was 31); `findById` ≤2 statements; `findAll(Pageable)` ≤5 statements (getRecentActivity path) - [x] `DocumentLazyLoadingTest` — 5 `@SpringBootTest` smoke tests: `getDocumentById`, `getRecentActivity` (accesses sender + tags post-return), `searchDocuments` (receiver + sender sort), `dashboardService.getResume` — all must not throw `LazyInitializationException` - [ ] Full CI suite green Closes #467 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 4 commits 2026-05-18 17:48:34 +02:00
Adds Hibernate statistics to the test config and two new tests in
DocumentRepositoryTest:
- findAll_withSpecAndPageable asserts ≤5 statements for 10 documents
  (currently RED: EAGER @ManyToMany generates 31 secondary SELECTs)
- findById regression guard verifies collections load in ≤2 statements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- receivers, tags, trainingLabels: FetchType.EAGER → FetchType.LAZY
- sender: add explicit FetchType.LAZY (was implicitly lazy, now explicit)
- @NamedEntityGraph("Document.full"): sender + receivers + tags
- @NamedEntityGraph("Document.list"): sender + tags
- DocumentRepository.findById overridden with @EntityGraph("Document.full")
- DocumentRepository.findAll(Specification, Pageable) overridden with
  @EntityGraph("Document.list")
- DocumentRepository.findAll(Specification) overridden with
  @EntityGraph("Document.list") for RECEIVER/SENDER sort paths
- @BatchSize(50) on receivers and tags as fallback for any list path
  that does not go through an @EntityGraph method

Fixes issue #467.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- getDocumentById: add @Transactional(readOnly=true) — calls
  tagService.resolveEffectiveColors(doc.getTags()) which requires an open
  session after the LAZY switch
- getRecentActivity: add @Transactional(readOnly=true) — callers may access
  tags/receivers on the returned list; keeps session open for @BatchSize fetches
- updateDocumentTags: add @Transactional — write method was missing annotation

Also adds @JsonIgnoreProperties({"hibernateLazyInitializer","handler"}) to
Person and Tag to prevent Jackson serialization errors on uninitialized
lazy proxies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(document): add @SpringBootTest smoke tests for lazy-loading correctness
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m2s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m6s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
e13b37d585
Five integration tests verify that DocumentService and DashboardService
do not throw LazyInitializationException after the EAGER→LAZY migration:
getDocumentById, getRecentActivity, searchDocuments (receiver/sender sort),
and dashboardService.getResume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid performance fix. The entity graph design is correct, the tests cover the right behaviors, and the @BatchSize fallback is a smart safety net. A few things need attention.


Blockers

1. @Transactional(readOnly=true) on read methods without an explanatory comment

DocumentService.java (lines for getRecentActivity and getDocumentById):

The project style guide explicitly says "Read methods are not annotated (default non-transactional is fine)." Future developers will see these annotations and "clean them up" — breaking lazy loading in the process. The WHY is non-obvious and exactly meets our criterion for adding a comment:

// @Transactional(readOnly=true) required: keeps the Hibernate session open so
// lazy-loaded collections on the returned entity remain accessible to the caller.
@Transactional(readOnly = true)
public Document getDocumentById(UUID id) { ... }

Same for getRecentActivity. Without this comment, this is a ticking time-bomb.


Concerns

2. getRecentActivity is not covered by any entity graph

DocumentService.javagetRecentActivity calls:

documentRepository.findAll(PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt")))

This calls JpaRepository.findAll(Pageable)not the overridden findAll(Specification, Pageable) in DocumentRepository. The entity graph override only applies to the Specification variant. So getRecentActivity gets:

  • A transaction (session stays open for lazy loading)
  • No @EntityGraph("Document.list") — sender is loaded N+1 per document

sender is @ManyToOne(fetch = LAZY) without @BatchSize. For a dashboard showing 10 recent documents, that's 10 extra queries for sender. The @BatchSize on receivers and tags helps those collections, but not sender.

Fix options:

  • Add a @EntityGraph("Document.list") Page<Document> findAll(Pageable pageable) override in DocumentRepository
  • Or annotate sender with @BatchSize(size = 50) as a fallback

3. No query-count test for the getRecentActivity path

DocumentLazyLoadingTest tests that getRecentActivity doesn't throw, but DocumentRepositoryTest has no query-count assertion for findAll(Pageable). This path is silently unguarded against future N+1 regressions.

4. trainingLabels has no @BatchSize

Document.java:

@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(...)
@Builder.Default
private Set<OcrLabel> trainingLabels = new HashSet<>();

If any code path loads many documents and accesses trainingLabels, this will N+1. Adding @BatchSize(size = 50) here is consistent with the approach taken for receivers and tags.


Suggestions

5. Statistics enabled in @AfterEach cleanup order is correct — deleting documents before tags/persons respects FK constraints. No issue there.

6. Test method findById_loadsSenderReceiversAndTagsInAtMostTwoStatements — the ≤2 bound is fine — with two @ManyToMany collections, Hibernate splits into 2 queries to avoid Cartesian product. The assertion is tight enough to catch regressions.


Overall this is a well-structured fix. Items 1 and 2 are the ones I'd want resolved before merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid performance fix. The entity graph design is correct, the tests cover the right behaviors, and the `@BatchSize` fallback is a smart safety net. A few things need attention. --- ### Blockers **1. `@Transactional(readOnly=true)` on read methods without an explanatory comment** `DocumentService.java` (lines for `getRecentActivity` and `getDocumentById`): The project style guide explicitly says _"Read methods are not annotated (default non-transactional is fine)."_ Future developers will see these annotations and "clean them up" — breaking lazy loading in the process. The WHY is non-obvious and exactly meets our criterion for adding a comment: ```java // @Transactional(readOnly=true) required: keeps the Hibernate session open so // lazy-loaded collections on the returned entity remain accessible to the caller. @Transactional(readOnly = true) public Document getDocumentById(UUID id) { ... } ``` Same for `getRecentActivity`. Without this comment, this is a ticking time-bomb. --- ### Concerns **2. `getRecentActivity` is not covered by any entity graph** `DocumentService.java` — `getRecentActivity` calls: ```java documentRepository.findAll(PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))) ``` This calls `JpaRepository.findAll(Pageable)` — **not** the overridden `findAll(Specification, Pageable)` in `DocumentRepository`. The entity graph override only applies to the `Specification` variant. So `getRecentActivity` gets: - ✅ A transaction (session stays open for lazy loading) - ❌ No `@EntityGraph("Document.list")` — sender is loaded N+1 per document `sender` is `@ManyToOne(fetch = LAZY)` without `@BatchSize`. For a dashboard showing 10 recent documents, that's 10 extra queries for sender. The `@BatchSize` on `receivers` and `tags` helps those collections, but not sender. **Fix options:** - Add a `@EntityGraph("Document.list") Page<Document> findAll(Pageable pageable)` override in `DocumentRepository` - Or annotate `sender` with `@BatchSize(size = 50)` as a fallback **3. No query-count test for the `getRecentActivity` path** `DocumentLazyLoadingTest` tests that `getRecentActivity` doesn't throw, but `DocumentRepositoryTest` has no query-count assertion for `findAll(Pageable)`. This path is silently unguarded against future N+1 regressions. **4. `trainingLabels` has no `@BatchSize`** `Document.java`: ```java @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(...) @Builder.Default private Set<OcrLabel> trainingLabels = new HashSet<>(); ``` If any code path loads many documents and accesses `trainingLabels`, this will N+1. Adding `@BatchSize(size = 50)` here is consistent with the approach taken for `receivers` and `tags`. --- ### Suggestions **5. Statistics enabled in `@AfterEach` cleanup order is correct** — deleting documents before tags/persons respects FK constraints. No issue there. **6. Test method `findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` — the ≤2 bound is fine** — with two `@ManyToMany` collections, Hibernate splits into 2 queries to avoid Cartesian product. The assertion is tight enough to catch regressions. --- Overall this is a well-structured fix. Items 1 and 2 are the ones I'd want resolved before merge.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

The architecture is sound. Using @NamedEntityGraph + @EntityGraph repository overrides is exactly the right pattern — it keeps the performance contract in the data layer where it belongs, it's declarative, and it's testable. No objections there.

No schema changes in this PR → no Flyway migration, no DB diagram updates required. ✓
No new domain modules, controllers, routes, Docker services, ErrorCodes, or Permissions. ✓


Blockers

None. The layer boundaries are respected. DocumentService doesn't reach into PersonRepository or TagRepository. Cross-domain lazy-proxy handling (@JsonIgnoreProperties) stays in the entity where it belongs.


Concerns

1. Entity graph coverage is incomplete — getRecentActivity has a silent N+1 for sender

DocumentRepository overrides:

@EntityGraph("Document.list")
Page<Document> findAll(Specification<Document> spec, Pageable pageable);

getRecentActivity calls findAll(Pageable) (the JpaRepository variant, no Specification). The override above does not intercept this call — it's a different method signature. So the Document.list entity graph never applies to getRecentActivity.

Concretely: loading 10 recent documents will trigger 1 query for the documents + up to 10 queries for sender (no @BatchSize on @ManyToOne sender). The @BatchSize(50) on receivers and tags collection fields helps those, but sender is unprotected.

The fix is either:

  • Add @EntityGraph("Document.list") Page<Document> findAll(Pageable pageable) to DocumentRepository
  • Or add @BatchSize(size = 50) to the sender field in Document

This is the most significant correctness gap in the PR.

2. The @Transactional(readOnly=true) deviation from project convention needs documentation

The project's stated style: "Read methods are not annotated (default non-transactional is fine)." The three new @Transactional(readOnly=true) annotations are necessary and correct, but they will look like violations to any future developer. A one-line comment at each call site prevents a regression where someone "cleans up" these annotations and breaks lazy loading.

This is not an ADR-level decision, but it is a non-obvious constraint that deserves a comment. I would normally not flag missing comments — this is the exception.


Notes

Doc table check (no action required):

Changed artifact Doc update needed?
JPA annotations on existing entities No — not a schema change
@EntityGraph on DocumentRepository No — internal query optimization, not a new API surface
New test classes No
application-test.yaml No

The N+1 reduction is real and meaningful. Dropping from 2,733 queries / 24 requests to handful-of-queries is the right goal. The entity graph design is architecturally clean. Resolve the getRecentActivity gap and this is mergeable.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** The architecture is sound. Using `@NamedEntityGraph` + `@EntityGraph` repository overrides is exactly the right pattern — it keeps the performance contract in the data layer where it belongs, it's declarative, and it's testable. No objections there. No schema changes in this PR → no Flyway migration, no DB diagram updates required. ✓ No new domain modules, controllers, routes, Docker services, ErrorCodes, or Permissions. ✓ --- ### Blockers **None.** The layer boundaries are respected. DocumentService doesn't reach into PersonRepository or TagRepository. Cross-domain lazy-proxy handling (`@JsonIgnoreProperties`) stays in the entity where it belongs. --- ### Concerns **1. Entity graph coverage is incomplete — `getRecentActivity` has a silent N+1 for sender** `DocumentRepository` overrides: ```java @EntityGraph("Document.list") Page<Document> findAll(Specification<Document> spec, Pageable pageable); ``` `getRecentActivity` calls `findAll(Pageable)` (the JpaRepository variant, no Specification). The override above does **not** intercept this call — it's a different method signature. So the `Document.list` entity graph never applies to `getRecentActivity`. Concretely: loading 10 recent documents will trigger 1 query for the documents + up to 10 queries for `sender` (no `@BatchSize` on `@ManyToOne sender`). The `@BatchSize(50)` on `receivers` and `tags` collection fields helps those, but `sender` is unprotected. The fix is either: - Add `@EntityGraph("Document.list") Page<Document> findAll(Pageable pageable)` to `DocumentRepository` - Or add `@BatchSize(size = 50)` to the `sender` field in `Document` This is the most significant correctness gap in the PR. **2. The `@Transactional(readOnly=true)` deviation from project convention needs documentation** The project's stated style: _"Read methods are not annotated (default non-transactional is fine)."_ The three new `@Transactional(readOnly=true)` annotations are necessary and correct, but they will look like violations to any future developer. A one-line comment at each call site prevents a regression where someone "cleans up" these annotations and breaks lazy loading. This is not an ADR-level decision, but it is a non-obvious constraint that deserves a comment. I would normally not flag missing comments — this is the exception. --- ### Notes **Doc table check (no action required):** | Changed artifact | Doc update needed? | |---|---| | JPA annotations on existing entities | No — not a schema change | | `@EntityGraph` on `DocumentRepository` | No — internal query optimization, not a new API surface | | New test classes | No | | `application-test.yaml` | No | **The N+1 reduction is real and meaningful.** Dropping from 2,733 queries / 24 requests to handful-of-queries is the right goal. The entity graph design is architecturally clean. Resolve the `getRecentActivity` gap and this is mergeable.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This PR is a pure ORM performance optimization. Reviewed through the full attack surface checklist — no security regressions introduced.


What I checked

1. No new endpoints or HTTP method changes — The PR adds @Transactional annotations to existing service methods and @EntityGraph to repository methods. No new controller routes, no HTTP method widening, no permission changes.

2. @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) on Person and Tag — Standard defensive annotation for Hibernate proxy serialization. Not a security concern. Jackson would otherwise attempt to serialize internal Hibernate proxy state, which could theoretically expose implementation details in error responses; suppressing it is correct.

3. No JPQL or query string changes — The @EntityGraph and @NamedEntityGraph annotations don't modify query strings. All existing parameterized queries remain parameterized. No injection surface added.

4. No change to permission enforcement@RequirePermission annotations on controller methods are unaffected. The @Transactional(readOnly=true) annotations are on service methods, not controllers, and do not bypass authorization checks.

5. No new deserialization paths — The entities were already being serialized. @JsonIgnoreProperties reduces, not expands, what gets serialized.

6. Statistics enabled in test config (generate_statistics: true) — Test-profile only, not production. No exposure.


LGTM from a security perspective. No action required.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This PR is a pure ORM performance optimization. Reviewed through the full attack surface checklist — no security regressions introduced. --- ### What I checked **1. No new endpoints or HTTP method changes** — The PR adds `@Transactional` annotations to existing service methods and `@EntityGraph` to repository methods. No new controller routes, no HTTP method widening, no permission changes. **2. `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` on `Person` and `Tag`** — Standard defensive annotation for Hibernate proxy serialization. Not a security concern. Jackson would otherwise attempt to serialize internal Hibernate proxy state, which could theoretically expose implementation details in error responses; suppressing it is correct. **3. No JPQL or query string changes** — The `@EntityGraph` and `@NamedEntityGraph` annotations don't modify query strings. All existing parameterized queries remain parameterized. No injection surface added. **4. No change to permission enforcement** — `@RequirePermission` annotations on controller methods are unaffected. The `@Transactional(readOnly=true)` annotations are on service methods, not controllers, and do not bypass authorization checks. **5. No new deserialization paths** — The entities were already being serialized. `@JsonIgnoreProperties` reduces, not expands, what gets serialized. **6. Statistics enabled in test config (`generate_statistics: true`)** — Test-profile only, not production. No exposure. --- LGTM from a security perspective. No action required.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Good test coverage for the core lazy-loading behavior. The query-count assertions are the right approach — they'll catch N+1 regressions automatically on every CI run. A few structural gaps to address.


Blockers

1. generate_statistics: true in application-test.yaml adds overhead to ALL tests

application-test.yaml:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

This enables Hibernate statistics collection for every test class in the suite — not just the query-count tests. Statistics collection adds measurable overhead (Hibernate increments counters on every SQL call). The correct approach is to enable statistics only where needed:

// In DocumentRepositoryTest, enable explicitly:
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);  // already done in the test

Since setStatisticsEnabled(true) is already called in the test, the application-test.yaml entry is redundant AND slows down unrelated tests. Remove the generate_statistics: true from the global test config.

2. No query-count test for getRecentActivity

This is the most at-risk code path. getRecentActivity calls findAll(Pageable) without an entity graph (see Felix's and Markus's findings), so it could produce N+1 for sender. There is a smoke test asserting no exception is thrown, but no query-count assertion asserting efficiency. This path needs a DocumentRepositoryTest-style query-count assertion.


Concerns

3. Smoke tests in DocumentLazyLoadingTest only test that no exception is thrown — not that collections are accessible post-return

DocumentLazyLoadingTest:

assertThatCode(() -> documentService.getRecentActivity(3))
        .doesNotThrowAnyException();

This asserts that the method call doesn't throw. It does NOT assert that the returned documents' lazy collections are accessible by the caller (e.g., result.get(0).getSender() outside the transaction). The meaningful test is:

List<Document> results = documentService.getRecentActivity(3);
assertThatCode(() -> {
    results.forEach(d -> assertThat(d.getSender()).isNotNull());
    results.forEach(d -> d.getTags().size()); // force initialization
}).doesNotThrowAnyException();

Without this, the test passes even if callers would get LazyInitializationException in production.

4. @AfterEach deleteAll() — deletion order and FK constraints

The cleanup deletes documents → tags → persons. This works as long as the document join tables (document_receivers, document_tags) are cleaned by Spring Data's deleteAll() cascade. This is correct for JPA-managed join tables, but worth verifying if tests ever add orphaned join table rows directly via SQL. Low risk, noting for completeness.


What's good

  • findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements — tests 10 docs, asserts ≤5 statements. Correct upper bound (accounts for count query + data query + potential join fetches).
  • findById_loadsSenderReceiversAndTagsInAtMostTwoStatements — ≤2 is tight and meaningful. Hibernate typically uses 2 queries for this graph (one query splits the two @ManyToMany collection fetches to avoid Cartesian product).
  • entityManager.flush(); entityManager.clear() before stat assertions — correct isolation from setup queries.
  • The @MockitoBean S3Client and AuditLogQueryService mocking in DocumentLazyLoadingTest keeps the test focused on the lazy-loading behavior without S3 side effects.
## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Good test coverage for the core lazy-loading behavior. The query-count assertions are the right approach — they'll catch N+1 regressions automatically on every CI run. A few structural gaps to address. --- ### Blockers **1. `generate_statistics: true` in `application-test.yaml` adds overhead to ALL tests** `application-test.yaml`: ```yaml spring: jpa: properties: hibernate: generate_statistics: true ``` This enables Hibernate statistics collection for every test class in the suite — not just the query-count tests. Statistics collection adds measurable overhead (Hibernate increments counters on every SQL call). The correct approach is to enable statistics only where needed: ```java // In DocumentRepositoryTest, enable explicitly: Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.setStatisticsEnabled(true); // already done in the test ``` Since `setStatisticsEnabled(true)` is already called in the test, the `application-test.yaml` entry is redundant AND slows down unrelated tests. Remove the `generate_statistics: true` from the global test config. **2. No query-count test for `getRecentActivity`** This is the most at-risk code path. `getRecentActivity` calls `findAll(Pageable)` without an entity graph (see Felix's and Markus's findings), so it could produce N+1 for `sender`. There is a smoke test asserting no exception is thrown, but no query-count assertion asserting efficiency. This path needs a `DocumentRepositoryTest`-style query-count assertion. --- ### Concerns **3. Smoke tests in `DocumentLazyLoadingTest` only test that no exception is thrown — not that collections are accessible post-return** `DocumentLazyLoadingTest`: ```java assertThatCode(() -> documentService.getRecentActivity(3)) .doesNotThrowAnyException(); ``` This asserts that the method call doesn't throw. It does NOT assert that the returned documents' lazy collections are accessible by the caller (e.g., `result.get(0).getSender()` outside the transaction). The meaningful test is: ```java List<Document> results = documentService.getRecentActivity(3); assertThatCode(() -> { results.forEach(d -> assertThat(d.getSender()).isNotNull()); results.forEach(d -> d.getTags().size()); // force initialization }).doesNotThrowAnyException(); ``` Without this, the test passes even if callers would get `LazyInitializationException` in production. **4. `@AfterEach deleteAll()` — deletion order and FK constraints** The cleanup deletes documents → tags → persons. This works as long as the document join tables (`document_receivers`, `document_tags`) are cleaned by Spring Data's `deleteAll()` cascade. This is correct for JPA-managed join tables, but worth verifying if tests ever add orphaned join table rows directly via SQL. Low risk, noting for completeness. --- ### What's good - `findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements` — tests 10 docs, asserts ≤5 statements. Correct upper bound (accounts for count query + data query + potential join fetches). - `findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` — ≤2 is tight and meaningful. Hibernate typically uses 2 queries for this graph (one query splits the two `@ManyToMany` collection fetches to avoid Cartesian product). - `entityManager.flush(); entityManager.clear()` before stat assertions — correct isolation from setup queries. - The `@MockitoBean S3Client` and `AuditLogQueryService` mocking in `DocumentLazyLoadingTest` keeps the test focused on the lazy-loading behavior without S3 side effects.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no Compose file edits, no CI pipeline changes, no new Docker services, no new environment variables. The change is entirely within the JPA and test layer, which is Felix's territory.


One note: generate_statistics: true in application-test.yaml

application-test.yaml now enables Hibernate statistics globally for the test profile. This adds a small but real overhead to every test class in CI — Hibernate increments atomic counters on every SQL call when statistics are enabled.

Since the query-count tests already call stats.setStatisticsEnabled(true) explicitly in the test body, the global config entry is redundant and should be removed to keep CI run time tight. (Sara flagged this too — just confirming from the CI perspective.)

The CI pipeline itself does not need changes. The new @SpringBootTest classes in DocumentLazyLoadingTest will pick up the existing Testcontainers config via @Import(PostgresContainerConfig.class).


Everything else is clean. LGTM from infrastructure and platform perspective.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no Compose file edits, no CI pipeline changes, no new Docker services, no new environment variables. The change is entirely within the JPA and test layer, which is Felix's territory. --- ### One note: `generate_statistics: true` in `application-test.yaml` `application-test.yaml` now enables Hibernate statistics globally for the test profile. This adds a small but real overhead to every test class in CI — Hibernate increments atomic counters on every SQL call when statistics are enabled. Since the query-count tests already call `stats.setStatisticsEnabled(true)` explicitly in the test body, the global config entry is redundant and should be removed to keep CI run time tight. (Sara flagged this too — just confirming from the CI perspective.) The CI pipeline itself does not need changes. The new `@SpringBootTest` classes in `DocumentLazyLoadingTest` will pick up the existing Testcontainers config via `@Import(PostgresContainerConfig.class)`. --- Everything else is clean. LGTM from infrastructure and platform perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

The PR description maps cleanly to the original issue (#467) and the implementation matches all stated requirements. From a requirements traceability perspective:


Traceability check

Requirement (from PR description) Implemented Tested
Switch receivers, tags, trainingLabels, sender to LAZY Document.java DocumentLazyLoadingTest
@NamedEntityGraph("Document.full") and ("Document.list") Document.java DocumentRepositoryTest
@EntityGraph overrides on DocumentRepository DocumentRepository.java query-count assertions
@BatchSize(50) on receivers and tags Document.java — (no direct assertion, fallback behavior)
@Transactional(readOnly=true) on getDocumentById and getRecentActivity DocumentService.java DocumentLazyLoadingTest
@JsonIgnoreProperties on Person and Tag both entity files — (implicit in serialization)

Gap: stated test plan vs. actual coverage

The PR test plan says:

findAll(Spec, Pageable) ≤5 statements for 10 docs (was 31)

This is tested.

But the pre-prod audit motivation was "2,733 queries / 24 HTTP requests." The test suite exercises individual code paths (getDocumentById, getRecentActivity, searchDocuments) but there's no end-to-end query-count assertion reflecting the full HTTP-request scenario. This is acceptable for a unit/integration test suite, but the "was 31 → now ≤5" improvement should ideally be re-verified manually in the pre-prod environment before closing #467.


Acceptance criteria interpretation

The original issue #467 presumably defined success as "eliminating N+1 for document list and detail views." This PR achieves that for the primary paths (findAll(Spec, Pageable) → list view, findById → detail view). The getRecentActivity path used on the dashboard is partially addressed (no LazyInitializationException) but may still have N+1 for sender — this is a scope question for the original issue.

Recommendation: Before closing #467, confirm whether dashboard document loading was part of the original N+1 audit scope, and if so, track the getRecentActivity sender N+1 gap as either a bug against this PR or a follow-up issue.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** The PR description maps cleanly to the original issue (#467) and the implementation matches all stated requirements. From a requirements traceability perspective: --- ### Traceability check | Requirement (from PR description) | Implemented | Tested | |---|---|---| | Switch receivers, tags, trainingLabels, sender to LAZY | ✅ `Document.java` | ✅ `DocumentLazyLoadingTest` | | `@NamedEntityGraph("Document.full")` and `("Document.list")` | ✅ `Document.java` | ✅ `DocumentRepositoryTest` | | `@EntityGraph` overrides on `DocumentRepository` | ✅ `DocumentRepository.java` | ✅ query-count assertions | | `@BatchSize(50)` on receivers and tags | ✅ `Document.java` | — (no direct assertion, fallback behavior) | | `@Transactional(readOnly=true)` on `getDocumentById` and `getRecentActivity` | ✅ `DocumentService.java` | ✅ `DocumentLazyLoadingTest` | | `@JsonIgnoreProperties` on `Person` and `Tag` | ✅ both entity files | — (implicit in serialization) | --- ### Gap: stated test plan vs. actual coverage The PR test plan says: > `findAll(Spec, Pageable)` ≤5 statements for 10 docs (was 31) This is tested. ✅ But the pre-prod audit motivation was "2,733 queries / 24 HTTP requests." The test suite exercises individual code paths (getDocumentById, getRecentActivity, searchDocuments) but there's no end-to-end query-count assertion reflecting the full HTTP-request scenario. This is acceptable for a unit/integration test suite, but the "was 31 → now ≤5" improvement should ideally be re-verified manually in the pre-prod environment before closing #467. --- ### Acceptance criteria interpretation The original issue #467 presumably defined success as "eliminating N+1 for document list and detail views." This PR achieves that for the primary paths (`findAll(Spec, Pageable)` → list view, `findById` → detail view). The `getRecentActivity` path used on the dashboard is partially addressed (no LazyInitializationException) but may still have N+1 for sender — this is a scope question for the original issue. **Recommendation:** Before closing #467, confirm whether dashboard document loading was part of the original N+1 audit scope, and if so, track the `getRecentActivity` sender N+1 gap as either a bug against this PR or a follow-up issue.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This PR is a backend JPA performance optimization with no frontend or UI changes. No Svelte components, no routes, no template markup, no CSS, no i18n strings were modified.

From a UX perspective, the change is a net positive: reducing from ~2,700 queries to a handful per page will result in faster response times, which directly improves the experience for our senior users on slower connections. Performance IS usability — a page that loads in 500ms vs 3 seconds is a fundamentally different experience for a 65-year-old on a rural DSL connection.

No accessibility, layout, or frontend concerns to flag. LGTM.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR is a backend JPA performance optimization with no frontend or UI changes. No Svelte components, no routes, no template markup, no CSS, no i18n strings were modified. From a UX perspective, the change is a net positive: reducing from ~2,700 queries to a handful per page will result in faster response times, which directly improves the experience for our senior users on slower connections. Performance IS usability — a page that loads in 500ms vs 3 seconds is a fundamentally different experience for a 65-year-old on a rural DSL connection. No accessibility, layout, or frontend concerns to flag. LGTM.
marcel added 6 commits 2026-05-18 19:06:31 +02:00
Stats tracking is already enabled per-test via setStatisticsEnabled(true);
enabling it globally added unnecessary overhead to every test in the suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds failing test: findAll(Pageable) must not N+1 sender for 5 docs.
Without @EntityGraph override for this overload, each document triggers
a separate SELECT for its lazy sender.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getRecentActivity calls findAll(Pageable) — the JpaRepository overload
not covered by the existing Specification variants. Without this override,
sender is loaded N+1 per document. Now applies Document.list graph so
sender and tags are fetched eagerly for every findAll(Pageable) call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consistent with the @BatchSize already on receivers and tags. Any lazy
code path not covered by an entity graph will batch-load these associations
instead of issuing one query per document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These annotations deviate from the project convention (read methods are
normally unannotated). The comment explains that the session must stay
open for callers to access lazy-loaded collections post-return, preventing
future developers from removing the annotation as a cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(document): strengthen getRecentActivity smoke test for post-return access
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m9s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
3358f0509f
Previous version only asserted the method call didn't throw. Now the test
captures the returned list and asserts that sender.getLastName() and
tags.size() are accessible outside the transaction, which is the scenario
that would have failed with a LazyInitializationException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed

All open concerns from Felix, Markus, Sara, and Tobias resolved in 6 commits.


@Sara / @Tobias — Blocker 1: generate_statistics: true in application-test.yaml

Removed in 0dd7d8a5. The global YAML entry was redundant — setStatisticsEnabled(true) is already called in-test before each query-count assertion.

@Felix / @Markus — getRecentActivity N+1 for sender (most significant gap)

Fixed in 108ab1a2: added @EntityGraph("Document.list") Page<Document> findAll(Pageable pageable) to DocumentRepository. getRecentActivity calls the findAll(Pageable) overload — not the Specification variant — so it never received the entity graph. This override intercepts that call and applies the Document.list graph (sender + tags).

@Felix — trainingLabels has no @BatchSize

Fixed in e6777561: @BatchSize(size = 50) added to both trainingLabels and sender, consistent with the existing @BatchSize on receivers and tags.

@Felix / @Markus — Blocker: @Transactional(readOnly=true) without explanatory comment

Fixed in 667b237c: one-line WHY comments added above both getRecentActivity and getDocumentById explaining that the annotation keeps the Hibernate session open for post-return lazy collection access — preventing future "cleanup" regressions.

@Sara / @Felix — Blocker 2: No query-count test for findAll(Pageable) path

Added in 5f83fa1b: findAll_withPageable_loadsSenderWithoutNPlusOne in DocumentRepositoryTest — 5 docs, asserts ≤5 statements. Without the entity graph override this test fails (N+1 for sender = 1 + 5 = 6+ statements).

@Sara — Concern 3: Smoke test only checked no exception on the call, not post-return access

Fixed in 3358f050: getRecentActivity_collectionsAccessibleAfterReturn now captures the returned List<Document> and asserts that d.getSender().getLastName() and d.getTags().size() are accessible outside the transaction — the exact failure mode that would surface in production.


Branch pushed. CI running.

## Review concerns addressed All open concerns from Felix, Markus, Sara, and Tobias resolved in 6 commits. --- ### @Sara / @Tobias — Blocker 1: `generate_statistics: true` in `application-test.yaml` ✅ Removed in `0dd7d8a5`. The global YAML entry was redundant — `setStatisticsEnabled(true)` is already called in-test before each query-count assertion. ### @Felix / @Markus — `getRecentActivity` N+1 for sender (most significant gap) ✅ Fixed in `108ab1a2`: added `@EntityGraph("Document.list") Page<Document> findAll(Pageable pageable)` to `DocumentRepository`. `getRecentActivity` calls the `findAll(Pageable)` overload — not the `Specification` variant — so it never received the entity graph. This override intercepts that call and applies the `Document.list` graph (sender + tags). ### @Felix — `trainingLabels` has no `@BatchSize` ✅ Fixed in `e6777561`: `@BatchSize(size = 50)` added to both `trainingLabels` and `sender`, consistent with the existing `@BatchSize` on `receivers` and `tags`. ### @Felix / @Markus — Blocker: `@Transactional(readOnly=true)` without explanatory comment ✅ Fixed in `667b237c`: one-line WHY comments added above both `getRecentActivity` and `getDocumentById` explaining that the annotation keeps the Hibernate session open for post-return lazy collection access — preventing future "cleanup" regressions. ### @Sara / @Felix — Blocker 2: No query-count test for `findAll(Pageable)` path ✅ Added in `5f83fa1b`: `findAll_withPageable_loadsSenderWithoutNPlusOne` in `DocumentRepositoryTest` — 5 docs, asserts ≤5 statements. Without the entity graph override this test fails (N+1 for sender = 1 + 5 = 6+ statements). ### @Sara — Concern 3: Smoke test only checked no exception on the call, not post-return access ✅ Fixed in `3358f050`: `getRecentActivity_collectionsAccessibleAfterReturn` now captures the returned `List<Document>` and asserts that `d.getSender().getLastName()` and `d.getTags().size()` are accessible outside the transaction — the exact failure mode that would surface in production. --- Branch pushed. CI running.
marcel added 1 commit 2026-05-18 21:12:16 +02:00
fix(document): remove @BatchSize from @ManyToOne sender — not supported
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m10s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m18s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
29eaa253a6
Hibernate throws AnnotationException at startup when @BatchSize is placed
on a @ManyToOne field. @BatchSize is only valid on collections (@OneToMany,
@ManyToMany, @ElementCollection). The N+1 for sender is already covered by
the @EntityGraph overrides on DocumentRepository.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

This is a well-executed performance migration. The EntityGraph strategy is correct — two graphs (Document.full for single-document access, Document.list for list/search) is exactly the right cardinality, and using named entity graphs on the entity rather than inline in queries keeps the definition in one place. The @BatchSize fallback is a prudent safety net.

Blockers

1. Missing ADR for the EAGER→LAZY strategy decision

This is an architectural decision with lasting consequences — every future caller that accesses lazy collections must now do so within a Hibernate session, or risk LazyInitializationException. There is no record of:

  • Why @EntityGraph was chosen over @BatchSize-only (both were considered)
  • Why two graphs instead of one unified graph
  • The consequences: read methods now require @Transactional(readOnly = true), which is a project-wide convention change

Create docs/adr/ADR-XXX-eager-to-lazy-fetch-strategy.md covering: Context (2,733 queries / 24 HTTP requests), Decision, Alternatives considered, Consequences.

2. @Transactional(readOnly = true) on read methods is an undocumented exception to the project's stated convention

CLAUDE.md says:

Read methods are not annotated (default non-transactional is fine).

getDocumentById and getRecentActivity now carry @Transactional(readOnly = true). The WHY comments are good, but this creates a trap for the next developer who sees the comment and removes it "to clean up." The CLAUDE.md section on Services should note that read methods serving lazy-initialized collections require @Transactional(readOnly = true).

Suggestions

findAll(Specification<Document> spec) returning List<Document> — is this called?

The new @EntityGraph("Document.list") List<Document> findAll(Specification<Document> spec) override appears in the repository but I see no evidence it is called from any service. If it is unused, it should be removed — an unused @EntityGraph override that contradicts the standard JpaSpecificationExecutor.findAll(Spec) signature is a maintenance liability.

trainingLabels in EntityGraphs

trainingLabels is an @ElementCollection of an @Enumerated value — it maps to strings, not entities. It is not included in either Document.full or Document.list, relying solely on @BatchSize(50). This is correct for a string collection used only in OCR/ML flows, but it is worth confirming: does trainingLabels need to be accessible post-session in any of the affected code paths? If so, it should be added to the relevant graph.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** This is a well-executed performance migration. The EntityGraph strategy is correct — two graphs (`Document.full` for single-document access, `Document.list` for list/search) is exactly the right cardinality, and using named entity graphs on the entity rather than inline in queries keeps the definition in one place. The `@BatchSize` fallback is a prudent safety net. ### Blockers **1. Missing ADR for the EAGER→LAZY strategy decision** This is an architectural decision with lasting consequences — every future caller that accesses lazy collections must now do so within a Hibernate session, or risk `LazyInitializationException`. There is no record of: - Why `@EntityGraph` was chosen over `@BatchSize`-only (both were considered) - Why two graphs instead of one unified graph - The consequences: read methods now require `@Transactional(readOnly = true)`, which is a project-wide convention change Create `docs/adr/ADR-XXX-eager-to-lazy-fetch-strategy.md` covering: Context (2,733 queries / 24 HTTP requests), Decision, Alternatives considered, Consequences. **2. `@Transactional(readOnly = true)` on read methods is an undocumented exception to the project's stated convention** `CLAUDE.md` says: > *Read methods are not annotated (default non-transactional is fine).* `getDocumentById` and `getRecentActivity` now carry `@Transactional(readOnly = true)`. The WHY comments are good, but this creates a trap for the next developer who sees the comment and removes it "to clean up." The CLAUDE.md section on Services should note that read methods serving lazy-initialized collections require `@Transactional(readOnly = true)`. ### Suggestions **`findAll(Specification<Document> spec)` returning `List<Document>` — is this called?** The new `@EntityGraph("Document.list") List<Document> findAll(Specification<Document> spec)` override appears in the repository but I see no evidence it is called from any service. If it is unused, it should be removed — an unused `@EntityGraph` override that contradicts the standard `JpaSpecificationExecutor.findAll(Spec)` signature is a maintenance liability. **`trainingLabels` in EntityGraphs** `trainingLabels` is an `@ElementCollection` of an `@Enumerated` value — it maps to strings, not entities. It is not included in either `Document.full` or `Document.list`, relying solely on `@BatchSize(50)`. This is correct for a string collection used only in OCR/ML flows, but it is worth confirming: does `trainingLabels` need to be accessible post-session in any of the affected code paths? If so, it should be added to the relevant graph.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Clean implementation. The EntityGraph definitions on the entity are correct, the two-graph strategy is well-scoped, and the WHY comments on the @Transactional(readOnly = true) methods are exactly the right kind of comment: they explain a hidden constraint, not what the code does.

Blockers

findById_loadsSenderReceiversAndTagsInAtMostTwoStatements never enables Hibernate statistics

In DocumentRepositoryTest.java, test 1 (findAll_withSpec...) and test 3 (findAll_withPageable...) both call stats.setStatisticsEnabled(true) before stats.clear(). Test 2 (findById_loads...) only calls stats.clear() — the enable call is missing:

// Current — BROKEN:
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();   // ← setStatisticsEnabled(true) is missing

// Fixed:
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();

If this test runs in isolation (or before test 1), statistics are disabled and getPrepareStatementCount() returns 0. The assertion ≤ 2 passes vacuously — a future N+1 regression would go undetected. This test is currently unreliable.

Suggestions

@AfterEach cleanup() in DocumentLazyLoadingTest — use @Transactional for auto-rollback instead

Manual deleteAll() calls in @AfterEach are fragile (ordering matters, failures leave dirty state). Annotate the test class with @Transactional and remove the @AfterEach:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional  // ← each test auto-rolls back; no @AfterEach needed
class DocumentLazyLoadingTest { ... }

Note: @Transactional on @SpringBootTest wraps the entire test method in a transaction, which is exactly what's needed to keep the Hibernate session open for the lazy-loading assertions.

findAll(Specification<Document> spec) override returning List<Document> — verify it is used

If no production code calls documentRepository.findAll(spec) (without pageable), this override is dead code. Remove it — unused @EntityGraph overrides are a maintenance liability.

Repeated builder boilerplate across lazy-loading tests

The 5 tests in DocumentLazyLoadingTest each inline 4–6 personRepository.save(...), tagRepository.save(...), documentRepository.save(...) calls. Extract to private factory helpers:

private Document savedDocumentWithSenderAndTag(Person sender, Tag tag) {
    return documentRepository.save(Document.builder()
        .title("Test").originalFilename("test.pdf")
        .status(DocumentStatus.UPLOADED)
        .sender(sender).tags(new HashSet<>(Set.of(tag))).build());
}

The test bodies would shrink to 2–3 lines of relevant setup and 1–2 assertions — the intent becomes immediately readable.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Clean implementation. The EntityGraph definitions on the entity are correct, the two-graph strategy is well-scoped, and the WHY comments on the `@Transactional(readOnly = true)` methods are exactly the right kind of comment: they explain a hidden constraint, not what the code does. ### Blockers **`findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` never enables Hibernate statistics** In `DocumentRepositoryTest.java`, test 1 (`findAll_withSpec...`) and test 3 (`findAll_withPageable...`) both call `stats.setStatisticsEnabled(true)` before `stats.clear()`. Test 2 (`findById_loads...`) only calls `stats.clear()` — the enable call is missing: ```java // Current — BROKEN: Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.clear(); // ← setStatisticsEnabled(true) is missing // Fixed: Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.setStatisticsEnabled(true); stats.clear(); ``` If this test runs in isolation (or before test 1), statistics are disabled and `getPrepareStatementCount()` returns 0. The assertion `≤ 2` passes vacuously — a future N+1 regression would go undetected. This test is currently unreliable. ### Suggestions **`@AfterEach cleanup()` in `DocumentLazyLoadingTest` — use `@Transactional` for auto-rollback instead** Manual `deleteAll()` calls in `@AfterEach` are fragile (ordering matters, failures leave dirty state). Annotate the test class with `@Transactional` and remove the `@AfterEach`: ```java @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) @Transactional // ← each test auto-rolls back; no @AfterEach needed class DocumentLazyLoadingTest { ... } ``` Note: `@Transactional` on `@SpringBootTest` wraps the entire test method in a transaction, which is exactly what's needed to keep the Hibernate session open for the lazy-loading assertions. **`findAll(Specification<Document> spec)` override returning `List<Document>` — verify it is used** If no production code calls `documentRepository.findAll(spec)` (without pageable), this override is dead code. Remove it — unused `@EntityGraph` overrides are a maintenance liability. **Repeated builder boilerplate across lazy-loading tests** The 5 tests in `DocumentLazyLoadingTest` each inline 4–6 `personRepository.save(...)`, `tagRepository.save(...)`, `documentRepository.save(...)` calls. Extract to private factory helpers: ```java private Document savedDocumentWithSenderAndTag(Person sender, Tag tag) { return documentRepository.save(Document.builder() .title("Test").originalFilename("test.pdf") .status(DocumentStatus.UPLOADED) .sender(sender).tags(new HashSet<>(Set.of(tag))).build()); } ``` The test bodies would shrink to 2–3 lines of relevant setup and 1–2 assertions — the intent becomes immediately readable.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no Compose file modifications, no CI workflow changes, no new Docker services or images. Nothing for me to flag.

What I checked

  • CI impact: Two new test classes (DocumentLazyLoadingTest with 5 @SpringBootTest tests, DocumentRepositoryTest with 3 new query-count tests) all use Testcontainers/PostgreSQL — correct approach, no H2 in sight. These will add to CI test duration, but that is expected and appropriate for integration-level coverage of a persistence optimization.

  • @MockitoBean S3Client in DocumentLazyLoadingTest: correctly prevents real S3 calls during integration tests. No secrets leak risk.

  • @MockitoBean AuditLogQueryService in DocumentLazyLoadingTest: correctly stubs the audit dependency so the dashboard test does not require a running audit infrastructure.

  • No new environment variables, no new ports, no new volumes: the operational surface is unchanged.

One observation

The Hibernate statistics collection (SessionFactory.getStatistics()) is left enabled in DocumentRepositoryTest after the query-count assertions run. Since these are @DataJpaTest tests and each test gets its own context or shares one, this should have no production impact — just worth being aware of if Hibernate stats overhead ever shows up in CI timing reports.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no Compose file modifications, no CI workflow changes, no new Docker services or images. Nothing for me to flag. ### What I checked - **CI impact**: Two new test classes (`DocumentLazyLoadingTest` with 5 `@SpringBootTest` tests, `DocumentRepositoryTest` with 3 new query-count tests) all use Testcontainers/PostgreSQL — correct approach, no H2 in sight. These will add to CI test duration, but that is expected and appropriate for integration-level coverage of a persistence optimization. - **`@MockitoBean S3Client`** in `DocumentLazyLoadingTest`: correctly prevents real S3 calls during integration tests. No secrets leak risk. - **`@MockitoBean AuditLogQueryService`** in `DocumentLazyLoadingTest`: correctly stubs the audit dependency so the dashboard test does not require a running audit infrastructure. - **No new environment variables, no new ports, no new volumes**: the operational surface is unchanged. ### One observation The Hibernate statistics collection (`SessionFactory.getStatistics()`) is left enabled in `DocumentRepositoryTest` after the query-count assertions run. Since these are `@DataJpaTest` tests and each test gets its own context or shares one, this should have no production impact — just worth being aware of if Hibernate stats overhead ever shows up in CI timing reports.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This is a pure performance migration — no new endpoints, no auth changes, no data exposure changes. Nothing exploitable introduced.

What I audited

@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) on Person and Tag

Safe. This annotation prevents Jackson from attempting to serialize Hibernate proxy internals when lazy associations are not initialized. It does not suppress any business data fields — only internal Hibernate infrastructure properties. There is no data exposure risk here.

@Transactional(readOnly = true) on getDocumentById and getRecentActivity

Mild positive from a security standpoint. Read-only transactions:

  • Set Connection.setReadOnly(true) at the JDBC level — databases may refuse writes within such a transaction
  • Reduce the risk of accidental dirty writes within the same transaction boundary (e.g., if Hibernate's first-level cache flushes unexpectedly)

This is not a security control per se, but it narrows the blast radius.

@EntityGraph overrides in DocumentRepository

These only change the join strategy for data the caller already has permission to access. There is no privilege escalation: findById still returns one document (access-controlled upstream by @RequirePermission on the controller), and the entity graphs fetch only what was already eagerly loaded before this PR.

No new controllers, no @CrossOrigin changes, no permission model changes

The @RequirePermission layer is untouched. Security perimeter is unchanged.

One smell (not a blocker)

The existing pattern of serializing Person and Tag entities directly as API responses (rather than DTOs) means that adding @JsonIgnoreProperties to the entity is the correct fix here. However, as the domain model grows and more lazy associations are added, this pattern will require additional @JsonIgnoreProperties entries on entities. This is a future maintenance concern, not a security issue for this PR.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This is a pure performance migration — no new endpoints, no auth changes, no data exposure changes. Nothing exploitable introduced. ### What I audited **`@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` on `Person` and `Tag`** Safe. This annotation prevents Jackson from attempting to serialize Hibernate proxy internals when lazy associations are not initialized. It does not suppress any business data fields — only internal Hibernate infrastructure properties. There is no data exposure risk here. **`@Transactional(readOnly = true)` on `getDocumentById` and `getRecentActivity`** Mild positive from a security standpoint. Read-only transactions: - Set `Connection.setReadOnly(true)` at the JDBC level — databases may refuse writes within such a transaction - Reduce the risk of accidental dirty writes within the same transaction boundary (e.g., if Hibernate's first-level cache flushes unexpectedly) This is not a security control per se, but it narrows the blast radius. **`@EntityGraph` overrides in `DocumentRepository`** These only change the join strategy for data the caller already has permission to access. There is no privilege escalation: `findById` still returns one document (access-controlled upstream by `@RequirePermission` on the controller), and the entity graphs fetch only what was already eagerly loaded before this PR. **No new controllers, no `@CrossOrigin` changes, no permission model changes** The `@RequirePermission` layer is untouched. Security perimeter is unchanged. ### One smell (not a blocker) The existing pattern of serializing `Person` and `Tag` entities directly as API responses (rather than DTOs) means that adding `@JsonIgnoreProperties` to the entity is the correct fix here. However, as the domain model grows and more lazy associations are added, this pattern will require additional `@JsonIgnoreProperties` entries on entities. This is a future maintenance concern, not a security issue for this PR.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Good coverage strategy — both the repository-level query-count tests and the service-level LazyInitializationException smoke tests are the right tool for verifying a fetch-strategy migration. But there is one silent false-negative risk that must be fixed before merge.

Blockers

findById_loadsSenderReceiversAndTagsInAtMostTwoStatements has a statistics-not-enabled bug

In DocumentRepositoryTest, tests 1 and 3 both call stats.setStatisticsEnabled(true) before running. Test 2 — findById_loads... — skips that call:

// DocumentRepositoryTest.java — findById test
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
// ← MISSING: stats.setStatisticsEnabled(true);
stats.clear();

documentRepository.findById(doc.getId());

assertThat(stats.getPrepareStatementCount())
    .as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
    .isLessThanOrEqualTo(2);

When this test runs in isolation (e.g., mvnw test -Dtest=DocumentRepositoryTest#findById_loads...), Hibernate statistics are disabled and getPrepareStatementCount() returns 0. The assertion ≤ 2 always passes — the test would never catch a regression from @EntityGraph("Document.full") being removed.

Fix: add stats.setStatisticsEnabled(true); before stats.clear() in every query-count test.

Suggestions

DocumentLazyLoadingTest: use @Transactional for auto-rollback instead of @AfterEach cleanup

The current @AfterEach deletes documents → tags → persons manually. This is fragile: a failed test leaves the database dirty, and ordering matters (FK constraints). Annotating the test class with @Transactional gives automatic rollback after each test — simpler, faster, and order-independent. Note: @SpringBootTest + @Transactional on the test method wraps the entire test in a single transaction and rolls it back after. This is appropriate here since the lazy-loading assertions are made within the same test method.

searchDocuments_receiverSort_doesNotThrowLazyInitializationException — too weak an assertion

The test only asserts doesNotThrowAnyException(). A searchDocuments implementation that returns an empty page would also pass. Consider adding a basic count assertion:

var page = documentService.searchDocuments(...);
assertThat(page.getTotalElements()).isGreaterThan(0);
assertThatCode(() -> page.getContent().forEach(d -> d.getSender().getLastName())).doesNotThrowAnyException();

Missing coverage: findAll(Specification<Document> spec) (non-paginated) @EntityGraph override

The new List<Document> findAll(Specification<Document> spec) override with @EntityGraph("Document.list") has no corresponding query-count or LazyInitialization test. If it is used in production, it needs the same coverage as the other overrides.

Test data naming collision risk

DocumentLazyLoadingTest uses static last names like "LzSender", "RaSender", "SrReceiver" to differentiate data. This works with the current @AfterEach cleanup, but would break if two tests ran concurrently or if cleanup failed. This is another reason to prefer @Transactional rollback — each test gets a fresh transaction scope with no risk of cross-test contamination.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Good coverage strategy — both the repository-level query-count tests and the service-level LazyInitializationException smoke tests are the right tool for verifying a fetch-strategy migration. But there is one silent false-negative risk that must be fixed before merge. ### Blockers **`findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` has a statistics-not-enabled bug** In `DocumentRepositoryTest`, tests 1 and 3 both call `stats.setStatisticsEnabled(true)` before running. Test 2 — `findById_loads...` — skips that call: ```java // DocumentRepositoryTest.java — findById test Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); // ← MISSING: stats.setStatisticsEnabled(true); stats.clear(); documentRepository.findById(doc.getId()); assertThat(stats.getPrepareStatementCount()) .as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4") .isLessThanOrEqualTo(2); ``` When this test runs in isolation (e.g., `mvnw test -Dtest=DocumentRepositoryTest#findById_loads...`), Hibernate statistics are disabled and `getPrepareStatementCount()` returns 0. The assertion `≤ 2` always passes — the test would never catch a regression from `@EntityGraph("Document.full")` being removed. Fix: add `stats.setStatisticsEnabled(true);` before `stats.clear()` in every query-count test. ### Suggestions **`DocumentLazyLoadingTest`: use `@Transactional` for auto-rollback instead of `@AfterEach` cleanup** The current `@AfterEach` deletes documents → tags → persons manually. This is fragile: a failed test leaves the database dirty, and ordering matters (FK constraints). Annotating the test class with `@Transactional` gives automatic rollback after each test — simpler, faster, and order-independent. Note: `@SpringBootTest` + `@Transactional` on the test method wraps the entire test in a single transaction and rolls it back after. This is appropriate here since the lazy-loading assertions are made within the same test method. **`searchDocuments_receiverSort_doesNotThrowLazyInitializationException` — too weak an assertion** The test only asserts `doesNotThrowAnyException()`. A `searchDocuments` implementation that returns an empty page would also pass. Consider adding a basic count assertion: ```java var page = documentService.searchDocuments(...); assertThat(page.getTotalElements()).isGreaterThan(0); assertThatCode(() -> page.getContent().forEach(d -> d.getSender().getLastName())).doesNotThrowAnyException(); ``` **Missing coverage: `findAll(Specification<Document> spec)` (non-paginated) `@EntityGraph` override** The new `List<Document> findAll(Specification<Document> spec)` override with `@EntityGraph("Document.list")` has no corresponding query-count or LazyInitialization test. If it is used in production, it needs the same coverage as the other overrides. **Test data naming collision risk** `DocumentLazyLoadingTest` uses static last names like `"LzSender"`, `"RaSender"`, `"SrReceiver"` to differentiate data. This works with the current `@AfterEach` cleanup, but would break if two tests ran concurrently or if cleanup failed. This is another reason to prefer `@Transactional` rollback — each test gets a fresh transaction scope with no risk of cross-test contamination.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing against issue #467 requirements.

Acceptance Criteria Coverage

The PR description maps directly to the stated goal (eliminating the N+1 collection query problem identified in the pre-prod audit — 2,733 queries / 24 HTTP requests). All three acceptance criteria from the issue are addressed:

Requirement Implementation Verified by
EAGER → LAZY migration for receivers, tags, trainingLabels, sender Document.java fetch type changes DocumentLazyLoadingTest smoke tests
EntityGraph definitions for selective eager loading per code path @NamedEntityGraph on Document + @EntityGraph overrides on DocumentRepository DocumentRepositoryTest query-count assertions
No LazyInitializationException in current call sites @Transactional(readOnly=true) on getDocumentById + getRecentActivity DocumentLazyLoadingTest

Open Item in Test Plan

The test plan in the PR description has one unchecked item: Full CI suite green. This is the remaining gate before merge. The two checked items (repository-level query-count tests, service-level smoke tests) are implemented and the tests appear to exercise the stated scenarios.

Scope Observations

The PR adds @Transactional to updateDocumentTags, which was not part of issue #467. This is a bug fix (a write method without a transaction annotation), not scope creep — but it should be noted as a side-fix included in this PR.

The @JsonIgnoreProperties addition to Person and Tag is correctly scoped: it prevents serialization errors on lazy proxies that would surface in the existing API responses, which is a direct consequence of the EAGER→LAZY migration.

Testability

Acceptance criteria are testable and the tests are present. The query-count thresholds (≤5 statements for 10 docs, ≤2 for single doc, ≤5 for paginated list) are specific and measurable — good Definition of Done.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing against issue #467 requirements. ### Acceptance Criteria Coverage The PR description maps directly to the stated goal (eliminating the N+1 collection query problem identified in the pre-prod audit — 2,733 queries / 24 HTTP requests). All three acceptance criteria from the issue are addressed: | Requirement | Implementation | Verified by | |---|---|---| | EAGER → LAZY migration for `receivers`, `tags`, `trainingLabels`, `sender` | `Document.java` fetch type changes | `DocumentLazyLoadingTest` smoke tests | | EntityGraph definitions for selective eager loading per code path | `@NamedEntityGraph` on `Document` + `@EntityGraph` overrides on `DocumentRepository` | `DocumentRepositoryTest` query-count assertions | | No LazyInitializationException in current call sites | `@Transactional(readOnly=true)` on `getDocumentById` + `getRecentActivity` | `DocumentLazyLoadingTest` | ### Open Item in Test Plan The test plan in the PR description has one unchecked item: **Full CI suite green**. This is the remaining gate before merge. The two checked items (repository-level query-count tests, service-level smoke tests) are implemented and the tests appear to exercise the stated scenarios. ### Scope Observations The PR adds `@Transactional` to `updateDocumentTags`, which was not part of issue #467. This is a bug fix (a write method without a transaction annotation), not scope creep — but it should be noted as a side-fix included in this PR. The `@JsonIgnoreProperties` addition to `Person` and `Tag` is correctly scoped: it prevents serialization errors on lazy proxies that would surface in the existing API responses, which is a direct consequence of the EAGER→LAZY migration. ### Testability Acceptance criteria are testable and the tests are present. The query-count thresholds (`≤5` statements for 10 docs, `≤2` for single doc, `≤5` for paginated list) are specific and measurable — good Definition of Done.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This PR is exclusively backend — no Svelte components, no markup, no CSS, no routes, no frontend files were touched.

UX Impact (positive)

From a user experience standpoint, this change should produce a measurable improvement in perceived performance on the document list and document detail views: fewer SQL queries per HTTP request means faster API response times, which translates directly to faster page loads for the senior audience (60+) who may be on slower connections or older hardware.

The document search (searchDocuments), recent activity (getRecentActivity), and document detail (getDocumentById) flows are all covered by the optimization — these are the highest-traffic views in the archive.

No frontend changes required. No design review needed.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR is exclusively backend — no Svelte components, no markup, no CSS, no routes, no frontend files were touched. ### UX Impact (positive) From a user experience standpoint, this change should produce a measurable improvement in perceived performance on the document list and document detail views: fewer SQL queries per HTTP request means faster API response times, which translates directly to faster page loads for the senior audience (60+) who may be on slower connections or older hardware. The document search (`searchDocuments`), recent activity (`getRecentActivity`), and document detail (`getDocumentById`) flows are all covered by the optimization — these are the highest-traffic views in the archive. No frontend changes required. No design review needed.
marcel added 6 commits 2026-05-18 22:26:57 +02:00
Without setStatisticsEnabled(true) the counter stays 0 and ≤2 passes
vacuously when the test runs in isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
assertThatCode(() -> service.searchDocuments(...)) passed vacuously on an
empty page; capture the result, assert totalElements > 0, then assert
getSender().getLastName() is accessible post-return.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
List<Document> findAll(Specification) is called in DocumentService for
receiver-sort, sender-sort, and conversation queries but had no query-count
coverage. Asserts ≤5 statements for 5 docs with @EntityGraph(Document.list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace repeated personRepository.save/tagRepository.save/documentRepository.save
boilerplate with savedPerson(), savedTag(), savedDocument() helpers.
Each test body is now 2-3 lines of relevant setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records context (2733 queries/24 requests), the two-graph decision,
@BatchSize fallback, @Transactional(readOnly=true) session-lifetime
requirement, and alternatives considered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(backend): document @Transactional(readOnly=true) exception in CLAUDE.md
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m17s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
82e81e159a
The convention 'read methods are not annotated' has one exception: methods
that return lazily-initialized entities to callers require readOnly=true to
keep the session open. Documents the rule and links to ADR-022.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Round 2 review concerns addressed

Six commits pushed addressing every blocker and actionable suggestion from the second review cycle.


@Felix / @Sara — Blocker: findById_loadsSenderReceiversAndTagsInAtMostTwoStatements passes vacuously in isolation

Fixed in 050cd0ad: added stats.setStatisticsEnabled(true) before stats.clear(). Without this, statistics are disabled when the test runs alone and getPrepareStatementCount() returns 0 — 0 ≤ 2 always passed. Now the stat collection is explicitly enabled in every query-count test method.

@Markus — Blocker: Missing ADR for the EAGER→LAZY strategy decision

Created docs/adr/022-eager-to-lazy-fetch-strategy.md in cabcf6a6. Covers: context (2,733 queries / 24 HTTP requests), the two-graph design decision (Document.full vs Document.list), @BatchSize fallback, @Transactional(readOnly=true) session-lifetime requirement, and three alternatives considered (BatchSize-only, single unified graph, repository-level transactions).

@Markus — Blocker: @Transactional(readOnly=true) exception undocumented in CLAUDE.md

Updated backend/CLAUDE.md Services section in 82e81e15. The rule now reads: "Read methods: no annotation — except when the method returns an entity whose lazy associations must remain accessible to the caller after the method returns. In that case, use @Transactional(readOnly = true) to keep the Hibernate session open. Removing this annotation causes LazyInitializationException in production. See ADR-022."

@Sara — Suggestion: searchDocuments_receiverSort assertion too weak

Fixed in 3356e272: captures the DocumentSearchResult, asserts totalElements() > 0 so an empty-page result cannot pass vacuously, then asserts getSender().getLastName() is accessible on each returned item post-return.

@Sara / @Markus / @Felix — Suggestion: findAll(Specification) non-paginated override has no test

Added findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements in 57259e41 (DocumentRepositoryTest). This override is called from 5 production code paths in DocumentService (receiver-sort, sender-sort, conversation queries) but had no query-count coverage. Asserts ≤5 statements for 5 docs; the entity graph collapses everything into a single JOIN query (1 ≤ 5).

@Felix — Suggestion: Repeated save() boilerplate in DocumentLazyLoadingTest

Extracted savedPerson(), savedTag(), savedDocument() helpers in befecc98. Each test body is now 3–4 lines of relevant setup + assertions.


Not applied: @Transactional on DocumentLazyLoadingTest

Both @Felix and @Sara suggested annotating the test class with @Transactional for auto-rollback. This would make the lazy-loading assertions vacuous: when the test method is wrapped in a transaction, the service method's @Transactional(readOnly=true) joins the outer transaction via PROPAGATION_REQUIRED, so the Hibernate session stays open throughout the test regardless of whether the service annotation is present. The doesNotThrowAnyException() + getSender().getLastName() assertions would pass even if @Transactional(readOnly=true) were removed from the service — exactly the regression we're trying to guard against. The @AfterEach deleteAll() pattern is kept intentionally.


Branch pushed. All 6 commits verified green (individual test class runs).

## Round 2 review concerns addressed Six commits pushed addressing every blocker and actionable suggestion from the second review cycle. --- ### @Felix / @Sara — Blocker: `findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` passes vacuously in isolation ✅ Fixed in `050cd0ad`: added `stats.setStatisticsEnabled(true)` before `stats.clear()`. Without this, statistics are disabled when the test runs alone and `getPrepareStatementCount()` returns 0 — `0 ≤ 2` always passed. Now the stat collection is explicitly enabled in every query-count test method. ### @Markus — Blocker: Missing ADR for the EAGER→LAZY strategy decision ✅ Created `docs/adr/022-eager-to-lazy-fetch-strategy.md` in `cabcf6a6`. Covers: context (2,733 queries / 24 HTTP requests), the two-graph design decision (`Document.full` vs `Document.list`), `@BatchSize` fallback, `@Transactional(readOnly=true)` session-lifetime requirement, and three alternatives considered (BatchSize-only, single unified graph, repository-level transactions). ### @Markus — Blocker: `@Transactional(readOnly=true)` exception undocumented in CLAUDE.md ✅ Updated `backend/CLAUDE.md` Services section in `82e81e15`. The rule now reads: "Read methods: no annotation — **except** when the method returns an entity whose lazy associations must remain accessible to the caller after the method returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022." ### @Sara — Suggestion: `searchDocuments_receiverSort` assertion too weak ✅ Fixed in `3356e272`: captures the `DocumentSearchResult`, asserts `totalElements() > 0` so an empty-page result cannot pass vacuously, then asserts `getSender().getLastName()` is accessible on each returned item post-return. ### @Sara / @Markus / @Felix — Suggestion: `findAll(Specification)` non-paginated override has no test ✅ Added `findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements` in `57259e41` (`DocumentRepositoryTest`). This override is called from 5 production code paths in `DocumentService` (receiver-sort, sender-sort, conversation queries) but had no query-count coverage. Asserts ≤5 statements for 5 docs; the entity graph collapses everything into a single JOIN query (1 ≤ 5). ### @Felix — Suggestion: Repeated save() boilerplate in `DocumentLazyLoadingTest` ✅ Extracted `savedPerson()`, `savedTag()`, `savedDocument()` helpers in `befecc98`. Each test body is now 3–4 lines of relevant setup + assertions. --- ### Not applied: `@Transactional` on `DocumentLazyLoadingTest` Both @Felix and @Sara suggested annotating the test class with `@Transactional` for auto-rollback. This would make the lazy-loading assertions vacuous: when the test method is wrapped in a transaction, the service method's `@Transactional(readOnly=true)` joins the outer transaction via `PROPAGATION_REQUIRED`, so the Hibernate session stays open throughout the test regardless of whether the service annotation is present. The `doesNotThrowAnyException()` + `getSender().getLastName()` assertions would pass even if `@Transactional(readOnly=true)` were removed from the service — exactly the regression we're trying to guard against. The `@AfterEach deleteAll()` pattern is kept intentionally. --- Branch pushed. All 6 commits verified green (individual test class runs).
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Solid execution of a real performance problem. The two-tier strategy (entity graph + @BatchSize) is well-reasoned, the ADR is thorough, and the test evidence is genuine. A few things catch my eye.


Blockers

1. updateDocumentTags gained @Transactional silently — and the convention comment was removed

DocumentService.java (diff line ~447):

+    @Transactional
     public Document updateDocumentTags(UUID docId, List<String> tagNames) {

Adding @Transactional to a write method that previously had none is the right fix, but it happened without a test proving the method was broken before. The pre-existing // 0. Zuletzt aktive Dokumente comment block was also removed in the same hunk as getRecentActivity without explanation. Minor, but the diff is noisy.

2. getDocumentById and getRecentActivity carry "what" comments, not "why"

DocumentService.java:

// @Transactional(readOnly=true) keeps the Hibernate session open so the
// lazy-loaded sender and tags on returned documents remain accessible to callers.

The persona file says: "when you feel the need to write a comment explaining what the code does, rewrite the code until it doesn't need one." The annotation speaks for itself; the why is in the ADR and CLAUDE.md. The comments should be removed — they explain the mechanism, not the domain decision. The ADR reference in CLAUDE.md is the right place for this.

3. Several repository methods are uncovered by entity graphs and are hot paths

DocumentRepository.java lines 54–58:

List<Document> findBySenderId(UUID senderId);
List<Document> findByReceiversId(UUID receiverId);
List<Document> findByTags_Id(UUID tagId);

These return full Document lists with no @EntityGraph. Every call site that accesses receivers, tags, or sender on those results will trigger N+1 queries at the @BatchSize fallback rate at best. The ADR says "any new DocumentRepository method added to a hot code path should be assessed for N+1 risk" — but these are existing methods that should have been assessed in this PR. Are they called from service methods that then access the lazy collections? If yes, they need graphs or a note explaining why they're safe.


Suggestions

4. DocumentLazyLoadingTest uses assertThatCode(() -> { ... }).doesNotThrowAnyException()

DocumentLazyLoadingTest.java lines ~89–97:

assertThatCode(() -> {
    assertThat(result.getTags()).isNotEmpty();
    result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
    assertThat(result.getReceivers()).isNotEmpty();
    result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
}).doesNotThrowAnyException();

Wrapping assertions in assertThatCode means a failing assertThat inside becomes a suppressed "unexpected exception thrown" message instead of a clean assertion failure. Pull the assertions out of the lambda — they don't need exception guarding; they are the assertions.

5. Test helpers are private factory methods — good. But savedDocument takes a title AND a filename as separate args when the test never needs them to differ.

Consider a single-arg overload for the common case. Minor ergonomics.

6. Hibernate Statistics API is unwrapped in three identical blocks across the test class

Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();

Three tests repeat this setup. Extract a @BeforeEach-style helper or a private method resetStats() that returns the Statistics handle. One-liner per test then.


What's done well

  • The two named graphs with distinct attribute sets (Document.full vs Document.list) is exactly the right design — no over-fetching on list paths.
  • @BatchSize(50) as a safety net for unplanned lazy access is a good layered defence.
  • ADR-022 is exemplary: context, decision, alternatives with clear rejection reasoning, consequences.
  • @JsonIgnoreProperties on Person and Tag catches a class of Jackson/Hibernate proxy errors that are notoriously hard to debug in production.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Solid execution of a real performance problem. The two-tier strategy (entity graph + `@BatchSize`) is well-reasoned, the ADR is thorough, and the test evidence is genuine. A few things catch my eye. --- ### Blockers **1. `updateDocumentTags` gained `@Transactional` silently — and the convention comment was removed** `DocumentService.java` (diff line ~447): ```java + @Transactional public Document updateDocumentTags(UUID docId, List<String> tagNames) { ``` Adding `@Transactional` to a write method that previously had none is the right fix, but it happened without a test proving the method was broken before. The pre-existing `// 0. Zuletzt aktive Dokumente` comment block was also removed in the same hunk as `getRecentActivity` without explanation. Minor, but the diff is noisy. **2. `getDocumentById` and `getRecentActivity` carry "what" comments, not "why"** `DocumentService.java`: ```java // @Transactional(readOnly=true) keeps the Hibernate session open so the // lazy-loaded sender and tags on returned documents remain accessible to callers. ``` The persona file says: *"when you feel the need to write a comment explaining what the code does, rewrite the code until it doesn't need one."* The annotation speaks for itself; the why is in the ADR and `CLAUDE.md`. The comments should be removed — they explain the mechanism, not the domain decision. The ADR reference in `CLAUDE.md` is the right place for this. **3. Several repository methods are uncovered by entity graphs and are hot paths** `DocumentRepository.java` lines 54–58: ```java List<Document> findBySenderId(UUID senderId); List<Document> findByReceiversId(UUID receiverId); List<Document> findByTags_Id(UUID tagId); ``` These return full `Document` lists with no `@EntityGraph`. Every call site that accesses `receivers`, `tags`, or `sender` on those results will trigger N+1 queries at the `@BatchSize` fallback rate at best. The ADR says "any new `DocumentRepository` method added to a hot code path should be assessed for N+1 risk" — but these are *existing* methods that should have been assessed in this PR. Are they called from service methods that then access the lazy collections? If yes, they need graphs or a note explaining why they're safe. --- ### Suggestions **4. `DocumentLazyLoadingTest` uses `assertThatCode(() -> { ... }).doesNotThrowAnyException()`** `DocumentLazyLoadingTest.java` lines ~89–97: ```java assertThatCode(() -> { assertThat(result.getTags()).isNotEmpty(); result.getTags().forEach(t -> assertThat(t.getName()).isNotNull()); assertThat(result.getReceivers()).isNotEmpty(); result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull()); }).doesNotThrowAnyException(); ``` Wrapping assertions in `assertThatCode` means a failing `assertThat` inside becomes a suppressed "unexpected exception thrown" message instead of a clean assertion failure. Pull the assertions out of the lambda — they don't need exception guarding; they *are* the assertions. **5. Test helpers are private factory methods — good. But `savedDocument` takes a title AND a filename as separate args when the test never needs them to differ.** Consider a single-arg overload for the common case. Minor ergonomics. **6. Hibernate Statistics API is unwrapped in three identical blocks across the test class** ```java Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.setStatisticsEnabled(true); stats.clear(); ``` Three tests repeat this setup. Extract a `@BeforeEach`-style helper or a private method `resetStats()` that returns the `Statistics` handle. One-liner per test then. --- ### What's done well - The two named graphs with distinct attribute sets (`Document.full` vs `Document.list`) is exactly the right design — no over-fetching on list paths. - `@BatchSize(50)` as a safety net for unplanned lazy access is a good layered defence. - ADR-022 is exemplary: context, decision, alternatives with clear rejection reasoning, consequences. - `@JsonIgnoreProperties` on `Person` and `Tag` catches a class of Jackson/Hibernate proxy errors that are notoriously hard to debug in production.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

The fetch-strategy migration is architecturally sound. The two-tier strategy (entity graph + @BatchSize) is the correct approach, the ADR is present and well-written, and the transaction boundary decision (service layer, not repository) is correct per our layering rules. I have one blocker and two structural concerns.


Blockers

1. Un-graphed repository methods on hot domain paths — N+1 risk not fully closed

DocumentRepository.java lines 54–58:

List<Document> findBySenderId(UUID senderId);
List<Document> findByReceiversId(UUID receiverId);
List<Document> findByTags_Id(UUID tagId);
List<Document> findConversation(...);
List<Document> findSinglePersonCorrespondence(...);
List<Document> findByStatus(DocumentStatus status);
List<Document> findByMetadataCompleteFalse(Sort sort);
Page<Document> findByMetadataCompleteFalse(Pageable pageable);

None of these carry @EntityGraph. If any of these is called from a service method that subsequently accesses receivers, tags, or sender, Hibernate falls back to @BatchSize(50) — meaning those paths improved from N+1 to ceil(N/50)+1, not to O(1). The ADR commits to "any new repository method added to a hot code path should be assessed" but does not account for existing hot paths.

This PR would be complete if it either (a) annotates the hot-path methods, or (b) documents explicitly in the ADR or a code comment that each un-graphed method is safe because its callers never access the relevant lazy collections. As-is, the change is a significant improvement but not a complete fix.


Structural Concerns

2. @NamedEntityGraph placement on the entity class is correct — but a two-graph split introduces a fragile coupling between Document.java and DocumentRepository.java

The named graph in Document.java must stay in sync with the @EntityGraph string references in DocumentRepository.java. If a developer adds a new association to Document and updates one graph definition but not the other, the code compiles but behaves incorrectly. This is a known trade-off of named graphs vs. programmatic graphs. It is acceptable for this project's scale, but the ADR should note the coupling explicitly as a maintenance consequence.

3. @Transactional(readOnly=true) on getDocumentById and getRecentActivity is an exception to the project-wide convention

The convention in CLAUDE.md §Services is that read methods carry no @Transactional. The PR correctly documents this exception in CLAUDE.md and in ADR-022. My concern is propagation: the next developer who adds a service method returning a Document may not realize they need the annotation, because the CLAUDE.md convention (now updated) explains why, but doesn't give a decision rule for when.

Suggestion for the ADR (not a blocker): add a one-line decision rule:

"Add @Transactional(readOnly = true) to any service method that (1) returns a Document (or a collection of Documents) AND (2) is called from a context where lazy associations will be accessed after the method returns."


What's done well

  • Transaction ownership stays at the service layer — the alternative (annotating repository methods) was correctly rejected.
  • Document.full vs Document.list is clean domain-graph naming. Both graphs are minimal — no over-fetching.
  • The ADR cites and rejects all three plausible alternatives with concrete reasoning. This is the right way to document the decision.
  • CI-verifiable query-count assertions in DocumentRepositoryTest are the right enforcement mechanism — the pattern should be replicated when new hot-path methods are added.
## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** The fetch-strategy migration is architecturally sound. The two-tier strategy (entity graph + `@BatchSize`) is the correct approach, the ADR is present and well-written, and the transaction boundary decision (service layer, not repository) is correct per our layering rules. I have one blocker and two structural concerns. --- ### Blockers **1. Un-graphed repository methods on hot domain paths — N+1 risk not fully closed** `DocumentRepository.java` lines 54–58: ```java List<Document> findBySenderId(UUID senderId); List<Document> findByReceiversId(UUID receiverId); List<Document> findByTags_Id(UUID tagId); List<Document> findConversation(...); List<Document> findSinglePersonCorrespondence(...); List<Document> findByStatus(DocumentStatus status); List<Document> findByMetadataCompleteFalse(Sort sort); Page<Document> findByMetadataCompleteFalse(Pageable pageable); ``` None of these carry `@EntityGraph`. If *any* of these is called from a service method that subsequently accesses `receivers`, `tags`, or `sender`, Hibernate falls back to `@BatchSize(50)` — meaning those paths improved from N+1 to ceil(N/50)+1, not to O(1). The ADR commits to "any new repository method added to a hot code path should be assessed" but does not account for existing hot paths. This PR would be complete if it either (a) annotates the hot-path methods, or (b) documents explicitly in the ADR or a code comment that each un-graphed method is safe because its callers never access the relevant lazy collections. As-is, the change is a significant improvement but not a complete fix. --- ### Structural Concerns **2. `@NamedEntityGraph` placement on the entity class is correct — but a two-graph split introduces a fragile coupling between `Document.java` and `DocumentRepository.java`** The named graph in `Document.java` must stay in sync with the `@EntityGraph` string references in `DocumentRepository.java`. If a developer adds a new association to `Document` and updates one graph definition but not the other, the code compiles but behaves incorrectly. This is a known trade-off of named graphs vs. programmatic graphs. It is acceptable for this project's scale, but the ADR should note the coupling explicitly as a maintenance consequence. **3. `@Transactional(readOnly=true)` on `getDocumentById` and `getRecentActivity` is an exception to the project-wide convention** The convention in `CLAUDE.md §Services` is that read methods carry no `@Transactional`. The PR correctly documents this exception in `CLAUDE.md` and in ADR-022. My concern is propagation: the next developer who adds a service method returning a `Document` may not realize they need the annotation, because the `CLAUDE.md` convention (now updated) explains *why*, but doesn't give a decision rule for *when*. Suggestion for the ADR (not a blocker): add a one-line decision rule: > "Add `@Transactional(readOnly = true)` to any service method that (1) returns a `Document` (or a collection of `Document`s) AND (2) is called from a context where lazy associations will be accessed after the method returns." --- ### What's done well - Transaction ownership stays at the service layer — the alternative (annotating repository methods) was correctly rejected. - `Document.full` vs `Document.list` is clean domain-graph naming. Both graphs are minimal — no over-fetching. - The ADR cites and rejects all three plausible alternatives with concrete reasoning. This is the right way to document the decision. - CI-verifiable query-count assertions in `DocumentRepositoryTest` are the right enforcement mechanism — the pattern should be replicated when new hot-path methods are added.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

The test coverage for the targeted code paths is solid — query-count assertions in DocumentRepositoryTest and smoke tests in DocumentLazyLoadingTest cover the stated requirements. But there are gaps I'd want closed before this merges.


Blockers

1. Multi-assertion lambdas hide the actual failure in DocumentLazyLoadingTest

DocumentLazyLoadingTest.java lines ~89–97:

assertThatCode(() -> {
    assertThat(result.getTags()).isNotEmpty();
    result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
    assertThat(result.getReceivers()).isNotEmpty();
    result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
}).doesNotThrowAnyException();

When assertThat(result.getTags()).isNotEmpty() fails, the test output says "expected no exception but got AssertionError" — which is indistinguishable from a LazyInitializationException. The point of these tests is to distinguish "lazy init worked but data is wrong" from "lazy init threw". Putting plain assertThat calls inside assertThatCode destroys that signal. Write them as separate assertions outside the lambda; use assertThatCode(() -> result.getTags().size()) only for the lazy-access guard.

2. No regression test covering the N+1 baseline — only the fixed state is asserted

DocumentRepositoryTest asserts ≤5 statements for 10 docs after the fix. There's no test proving what the number was before — that's fine for regression, the important thing is CI will catch if it regresses. However, the threshold of 5 for 10 docs is asserted with no explanation of how 5 was derived. If Hibernate's query plan changes (e.g., Spring Data JPA upgrade), this test will start failing with no guidance on whether 6 is acceptable or catastrophic. Add an as(...) message that explains the formula: "1 entity graph query + 1 count query for pagination + 1 tags batch + 1 sender batch ≤ 5."

The current message "@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1" is good but does not explain the budget. Consider:

.as("Document.list graph: 1 docs JOIN (sender+tags), 1 COUNT(*) for pagination = 2 statements maximum for 10 docs with 1 shared sender and 1 shared tag")
.isLessThanOrEqualTo(5);

3. DocumentLazyLoadingTest uses @AfterEach cleanup instead of @Transactional rollback

@AfterEach
void cleanup() {
    documentRepository.deleteAll();
    tagRepository.deleteAll();
    personRepository.deleteAll();
}

deleteAll() without FK cascade ordering can fail if there are constraint violations (e.g., document_receivers FK to persons). Sara's preferred approach here: annotate the test class with @Transactional and let Spring roll back each test automatically, except that @Transactional on the test class would defeat the purpose — the tests verify post-transaction lazy access. So the cleanup approach is actually forced here. That is fine — but the cleanup order is wrong: documents must be deleted before persons and tags due to join table FKs. Current order is: documentRepository.deleteAll() first — that's correct. Flag this as OK but fragile; a comment explaining the order dependency would prevent future breakage.


Suggestions

4. One logical assertion per test — some tests mix "does not throw" with data assertions

For example, getRecentActivity_collectionsAccessibleAfterReturn:

assertThatCode(() -> {
    results.forEach(d -> assertThat(d.getSender()).isNotNull());
    results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
    results.forEach(d -> d.getTags().size());
}).doesNotThrowAnyException();

Split into: (1) assert result is non-empty, (2) assert sender is not null, (3) assert tags is accessible. One reason to fail per test.

5. No test for the case where getDocumentById is called without @Transactional(readOnly=true) — the guard has no red-phase evidence

There is no test that would have caught the missing annotation before the fix. The DocumentLazyLoadingTest tests pass after the fix but there's no test that would fail if someone removed @Transactional(readOnly=true) from getDocumentById. The test getDocumentById_tagsAndReceiversAccessible_afterReturnFromService does exactly this — it would fail with LazyInitializationException if the annotation were removed — so this concern is actually addressed. LGTM on this specific point.

6. Query-count tests enable Statistics mid-test — potential interference with other tests

Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();

setStatisticsEnabled(true) is a global JVM-level setting on the SessionFactory. If tests run in parallel (Spring Boot test concurrency), one test's stats.clear() can wipe counts that another test is measuring. Currently tests run sequentially in @DataJpaTest context, so this is low risk — but it's worth a comment explaining the assumption.


What's done well

  • @SpringBootTest(webEnvironment = NONE) for DocumentLazyLoadingTest is the right choice — avoids spinning up Jetty while still testing real Spring context behavior.
  • Testcontainers via PostgresContainerConfig — real Postgres, not H2. Correct.
  • @MockitoBean S3Client avoids MinIO dependency for what is a pure persistence test. Clean.
  • Factory helpers savedPerson, savedTag, savedDocument are well-extracted and used consistently.
  • Query-count assertions are the gold standard for N+1 regression prevention. This pattern should be the template for all future hot-path repository additions.
## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** The test coverage for the targeted code paths is solid — query-count assertions in `DocumentRepositoryTest` and smoke tests in `DocumentLazyLoadingTest` cover the stated requirements. But there are gaps I'd want closed before this merges. --- ### Blockers **1. Multi-assertion lambdas hide the actual failure in `DocumentLazyLoadingTest`** `DocumentLazyLoadingTest.java` lines ~89–97: ```java assertThatCode(() -> { assertThat(result.getTags()).isNotEmpty(); result.getTags().forEach(t -> assertThat(t.getName()).isNotNull()); assertThat(result.getReceivers()).isNotEmpty(); result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull()); }).doesNotThrowAnyException(); ``` When `assertThat(result.getTags()).isNotEmpty()` fails, the test output says "expected no exception but got `AssertionError`" — which is indistinguishable from a `LazyInitializationException`. The point of these tests is to distinguish "lazy init worked but data is wrong" from "lazy init threw". Putting plain `assertThat` calls inside `assertThatCode` destroys that signal. Write them as separate assertions outside the lambda; use `assertThatCode(() -> result.getTags().size())` only for the lazy-access guard. **2. No regression test covering the N+1 baseline — only the fixed state is asserted** `DocumentRepositoryTest` asserts `≤5 statements for 10 docs` after the fix. There's no test proving what the number was before — that's fine for regression, the important thing is CI will catch if it regresses. However, the threshold of 5 for 10 docs is asserted with no explanation of how 5 was derived. If Hibernate's query plan changes (e.g., Spring Data JPA upgrade), this test will start failing with no guidance on whether 6 is acceptable or catastrophic. Add an `as(...)` message that explains the formula: "1 entity graph query + 1 count query for pagination + 1 tags batch + 1 sender batch ≤ 5." The current message `"@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1"` is good but does not explain the budget. Consider: ```java .as("Document.list graph: 1 docs JOIN (sender+tags), 1 COUNT(*) for pagination = 2 statements maximum for 10 docs with 1 shared sender and 1 shared tag") .isLessThanOrEqualTo(5); ``` **3. `DocumentLazyLoadingTest` uses `@AfterEach` cleanup instead of `@Transactional` rollback** ```java @AfterEach void cleanup() { documentRepository.deleteAll(); tagRepository.deleteAll(); personRepository.deleteAll(); } ``` `deleteAll()` without FK cascade ordering can fail if there are constraint violations (e.g., `document_receivers` FK to `persons`). Sara's preferred approach here: annotate the test class with `@Transactional` and let Spring roll back each test automatically, *except* that `@Transactional` on the test class would defeat the purpose — the tests verify post-transaction lazy access. So the cleanup approach is actually forced here. That is fine — but the cleanup order is wrong: documents must be deleted before persons and tags due to join table FKs. Current order is: `documentRepository.deleteAll()` first — that's correct. Flag this as OK but fragile; a comment explaining the order dependency would prevent future breakage. --- ### Suggestions **4. One logical assertion per test — some tests mix "does not throw" with data assertions** For example, `getRecentActivity_collectionsAccessibleAfterReturn`: ```java assertThatCode(() -> { results.forEach(d -> assertThat(d.getSender()).isNotNull()); results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull()); results.forEach(d -> d.getTags().size()); }).doesNotThrowAnyException(); ``` Split into: (1) assert result is non-empty, (2) assert `sender` is not null, (3) assert `tags` is accessible. One reason to fail per test. **5. No test for the case where `getDocumentById` is called without `@Transactional(readOnly=true)` — the guard has no red-phase evidence** There is no test that would have caught the missing annotation before the fix. The `DocumentLazyLoadingTest` tests *pass* after the fix but there's no test that would fail if someone removed `@Transactional(readOnly=true)` from `getDocumentById`. The test `getDocumentById_tagsAndReceiversAccessible_afterReturnFromService` does exactly this — it would fail with `LazyInitializationException` if the annotation were removed — so this concern is actually addressed. LGTM on this specific point. **6. Query-count tests enable `Statistics` mid-test — potential interference with other tests** ```java Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.setStatisticsEnabled(true); stats.clear(); ``` `setStatisticsEnabled(true)` is a global JVM-level setting on the `SessionFactory`. If tests run in parallel (Spring Boot test concurrency), one test's `stats.clear()` can wipe counts that another test is measuring. Currently tests run sequentially in `@DataJpaTest` context, so this is low risk — but it's worth a comment explaining the assumption. --- ### What's done well - `@SpringBootTest(webEnvironment = NONE)` for `DocumentLazyLoadingTest` is the right choice — avoids spinning up Jetty while still testing real Spring context behavior. - Testcontainers via `PostgresContainerConfig` — real Postgres, not H2. Correct. - `@MockitoBean S3Client` avoids MinIO dependency for what is a pure persistence test. Clean. - Factory helpers `savedPerson`, `savedTag`, `savedDocument` are well-extracted and used consistently. - Query-count assertions are the gold standard for N+1 regression prevention. This pattern should be the template for all future hot-path repository additions.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This PR touches the persistence layer, entity serialization, and transaction boundaries. From a security standpoint it is clean. Detailed findings below.


What I checked

Jackson @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) on Person and Tag

This is a correct and well-understood fix for Hibernate proxy serialization. The two properties being ignored (hibernateLazyInitializer, handler) are Hibernate internals injected by CGLIB into proxy subclasses. They carry no user data and their serialization would only cause JsonMappingException, not a data exposure. The annotation is placed at the class level — correct scope.

No security concern here.

@Transactional(readOnly=true) on service read methods

readOnly=true is not a security control, but it has a beneficial side effect: Hibernate sets the connection to read-only mode, and some JDBC drivers and PostgreSQL configurations use this hint to route queries to read replicas and prevent accidental writes. No security regression introduced.

Entity graph named references ("Document.full", "Document.list")

String-based graph names are resolved by Hibernate at startup, not at runtime from user input. No injection vector. The @EntityGraph annotation on repository methods references compile-time constants effectively. No concern.

No new endpoints, no new permissions, no new controller methods

This PR is purely a persistence layer change. No controller-layer attack surface was modified.

@BatchSize fallback

@BatchSize affects query batching, not query parameterization. The batch IN clause parameters are bound via JPA named parameters, not string concatenation. No SQL injection vector.


Minor observation (not a blocker)

The PR does not add @JsonIgnoreProperties to other entities that might be exposed as lazy associations in the future (e.g., UserGroup, AppUser). This is not a current vulnerability — those entities are not lazy-proxied associations on Document — but the ADR consequence section could mention: "any entity serialized directly as a lazy association should carry @JsonIgnoreProperties({\"hibernateLazyInitializer\", \"handler\"})." The ADR does say this; it's correctly documented.


Bottom line

No security regressions introduced. The change reduces attack surface in an indirect but real way: fewer queries per request means less time holding database connections, which reduces the blast radius of connection exhaustion under load.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This PR touches the persistence layer, entity serialization, and transaction boundaries. From a security standpoint it is clean. Detailed findings below. --- ### What I checked **Jackson `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` on `Person` and `Tag`** This is a correct and well-understood fix for Hibernate proxy serialization. The two properties being ignored (`hibernateLazyInitializer`, `handler`) are Hibernate internals injected by CGLIB into proxy subclasses. They carry no user data and their serialization would only cause `JsonMappingException`, not a data exposure. The annotation is placed at the class level — correct scope. No security concern here. **`@Transactional(readOnly=true)` on service read methods** `readOnly=true` is not a security control, but it has a beneficial side effect: Hibernate sets the connection to read-only mode, and some JDBC drivers and PostgreSQL configurations use this hint to route queries to read replicas and prevent accidental writes. No security regression introduced. **Entity graph named references (`"Document.full"`, `"Document.list"`)** String-based graph names are resolved by Hibernate at startup, not at runtime from user input. No injection vector. The `@EntityGraph` annotation on repository methods references compile-time constants effectively. No concern. **No new endpoints, no new permissions, no new controller methods** This PR is purely a persistence layer change. No controller-layer attack surface was modified. **`@BatchSize` fallback** `@BatchSize` affects query batching, not query parameterization. The batch `IN` clause parameters are bound via JPA named parameters, not string concatenation. No SQL injection vector. --- ### Minor observation (not a blocker) The PR does not add `@JsonIgnoreProperties` to other entities that might be exposed as lazy associations in the future (e.g., `UserGroup`, `AppUser`). This is not a current vulnerability — those entities are not lazy-proxied associations on `Document` — but the ADR consequence section could mention: "any entity serialized directly as a lazy association should carry `@JsonIgnoreProperties({\"hibernateLazyInitializer\", \"handler\"})`." The ADR does say this; it's correctly documented. --- ### Bottom line No security regressions introduced. The change reduces attack surface in an indirect but real way: fewer queries per request means less time holding database connections, which reduces the blast radius of connection exhaustion under load.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

This is a pure application-layer change — no Compose file, no CI workflow, no infrastructure config was modified. My review scope is narrow here, but I checked what's relevant.


What I checked

CI impact: @SpringBootTest integration tests added

DocumentLazyLoadingTest uses @SpringBootTest(webEnvironment = NONE) which starts a full Spring context with Testcontainers. DocumentRepositoryTest already used @DataJpaTest with Testcontainers. Four new query-count tests and five new lazy-loading smoke tests were added.

Estimated CI time impact: @SpringBootTest with Testcontainers cold start is typically 10–20 seconds per class (after container reuse kicks in). Since DocumentLazyLoadingTest is a new class, the first run per CI job pays the full container boot cost. With @DirtiesContext not used and shared container config (PostgresContainerConfig), Testcontainers reuse should apply. Net impact: probably +15–30 seconds on the integration test step. Acceptable.

No Compose or environment changes

No new env vars, no new Docker services, no new ports, no new volumes. Nothing to review in infra config.

Hibernate Statistics API in tests

entityManagerFactory.unwrap(SessionFactory.class).getStatistics() is a JVM-level handle. It does not require any infrastructure change and has no production impact — Hibernate statistics are disabled by default in production unless spring.jpa.properties.hibernate.generate_statistics=true is set. Not set in this PR. No concern.

@MockitoBean S3Client in DocumentLazyLoadingTest

Correct approach. The S3 client is mocked, so no MinIO container dependency for the lazy-loading test suite. CI doesn't need to spin up MinIO for these tests.


Observation

The DocumentRepositoryTest now injects both EntityManager and EntityManagerFactory. In a @DataJpaTest slice, both are available and properly scoped. No issue.


Bottom line

No infrastructure changes. CI impact is minimal and acceptable. The test architecture is clean for a platform perspective — no flakiness risks from external service dependencies, Testcontainers reuse is configured correctly.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** This is a pure application-layer change — no Compose file, no CI workflow, no infrastructure config was modified. My review scope is narrow here, but I checked what's relevant. --- ### What I checked **CI impact: `@SpringBootTest` integration tests added** `DocumentLazyLoadingTest` uses `@SpringBootTest(webEnvironment = NONE)` which starts a full Spring context with Testcontainers. `DocumentRepositoryTest` already used `@DataJpaTest` with Testcontainers. Four new query-count tests and five new lazy-loading smoke tests were added. Estimated CI time impact: `@SpringBootTest` with Testcontainers cold start is typically 10–20 seconds per class (after container reuse kicks in). Since `DocumentLazyLoadingTest` is a new class, the first run per CI job pays the full container boot cost. With `@DirtiesContext` not used and shared container config (`PostgresContainerConfig`), Testcontainers reuse should apply. Net impact: probably +15–30 seconds on the integration test step. Acceptable. **No Compose or environment changes** No new env vars, no new Docker services, no new ports, no new volumes. Nothing to review in infra config. **Hibernate Statistics API in tests** `entityManagerFactory.unwrap(SessionFactory.class).getStatistics()` is a JVM-level handle. It does not require any infrastructure change and has no production impact — Hibernate statistics are disabled by default in production unless `spring.jpa.properties.hibernate.generate_statistics=true` is set. Not set in this PR. No concern. **`@MockitoBean S3Client` in `DocumentLazyLoadingTest`** Correct approach. The S3 client is mocked, so no MinIO container dependency for the lazy-loading test suite. CI doesn't need to spin up MinIO for these tests. --- ### Observation The `DocumentRepositoryTest` now injects both `EntityManager` and `EntityManagerFactory`. In a `@DataJpaTest` slice, both are available and properly scoped. No issue. --- ### Bottom line No infrastructure changes. CI impact is minimal and acceptable. The test architecture is clean for a platform perspective — no flakiness risks from external service dependencies, Testcontainers reuse is configured correctly.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This PR is a backend performance change with no frontend or UI modifications. I have nothing to flag from a UX, accessibility, or visual perspective.


What I checked

No Svelte components modified. The diff contains changes to:

  • backend/CLAUDE.md — developer documentation
  • backend/src/main/java/... — Java entities, repository, service
  • backend/src/test/java/... — JUnit tests
  • docs/adr/022-eager-to-lazy-fetch-strategy.md — ADR

No frontend routes, no .svelte files, no Tailwind classes, no i18n message keys, no OpenAPI schema changes.


Indirect UX benefit (worth noting)

The stated performance improvement — from ~2,733 queries per 24 HTTP requests down to ≤10 — will have a meaningful impact on perceived page load speed, especially on the document list and search routes. Faster backend responses directly improve the experience for our senior audience (60+) on slower connections. This is the right work to do. Well done.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR is a backend performance change with no frontend or UI modifications. I have nothing to flag from a UX, accessibility, or visual perspective. --- ### What I checked **No Svelte components modified.** The diff contains changes to: - `backend/CLAUDE.md` — developer documentation - `backend/src/main/java/...` — Java entities, repository, service - `backend/src/test/java/...` — JUnit tests - `docs/adr/022-eager-to-lazy-fetch-strategy.md` — ADR No frontend routes, no `.svelte` files, no Tailwind classes, no i18n message keys, no OpenAPI schema changes. --- ### Indirect UX benefit (worth noting) The stated performance improvement — from ~2,733 queries per 24 HTTP requests down to ≤10 — will have a meaningful impact on perceived page load speed, especially on the document list and search routes. Faster backend responses directly improve the experience for our senior audience (60+) on slower connections. This is the right work to do. Well done.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing this PR through a requirements and specification lens: does the implementation deliver what issue #467 asked for, is the intent traceable, and are there gaps in the stated requirements?


Traceability check

Issue #467 → PR #622 → ADR-022

The PR description states the problem clearly (2,733 queries / 24 HTTP requests), names the root cause (EAGER fetch on four associations), and describes the solution with measurable outcomes (≤10 statements). This is the kind of spec-dense issue description the project's workflow requires. ADR-022 provides the decision record, alternatives analysis, and consequences. Traceability is complete.

Test plan completeness

The PR test plan lists:

  • DocumentRepositoryTest — query-count assertions
  • DocumentLazyLoadingTest — 5 smoke tests
  • Full CI suite green

The unchecked CI item is a process note, not a missing test. The test coverage for the stated requirements is present.


Requirements gaps worth tracking

NFR-PERF-001 (implicit): Are query-count bounds documented as formal NFRs?

The PR establishes ≤5 statements for findAll(Spec, Pageable) with 10 docs as a concrete, test-enforced bound. This is excellent. But it is not captured as a formal non-functional requirement anywhere visible to future implementers who may add new repository methods. The ADR says "new hot-path methods should be assessed" — consider adding a one-liner to CONTRIBUTING.md or the backend architecture docs: "Every new DocumentRepository method that returns Document entities and is called from a hot path must have a query-count assertion in DocumentRepositoryTest."

Open question for future PRs: what is "hot path"?

The ADR introduces the term "hot code path" but does not define it. Future developers need a decision rule. Suggested definition to add to ADR-022 consequences:

"A hot code path is any repository method called directly or indirectly from a user-facing HTTP request that can return more than one Document at a time."

This is a suggestion, not a blocker.


What's done well

  • The issue #467 is closed by this PR with traceable evidence (tests + ADR + CLAUDE.md update). The full Requirements → Implementation → Verification loop is closed.
  • CLAUDE.md was updated to document the @Transactional(readOnly=true) exception. Future implementers have the guidance they need.
  • ADR-022 documents the "won't do" list (alternatives considered) as explicitly as the "will do" — this prevents the alternatives from being re-proposed and re-debated in future PRs.
## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing this PR through a requirements and specification lens: does the implementation deliver what issue #467 asked for, is the intent traceable, and are there gaps in the stated requirements? --- ### Traceability check **Issue #467 → PR #622 → ADR-022** The PR description states the problem clearly (2,733 queries / 24 HTTP requests), names the root cause (EAGER fetch on four associations), and describes the solution with measurable outcomes (≤10 statements). This is the kind of spec-dense issue description the project's workflow requires. ADR-022 provides the decision record, alternatives analysis, and consequences. Traceability is complete. **Test plan completeness** The PR test plan lists: - [x] `DocumentRepositoryTest` — query-count assertions - [x] `DocumentLazyLoadingTest` — 5 smoke tests - [ ] Full CI suite green The unchecked CI item is a process note, not a missing test. The test coverage for the stated requirements is present. --- ### Requirements gaps worth tracking **NFR-PERF-001 (implicit): Are query-count bounds documented as formal NFRs?** The PR establishes `≤5 statements for findAll(Spec, Pageable) with 10 docs` as a concrete, test-enforced bound. This is excellent. But it is not captured as a formal non-functional requirement anywhere visible to future implementers who may add new repository methods. The ADR says "new hot-path methods should be assessed" — consider adding a one-liner to `CONTRIBUTING.md` or the backend architecture docs: "Every new `DocumentRepository` method that returns `Document` entities and is called from a hot path must have a query-count assertion in `DocumentRepositoryTest`." **Open question for future PRs: what is "hot path"?** The ADR introduces the term "hot code path" but does not define it. Future developers need a decision rule. Suggested definition to add to ADR-022 consequences: > "A hot code path is any repository method called directly or indirectly from a user-facing HTTP request that can return more than one `Document` at a time." This is a suggestion, not a blocker. --- ### What's done well - The issue #467 is closed by this PR with traceable evidence (tests + ADR + CLAUDE.md update). The full Requirements → Implementation → Verification loop is closed. - `CLAUDE.md` was updated to document the `@Transactional(readOnly=true)` exception. Future implementers have the guidance they need. - ADR-022 documents the "won't do" list (alternatives considered) as explicitly as the "will do" — this prevents the alternatives from being re-proposed and re-debated in future PRs.
Author
Owner

🏗️ Markus Keller (@mkeller) — Application Architect

Verdict: Approved

This is a well-structured, defensible architecture change. The two-tier strategy (entity graphs as the deterministic fast path, @BatchSize as the safety net for uncovered paths) is exactly the right layered approach for this codebase. The ADR is comprehensive, documents the rejected alternatives with honest trade-offs, and the consequences section is unusually good — it calls out future obligations explicitly.

What I Checked

ADR-022 — Covers context, decision, alternatives, and consequences. The note that @Transactional(readOnly = true) is an intentional exception to the CLAUDE.md convention (and that it's documented in CLAUDE.md) is exactly how architectural exceptions should be handled.

Layer boundaries@EntityGraph lives on DocumentRepository where it belongs; transaction management lives on DocumentService. No boundary leaks.

Package structure — No new packages, no new modules, no structural changes. Nothing to update in CLAUDE.md §Package Structure.

DB diagram requirements — This PR changes fetch strategy annotations only, not the schema. No new tables, no new columns, no new join tables or FKs. DB diagrams (db-orm.puml, db-relationships.puml) do not need updating.

CLAUDE.md §Services — Updated in backend/CLAUDE.md with the @Transactional(readOnly = true) exception rule.

One Observation (Non-Blocker)

The repository still contains several non-graphed methods that return List<Document> and are called from service methods that also return those documents to callers: findByStatus, findBySenderId, findByReceiversId, findByTags_Id, findConversation, findSinglePersonCorrespondence, findByMetadataCompleteFalse. The ADR correctly characterizes these as non-hot-path and protected by @BatchSize(50). This is the right call for now.

The consequence is that if any of those callers is later called from a context where lazy associations are accessed after the session closes, a LazyInitializationException will surface. The ADR's consequence item "New lazy code paths need an entity graph or @BatchSize review" is the right forward-looking note to have, but it relies on future developers reading the ADR. Consider adding a brief // NOTE: lazy – @BatchSize(50) fallback; see ADR-022 comment on the most-likely-to-be-touched methods (findBySenderId, findByReceiversId) to make the obligation visible in context.

This is a suggestion, not a blocker — the ADR and CLAUDE.md documentation fulfils the architectural responsibility. The PR is ready to merge.

## 🏗️ Markus Keller (@mkeller) — Application Architect **Verdict: ✅ Approved** This is a well-structured, defensible architecture change. The two-tier strategy (entity graphs as the deterministic fast path, `@BatchSize` as the safety net for uncovered paths) is exactly the right layered approach for this codebase. The ADR is comprehensive, documents the rejected alternatives with honest trade-offs, and the consequences section is unusually good — it calls out future obligations explicitly. ### What I Checked **ADR-022** — Covers context, decision, alternatives, and consequences. The note that `@Transactional(readOnly = true)` is an *intentional exception* to the CLAUDE.md convention (and that it's documented in CLAUDE.md) is exactly how architectural exceptions should be handled. ✅ **Layer boundaries** — `@EntityGraph` lives on `DocumentRepository` where it belongs; transaction management lives on `DocumentService`. No boundary leaks. ✅ **Package structure** — No new packages, no new modules, no structural changes. Nothing to update in `CLAUDE.md §Package Structure`. ✅ **DB diagram requirements** — This PR changes fetch strategy annotations only, not the schema. No new tables, no new columns, no new join tables or FKs. DB diagrams (`db-orm.puml`, `db-relationships.puml`) do not need updating. ✅ **CLAUDE.md §Services** — Updated in `backend/CLAUDE.md` with the `@Transactional(readOnly = true)` exception rule. ✅ ### One Observation (Non-Blocker) The repository still contains several non-graphed methods that return `List<Document>` and are called from service methods that also return those documents to callers: `findByStatus`, `findBySenderId`, `findByReceiversId`, `findByTags_Id`, `findConversation`, `findSinglePersonCorrespondence`, `findByMetadataCompleteFalse`. The ADR correctly characterizes these as non-hot-path and protected by `@BatchSize(50)`. This is the right call for now. The consequence is that if any of those callers is later called from a context where lazy associations are accessed after the session closes, a `LazyInitializationException` will surface. The ADR's consequence item "New lazy code paths need an entity graph or @BatchSize review" is the right forward-looking note to have, but it relies on future developers reading the ADR. Consider adding a brief `// NOTE: lazy – @BatchSize(50) fallback; see ADR-022` comment on the most-likely-to-be-touched methods (`findBySenderId`, `findByReceiversId`) to make the obligation visible in context. This is a suggestion, not a blocker — the ADR and CLAUDE.md documentation fulfils the architectural responsibility. The PR is ready to merge.
Author
Owner

👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Verdict: Approved

Clean, focused implementation. The diff is surgical — exactly the associations that needed changing, with the tests to prove each path. The factory helpers in DocumentLazyLoadingTest (savedPerson, savedTag, savedDocument) follow the pattern I'd write myself: one-liners with sensible defaults, override only what the test cares about. The previous review cycle clearly resolved the earlier concerns around assertion structure, and the result is solid.

What I Checked

NamingDocument.full, Document.list — intent-revealing graph names. getDocumentById, getRecentActivity — unchanged, still readable. savedPerson, savedTag, savedDocument in the test — excellent factory helper naming.

@Transactional usageupdateDocumentTags correctly gets @Transactional (write method, not annotated before — this is a fix). getDocumentById and getRecentActivity get @Transactional(readOnly = true) with explanatory comments justifying the exception to the convention. The comments explain why, not what — exactly right.

Guard clause pattern — No new guard clause violations introduced. Existing patterns unchanged.

Dead code — No commented-out code introduced. The old // 0. Zuletzt aktive Dokumente... comment was replaced with the explanatory @Transactional(readOnly=true) comment, which is better.

Test structureDocumentRepositoryTest query-count tests follow Arrange-Act-Assert cleanly. The stats.setStatisticsEnabled(true) + stats.clear() pattern is correctly placed after the flush/clear to avoid counting setup queries.

One Observation (Non-Blocker)

In DocumentLazyLoadingTest.getRecentActivity_collectionsAccessibleAfterReturn, the line:

results.forEach(d -> d.getTags().size());

calls size() purely for its side effect (forcing collection initialization) but discards the result. This is correct and will throw if lazy-loading fails, but it reads oddly. A clearer equivalent would be:

results.forEach(d -> assertThat(d.getTags()).isNotNull());

This is a minor style note — the test is functionally correct as written. The PR is ready to merge.

## 👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer **Verdict: ✅ Approved** Clean, focused implementation. The diff is surgical — exactly the associations that needed changing, with the tests to prove each path. The factory helpers in `DocumentLazyLoadingTest` (`savedPerson`, `savedTag`, `savedDocument`) follow the pattern I'd write myself: one-liners with sensible defaults, override only what the test cares about. The previous review cycle clearly resolved the earlier concerns around assertion structure, and the result is solid. ### What I Checked **Naming** — `Document.full`, `Document.list` — intent-revealing graph names. `getDocumentById`, `getRecentActivity` — unchanged, still readable. `savedPerson`, `savedTag`, `savedDocument` in the test — excellent factory helper naming. ✅ **`@Transactional` usage** — `updateDocumentTags` correctly gets `@Transactional` (write method, not annotated before — this is a fix). `getDocumentById` and `getRecentActivity` get `@Transactional(readOnly = true)` with explanatory comments justifying the exception to the convention. The comments explain *why*, not *what* — exactly right. ✅ **Guard clause pattern** — No new guard clause violations introduced. Existing patterns unchanged. ✅ **Dead code** — No commented-out code introduced. The old `// 0. Zuletzt aktive Dokumente...` comment was replaced with the explanatory `@Transactional(readOnly=true)` comment, which is better. ✅ **Test structure** — `DocumentRepositoryTest` query-count tests follow Arrange-Act-Assert cleanly. The `stats.setStatisticsEnabled(true)` + `stats.clear()` pattern is correctly placed after the flush/clear to avoid counting setup queries. ✅ ### One Observation (Non-Blocker) In `DocumentLazyLoadingTest.getRecentActivity_collectionsAccessibleAfterReturn`, the line: ```java results.forEach(d -> d.getTags().size()); ``` calls `size()` purely for its side effect (forcing collection initialization) but discards the result. This is correct and will throw if lazy-loading fails, but it reads oddly. A clearer equivalent would be: ```java results.forEach(d -> assertThat(d.getTags()).isNotNull()); ``` This is a minor style note — the test is functionally correct as written. The PR is ready to merge.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No security regressions introduced. This PR changes fetch strategy and transaction boundaries — it does not touch authentication, authorization, input validation, or query parameterization. The @JsonIgnoreProperties additions on Person and Tag are defensive and correct.

What I Checked

@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) on Person and Tag — This annotation prevents Jackson from serializing Hibernate proxy internals. Without it, a lazy-initialized Person returned in an HTTP response could expose internal Hibernate class names and proxy metadata, which is a minor information disclosure. The fix is correct and the consequence in ADR-022 correctly mandates this for any future entity used as a lazy association.

Query parameterization — None of the new or modified repository methods use string concatenation in JPQL or native SQL. Entity graphs operate at the JPA metadata level, not at the query string level. No injection surface introduced.

@Transactional(readOnly = true) boundary — Read-only transactions do not affect authorization boundaries. The Hibernate session remains open longer, but no additional data is loaded beyond what the entity graph specifies. No data exposure risk.

Permission annotations — No new controller endpoints added. No @RequirePermission annotations changed. The service methods affected (getDocumentById, getRecentActivity, updateDocumentTags) are not themselves security boundaries — they are called from controllers that carry the @RequirePermission checks.

updateDocumentTags gaining @Transactional — This is a write method that was missing @Transactional. Adding it is correct and reduces the risk of partial updates. No security implication.

Observation (Non-Blocker)

The ADR consequence note about @JsonIgnoreProperties is good, but it's only visible in docs/adr/. Future developers adding a new lazily-loaded entity that is serialized directly (not via DTO) have no in-code reminder. Consider adding a brief comment near the @JsonIgnoreProperties annotation on Person and Tag referencing ADR-022 — but this is a minor hardening suggestion, not a security flaw.

The PR is ready to merge from a security perspective.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No security regressions introduced. This PR changes fetch strategy and transaction boundaries — it does not touch authentication, authorization, input validation, or query parameterization. The `@JsonIgnoreProperties` additions on `Person` and `Tag` are defensive and correct. ### What I Checked **`@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` on `Person` and `Tag`** — This annotation prevents Jackson from serializing Hibernate proxy internals. Without it, a lazy-initialized `Person` returned in an HTTP response could expose internal Hibernate class names and proxy metadata, which is a minor information disclosure. The fix is correct and the consequence in ADR-022 correctly mandates this for any future entity used as a lazy association. ✅ **Query parameterization** — None of the new or modified repository methods use string concatenation in JPQL or native SQL. Entity graphs operate at the JPA metadata level, not at the query string level. No injection surface introduced. ✅ **`@Transactional(readOnly = true)` boundary** — Read-only transactions do not affect authorization boundaries. The Hibernate session remains open longer, but no additional data is loaded beyond what the entity graph specifies. No data exposure risk. ✅ **Permission annotations** — No new controller endpoints added. No `@RequirePermission` annotations changed. The service methods affected (`getDocumentById`, `getRecentActivity`, `updateDocumentTags`) are not themselves security boundaries — they are called from controllers that carry the `@RequirePermission` checks. ✅ **`updateDocumentTags` gaining `@Transactional`** — This is a write method that was missing `@Transactional`. Adding it is correct and reduces the risk of partial updates. No security implication. ✅ ### Observation (Non-Blocker) The ADR consequence note about `@JsonIgnoreProperties` is good, but it's only visible in `docs/adr/`. Future developers adding a new lazily-loaded entity that is serialized directly (not via DTO) have no in-code reminder. Consider adding a brief comment near the `@JsonIgnoreProperties` annotation on `Person` and `Tag` referencing ADR-022 — but this is a minor hardening suggestion, not a security flaw. The PR is ready to merge from a security perspective.
Author
Owner

🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: Approved

The test suite for this PR is genuinely good. Two distinct test classes at different layers, factory helpers, query-count bounds, and assertThatCode(...).doesNotThrowAnyException() smoke tests covering the real session-lifetime concern. The previous review cycle addressed the structural issues — the current state is the right shape.

What I Checked

Test naming — Names are descriptive and sentence-like:

  • findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements
  • getDocumentById_tagsAndReceiversAccessible_afterReturnFromService
  • dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException

Factory helperssavedPerson, savedTag, savedDocument in DocumentLazyLoadingTest. Single-line calls, readable, follows the project's established factory pattern. The three helpers in DocumentRepositoryTest (for query-count tests) use inline builders which is fine for that test class's scope.

Real PostgreSQL — Both test classes use @Import(PostgresContainerConfig.class). No H2.

Hibernate Statistics approachentityManager.flush() + entityManager.clear() before enabling statistics is the correct pattern — it ensures setup queries aren't counted in the bounds assertion. stats.setStatisticsEnabled(true) + stats.clear() immediately before the measured call is also correct.

@AfterEach cleanup in DocumentLazyLoadingTest — The cleanup deletes documents, tags, then persons in dependency order. This is correct and necessary since @Transactional rollback is not used here (@SpringBootTest with no @Transactional on the test class).

Query-count bounds are conservative and well-justified≤5 for a list of 10 docs, ≤2 for findById with 3 receivers and 5 tags, ≤5 for findAll(Pageable) with 5 docs. These leave room for a count query on paginated results while being tight enough to catch N+1 regressions.

One Observation (Non-Blocker)

DocumentRepositoryTest uses @DataJpaTest (a test slice) while DocumentLazyLoadingTest uses @SpringBootTest. This is the correct separation: query-count assertions belong at the repository slice level (fast, no full context), and service-layer lazy-loading smoke tests require the full Spring context because they test service method transaction boundaries. The split is intentional and correct.

Gap to note for future: There is no test covering what happens when getDocumentById or getRecentActivity is called from a context that is already inside a transaction (e.g., a wrapping @Transactional method). In that case the outer transaction takes precedence and readOnly = true may be overridden. This is an edge case for this codebase but worth documenting as a known limitation. Not a blocker for this PR.

The test coverage for the stated goals is complete. PR is ready to merge.

## 🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: ✅ Approved** The test suite for this PR is genuinely good. Two distinct test classes at different layers, factory helpers, query-count bounds, and `assertThatCode(...).doesNotThrowAnyException()` smoke tests covering the real session-lifetime concern. The previous review cycle addressed the structural issues — the current state is the right shape. ### What I Checked **Test naming** — Names are descriptive and sentence-like: - `findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements` ✅ - `getDocumentById_tagsAndReceiversAccessible_afterReturnFromService` ✅ - `dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException` ✅ **Factory helpers** — `savedPerson`, `savedTag`, `savedDocument` in `DocumentLazyLoadingTest`. Single-line calls, readable, follows the project's established factory pattern. The three helpers in `DocumentRepositoryTest` (for query-count tests) use inline builders which is fine for that test class's scope. ✅ **Real PostgreSQL** — Both test classes use `@Import(PostgresContainerConfig.class)`. No H2. ✅ **Hibernate Statistics approach** — `entityManager.flush()` + `entityManager.clear()` before enabling statistics is the correct pattern — it ensures setup queries aren't counted in the bounds assertion. `stats.setStatisticsEnabled(true)` + `stats.clear()` immediately before the measured call is also correct. ✅ **`@AfterEach` cleanup** in `DocumentLazyLoadingTest` — The cleanup deletes documents, tags, then persons in dependency order. This is correct and necessary since `@Transactional` rollback is not used here (`@SpringBootTest` with no `@Transactional` on the test class). ✅ **Query-count bounds are conservative and well-justified** — `≤5` for a list of 10 docs, `≤2` for `findById` with 3 receivers and 5 tags, `≤5` for `findAll(Pageable)` with 5 docs. These leave room for a count query on paginated results while being tight enough to catch N+1 regressions. ✅ ### One Observation (Non-Blocker) `DocumentRepositoryTest` uses `@DataJpaTest` (a test slice) while `DocumentLazyLoadingTest` uses `@SpringBootTest`. This is the correct separation: query-count assertions belong at the repository slice level (fast, no full context), and service-layer lazy-loading smoke tests require the full Spring context because they test service method transaction boundaries. The split is intentional and correct. **Gap to note for future:** There is no test covering what happens when `getDocumentById` or `getRecentActivity` is called from a context that is *already* inside a transaction (e.g., a wrapping `@Transactional` method). In that case the outer transaction takes precedence and `readOnly = true` may be overridden. This is an edge case for this codebase but worth documenting as a known limitation. Not a blocker for this PR. The test coverage for the stated goals is complete. PR is ready to merge.
Author
Owner

🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

This PR is a pure application-layer change — no Docker Compose modifications, no CI workflow changes, no new infrastructure, no environment variables added. My review scope is limited but clear.

What I Checked

Docker Compose / infrastructure — No changes. Nothing to flag.

CI pipeline impact — The new tests (DocumentLazyLoadingTest and the query-count additions to DocumentRepositoryTest) use Testcontainers, which is already in the CI stack. @SpringBootTest tests are slower than @DataJpaTest slices — DocumentLazyLoadingTest will add some CI time, but it's a single test class with 5 tests against an already-running Postgres container. Negligible impact.

Observability — No new log statements, metrics, or tracing changes. The performance improvement (2,733 → ≤10 queries per 24 HTTP requests) will be visible in existing Prometheus/Grafana query duration metrics without any additional instrumentation needed. If you want to validate the improvement post-deploy, the existing slow-query log threshold in PostgreSQL will show the before/after.

No new secrets or environment variables@BatchSize, @EntityGraph, @NamedEntityGraph are all JPA/Hibernate annotations resolved at startup from the classpath. No runtime configuration required.

No new Docker services or infrastructure components — No updates needed to docs/architecture/c4/l2-containers.puml or docs/DEPLOYMENT.md.

This PR requires no DevOps follow-up. Ready to merge.

## 🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** This PR is a pure application-layer change — no Docker Compose modifications, no CI workflow changes, no new infrastructure, no environment variables added. My review scope is limited but clear. ### What I Checked **Docker Compose / infrastructure** — No changes. Nothing to flag. ✅ **CI pipeline impact** — The new tests (`DocumentLazyLoadingTest` and the query-count additions to `DocumentRepositoryTest`) use Testcontainers, which is already in the CI stack. `@SpringBootTest` tests are slower than `@DataJpaTest` slices — `DocumentLazyLoadingTest` will add some CI time, but it's a single test class with 5 tests against an already-running Postgres container. Negligible impact. ✅ **Observability** — No new log statements, metrics, or tracing changes. The performance improvement (2,733 → ≤10 queries per 24 HTTP requests) will be visible in existing Prometheus/Grafana query duration metrics without any additional instrumentation needed. If you want to validate the improvement post-deploy, the existing slow-query log threshold in PostgreSQL will show the before/after. ✅ **No new secrets or environment variables** — `@BatchSize`, `@EntityGraph`, `@NamedEntityGraph` are all JPA/Hibernate annotations resolved at startup from the classpath. No runtime configuration required. ✅ **No new Docker services or infrastructure components** — No updates needed to `docs/architecture/c4/l2-containers.puml` or `docs/DEPLOYMENT.md`. ✅ This PR requires no DevOps follow-up. Ready to merge.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing from a requirements traceability and acceptance criteria perspective.

Traceability Check

Issue #467 → PR #622: The PR description maps directly to the issue's stated concern (N+1 queries in the document list/detail paths). The "2,733 queries / 24 HTTP requests" audit finding from the issue is cited in both the PR description and ADR-022, providing clear traceability from observed problem to implemented solution.

Acceptance criteria coverage:

  • findAll(Spec, Pageable) ≤5 statements for 10 docs → findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements
  • findById ≤2 statements → findById_loadsSenderReceiversAndTagsInAtMostTwoStatements
  • findAll(Pageable) ≤5 statements → findAll_withPageable_loadsSenderWithoutNPlusOne
  • findAll(Spec) ≤5 statements → findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements
  • getDocumentById no LazyInitializationExceptiongetDocumentById_tagsAndReceiversAccessible_afterReturnFromService
  • getRecentActivity no LazyInitializationExceptiongetRecentActivity_collectionsAccessibleAfterReturn
  • searchDocuments (receiver/sender sort) no LazyInitializationException → two tests
  • dashboardService.getResume no LazyInitializationExceptiondashboardService_getResume_...
  • "Full CI suite green" — marked as not yet checked in the PR checklist, which is honest

Every stated acceptance criterion has a corresponding test. The "Full CI suite green" item being unchecked is appropriate transparency.

Non-Functional Requirements Coverage

Performance NFR — The query-count bounds in DocumentRepositoryTest make the performance NFR testable and regression-proof. Not just "it's faster" but "it stays within these bounds on every CI run." This is the gold standard for performance requirements.

Documentation NFR — ADR-022 created, backend/CLAUDE.md updated. The architectural decision is documented in a way that prevents future developers from reverting it out of ignorance.

Backward compatibility — No API surface changes, no schema changes, no frontend changes required. The performance improvement is transparent to callers.

One Observation (Non-Blocker)

The PR checklist item "Full CI suite green" remains unchecked. For a merged PR, this should ideally be checked before merge to confirm no regressions in unrelated test classes. This is a workflow note, not a code review finding.

The requirements stated in issue #467 are fully addressed. PR is ready to merge.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing from a requirements traceability and acceptance criteria perspective. ### Traceability Check **Issue #467 → PR #622:** The PR description maps directly to the issue's stated concern (N+1 queries in the document list/detail paths). The "2,733 queries / 24 HTTP requests" audit finding from the issue is cited in both the PR description and ADR-022, providing clear traceability from observed problem to implemented solution. ✅ **Acceptance criteria coverage:** - ✅ `findAll(Spec, Pageable)` ≤5 statements for 10 docs → `findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements` - ✅ `findById` ≤2 statements → `findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` - ✅ `findAll(Pageable)` ≤5 statements → `findAll_withPageable_loadsSenderWithoutNPlusOne` - ✅ `findAll(Spec)` ≤5 statements → `findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements` - ✅ `getDocumentById` no `LazyInitializationException` → `getDocumentById_tagsAndReceiversAccessible_afterReturnFromService` - ✅ `getRecentActivity` no `LazyInitializationException` → `getRecentActivity_collectionsAccessibleAfterReturn` - ✅ `searchDocuments` (receiver/sender sort) no `LazyInitializationException` → two tests - ✅ `dashboardService.getResume` no `LazyInitializationException` → `dashboardService_getResume_...` - ⬜ "Full CI suite green" — marked as not yet checked in the PR checklist, which is honest Every stated acceptance criterion has a corresponding test. The "Full CI suite green" item being unchecked is appropriate transparency. ✅ ### Non-Functional Requirements Coverage **Performance NFR** — The query-count bounds in `DocumentRepositoryTest` make the performance NFR testable and regression-proof. Not just "it's faster" but "it stays within these bounds on every CI run." This is the gold standard for performance requirements. ✅ **Documentation NFR** — ADR-022 created, `backend/CLAUDE.md` updated. The architectural decision is documented in a way that prevents future developers from reverting it out of ignorance. ✅ **Backward compatibility** — No API surface changes, no schema changes, no frontend changes required. The performance improvement is transparent to callers. ✅ ### One Observation (Non-Blocker) The PR checklist item "Full CI suite green" remains unchecked. For a merged PR, this should ideally be checked before merge to confirm no regressions in unrelated test classes. This is a workflow note, not a code review finding. The requirements stated in issue #467 are fully addressed. PR is ready to merge.
Author
Owner

🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist

Verdict: Approved

This PR is a backend performance change with zero frontend modifications. No Svelte components, no routes, no CSS, no i18n keys, no accessibility-relevant changes. My review is brief by necessity.

What I Checked

Frontend files changed — Zero. No .svelte, no .ts, no .css changes.

User-facing impact — The change is transparent to the user. The document list, detail, and dashboard views will load with the same data, same layout, same interactions — just faster. For the senior audience (60+) on slower connections or devices, reduced query count translates directly to faster perceived response times. This is a UX win delivered through infrastructure, not design.

Error message changes — None. @JsonIgnoreProperties on Person and Tag prevents Hibernate proxy internals from appearing in serialized error responses. This is a minor improvement to error message hygiene — users will never see "hibernateLazyInitializer" in an error payload.

i18n / Paraglide — No changes to messages/ files needed. No new user-visible strings introduced.

Nothing to flag from a UI/UX or accessibility perspective. The PR improves the experience without touching the experience layer. Ready to merge.

## 🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR is a backend performance change with zero frontend modifications. No Svelte components, no routes, no CSS, no i18n keys, no accessibility-relevant changes. My review is brief by necessity. ### What I Checked **Frontend files changed** — Zero. No `.svelte`, no `.ts`, no `.css` changes. ✅ **User-facing impact** — The change is transparent to the user. The document list, detail, and dashboard views will load with the same data, same layout, same interactions — just faster. For the senior audience (60+) on slower connections or devices, reduced query count translates directly to faster perceived response times. This is a UX win delivered through infrastructure, not design. ✅ **Error message changes** — None. `@JsonIgnoreProperties` on `Person` and `Tag` prevents Hibernate proxy internals from appearing in serialized error responses. This is a minor improvement to error message hygiene — users will never see "hibernateLazyInitializer" in an error payload. ✅ **i18n / Paraglide** — No changes to `messages/` files needed. No new user-visible strings introduced. ✅ Nothing to flag from a UI/UX or accessibility perspective. The PR improves the experience without touching the experience layer. Ready to merge.
marcel added 1 commit 2026-05-19 08:35:13 +02:00
fix(document): fix test assertion structure + add entity graph decision comments
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m25s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
be15de6cc6
- Refactor DocumentLazyLoadingTest: pull value assertions (assertThat) out
  of assertThatCode lambdas so failures surface as AssertionError rather
  than "unexpected exception: AssertionError" (review item 1)
- Add @EntityGraph("Document.full") to findBySenderId, findByReceiversId,
  findConversation, and findSinglePersonCorrespondence — all return full
  Documents to the controller for JSON serialization (review item 2)
- Add "// Callers access only ..." comments to un-graphed methods where no
  lazy associations are touched: findByTags_Id, findByStatus,
  findByMetadataCompleteFalse(Sort), findByMetadataCompleteFalse(Pageable)
- Remove "what" inline comments from @Transactional(readOnly=true)
  on getRecentActivity and getDocumentById — the why is in ADR-022 (item 4)
- Add named-graph coupling consequence to ADR-022: Document.java and
  DocumentRepository.java graph name strings must stay in sync (item 5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏗️ Markus Keller (@mkeller) — Senior Application Architect

Verdict: Approved

What I checked

Layer boundaries — the change stays entirely within the document domain. @EntityGraph overrides live on DocumentRepository, named graphs are declared on Document, and the session-lifetime contracts are owned by DocumentService. No domain boundary is crossed; PersonRepository and TagRepository are not touched from DocumentService directly. Clean.

ADR completeness — ADR-022 is present, committed, and well-structured: context (2,733 queries / 24 requests), decision (two-tier: named entity graphs + @BatchSize fallback), three alternatives considered and rejected with concrete reasoning, and four consequences listed. The decision to keep @Transactional(readOnly=true) as an explicit exception to the read-method convention is called out and cross-referenced in CLAUDE.md. This is exactly what an ADR is for.

Documentation table audit:

Trigger Required update Status
No new Flyway migration (no schema change) DB diagrams — not required N/A
No new package, controller, or service C4 / CLAUDE.md package table — not required N/A
New named entity graphs, new repository methods No diagram category covers this N/A
Architectural decision with lasting consequences ADR in docs/adr/ ADR-022 added
Exception to CLAUDE.md §Services read-method convention backend/CLAUDE.md update Updated

No missing doc updates. The PR is self-contained with respect to the documentation policy.

Accidental complexity check — the two-graph split (Document.full / Document.list) is the right granularity. A single Document.full graph on all list paths would JOIN-fetch receivers unnecessarily and force Hibernate into two queries anyway (Cartesian product avoidance). The @BatchSize(50) fallback is a pragmatic safety net for any path not covered by a graph — it does not add operational complexity. The @Transactional(readOnly=true) on two service methods is the minimal viable fix for the session-lifetime problem.

Naming sync risk — the ADR correctly documents that @NamedEntityGraph(name = "Document.full") in Document.java and @EntityGraph(value = "Document.full") in DocumentRepository.java must stay in sync. A name mismatch throws at application startup, which is early detection. The risk is real but tolerable given the startup-time failure mode.

One observation (not a blocker): the @BatchSize annotation on trainingLabels is sound, but trainingLabels has no corresponding entity graph on any repository method. That means any code path that accesses trainingLabels will fall through to the batch loader. If trainingLabels is ever accessed on a hot path (e.g., a future ML export endpoint iterating all documents), it should get its own graph. The comment in DocumentRepository (// No production callers — only used if a future export path…) documents this intent correctly. Worth revisiting if a bulk-export feature is added.

## 🏗️ Markus Keller (@mkeller) — Senior Application Architect **Verdict: ✅ Approved** ### What I checked **Layer boundaries** — the change stays entirely within the `document` domain. `@EntityGraph` overrides live on `DocumentRepository`, named graphs are declared on `Document`, and the session-lifetime contracts are owned by `DocumentService`. No domain boundary is crossed; `PersonRepository` and `TagRepository` are not touched from `DocumentService` directly. Clean. **ADR completeness** — ADR-022 is present, committed, and well-structured: context (2,733 queries / 24 requests), decision (two-tier: named entity graphs + `@BatchSize` fallback), three alternatives considered and rejected with concrete reasoning, and four consequences listed. The decision to keep `@Transactional(readOnly=true)` as an explicit exception to the read-method convention is called out and cross-referenced in `CLAUDE.md`. This is exactly what an ADR is for. **Documentation table audit:** | Trigger | Required update | Status | |---|---|---| | No new Flyway migration (no schema change) | DB diagrams — not required | ✅ N/A | | No new package, controller, or service | C4 / CLAUDE.md package table — not required | ✅ N/A | | New named entity graphs, new repository methods | No diagram category covers this | ✅ N/A | | Architectural decision with lasting consequences | ADR in `docs/adr/` | ✅ ADR-022 added | | Exception to `CLAUDE.md §Services` read-method convention | `backend/CLAUDE.md` update | ✅ Updated | No missing doc updates. The PR is self-contained with respect to the documentation policy. **Accidental complexity check** — the two-graph split (`Document.full` / `Document.list`) is the right granularity. A single `Document.full` graph on all list paths would JOIN-fetch `receivers` unnecessarily and force Hibernate into two queries anyway (Cartesian product avoidance). The `@BatchSize(50)` fallback is a pragmatic safety net for any path not covered by a graph — it does not add operational complexity. The `@Transactional(readOnly=true)` on two service methods is the minimal viable fix for the session-lifetime problem. **Naming sync risk** — the ADR correctly documents that `@NamedEntityGraph(name = "Document.full")` in `Document.java` and `@EntityGraph(value = "Document.full")` in `DocumentRepository.java` must stay in sync. A name mismatch throws at application startup, which is early detection. The risk is real but tolerable given the startup-time failure mode. **One observation (not a blocker):** the `@BatchSize` annotation on `trainingLabels` is sound, but `trainingLabels` has no corresponding entity graph on any repository method. That means any code path that accesses `trainingLabels` will fall through to the batch loader. If `trainingLabels` is ever accessed on a hot path (e.g., a future ML export endpoint iterating all documents), it should get its own graph. The comment in `DocumentRepository` (`// No production callers — only used if a future export path…`) documents this intent correctly. Worth revisiting if a bulk-export feature is added.
Author
Owner

👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Verdict: Approved

What I checked

TDD evidence — the test-first signal is present. The commit history shows DocumentRepositoryTest query-count assertions and DocumentLazyLoadingTest smoke tests were introduced alongside (and in some commits before) the production changes. The assertThatCode(...).doesNotThrowAnyException() / value-assertion pattern is correctly split: lazy-initialization guards inside assertThatCode, value assertions outside. That discipline means a LazyInitializationException surfaces as an unexpected-exception failure (not as an AssertionError), and a missing value surfaces as an AssertionError. Correct.

Function size and responsibility — the DocumentService changes are minimal: two annotations added (@Transactional(readOnly=true) on getDocumentById and getRecentActivity; @Transactional on updateDocumentTags). No new logic bundled in. Good.

NamingDocument.full, Document.list, factory helpers savedPerson, savedTag, savedDocument — all reveal intent. The unique last-name suffixes (LzSender, QcReceiver) are a pragmatic guard against cross-test data bleed and are clearly documented by the @AfterEach cleanup.

Guard clause / JPQL — the @EntityGraph-annotated queries in DocumentRepository use named parameters everywhere. No concatenation, no new @Query strings were introduced without parameter binding. Clean.

One thing I'd normally flag (but it's deliberate): @Transactional(readOnly=true) on read service methods. The developer guide says read methods are not annotated — but CLAUDE.md was updated with the explicit exception and ADR-022 documents the rationale. The exception is intentional and documented. No issue.

Dead code check — no commented-out code, no unused imports left behind. The old German comment // 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC) was removed when @Transactional(readOnly=true) was added to getRecentActivity. That comment was describing what the code does (not why) — removing it is correct.

The @BatchSize placement — applied directly on the field-level collection annotations in Document.java, which is the correct location for fallback batching. No issues.

Solid execution. The discipline of splitting assertThatCode from value assertions shows real TDD maturity.

## 👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer **Verdict: ✅ Approved** ### What I checked **TDD evidence** — the test-first signal is present. The commit history shows `DocumentRepositoryTest` query-count assertions and `DocumentLazyLoadingTest` smoke tests were introduced alongside (and in some commits before) the production changes. The `assertThatCode(...).doesNotThrowAnyException()` / value-assertion pattern is correctly split: lazy-initialization guards inside `assertThatCode`, value assertions outside. That discipline means a `LazyInitializationException` surfaces as an unexpected-exception failure (not as an `AssertionError`), and a missing value surfaces as an `AssertionError`. Correct. **Function size and responsibility** — the `DocumentService` changes are minimal: two annotations added (`@Transactional(readOnly=true)` on `getDocumentById` and `getRecentActivity`; `@Transactional` on `updateDocumentTags`). No new logic bundled in. Good. **Naming** — `Document.full`, `Document.list`, factory helpers `savedPerson`, `savedTag`, `savedDocument` — all reveal intent. The unique last-name suffixes (`LzSender`, `QcReceiver`) are a pragmatic guard against cross-test data bleed and are clearly documented by the `@AfterEach` cleanup. **Guard clause / JPQL** — the `@EntityGraph`-annotated queries in `DocumentRepository` use named parameters everywhere. No concatenation, no new `@Query` strings were introduced without parameter binding. Clean. **One thing I'd normally flag (but it's deliberate):** `@Transactional(readOnly=true)` on read service methods. The developer guide says read methods are not annotated — but CLAUDE.md was updated with the explicit exception and ADR-022 documents the rationale. The exception is intentional and documented. No issue. **Dead code check** — no commented-out code, no unused imports left behind. The old German comment `// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)` was removed when `@Transactional(readOnly=true)` was added to `getRecentActivity`. That comment was describing *what* the code does (not *why*) — removing it is correct. **The `@BatchSize` placement** — applied directly on the field-level collection annotations in `Document.java`, which is the correct location for fallback batching. No issues. Solid execution. The discipline of splitting `assertThatCode` from value assertions shows real TDD maturity.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

What I checked

Lazy-proxy serialization (@JsonIgnoreProperties)Person and Tag now carry @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}). This is the correct and well-known fix: when Jackson serializes a Hibernate proxy whose association was not initialized, it will attempt to serialize the proxy's internal fields ($jacocoData, $$_hibernate_interceptor, etc.) — resulting in either serialization errors or, in older Jackson versions, silently emitting proxy metadata to the API response. The annotation prevents both. The annotation is placed at the class level, which means it applies to all Jackson serialization paths for those types — correct scope.

Data exposure risk assessment — switching from EAGER to LAZY reduces the blast radius of inadvertent data serialization. With EAGER fetch, every Document response previously included all receivers, sender, and tags regardless of whether the serializer was called inside or outside a transaction. With LAZY + entity graphs, the data loaded matches what the code path actually needs. This is a net security improvement: less unnecessary data traversal means fewer accidental exposure paths.

No new user-controlled query parameters — the @EntityGraph overrides are applied at the repository-method level, not conditionally based on runtime input. The graph selection is static and code-controlled. No injection surface.

No new @CrossOrigin or permission bypass — I scanned the diff for any new endpoint, permission annotation change, or auth bypass. None present. The three @Transactional(readOnly=true) additions are session-lifetime fixes, not auth changes.

@BatchSize on trainingLabelstrainingLabels is an @ElementCollection of enum values, not an entity association. There is no @JsonIgnoreProperties needed here because enum values are serialized as strings, not as entity proxies. Correct.

One observation (not a blocker): if a future service method loads a Document outside a transaction and accidentally serializes it via Jackson before the session is closed, the @JsonIgnoreProperties annotation on Person/Tag will silently produce null fields in the JSON output rather than throwing an error. This is the correct behavior (Jackson skips the unknown/proxy properties), but it could mask a missing @Transactional(readOnly=true) annotation in a new service method. The ADR documents this failure mode; team awareness is the mitigation.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** ### What I checked **Lazy-proxy serialization (`@JsonIgnoreProperties`)** — `Person` and `Tag` now carry `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})`. This is the correct and well-known fix: when Jackson serializes a Hibernate proxy whose association was not initialized, it will attempt to serialize the proxy's internal fields (`$jacocoData`, `$$_hibernate_interceptor`, etc.) — resulting in either serialization errors or, in older Jackson versions, silently emitting proxy metadata to the API response. The annotation prevents both. The annotation is placed at the class level, which means it applies to all Jackson serialization paths for those types — correct scope. **Data exposure risk assessment** — switching from EAGER to LAZY reduces the blast radius of inadvertent data serialization. With EAGER fetch, every `Document` response previously included all receivers, sender, and tags regardless of whether the serializer was called inside or outside a transaction. With LAZY + entity graphs, the data loaded matches what the code path actually needs. This is a net security improvement: less unnecessary data traversal means fewer accidental exposure paths. **No new user-controlled query parameters** — the `@EntityGraph` overrides are applied at the repository-method level, not conditionally based on runtime input. The graph selection is static and code-controlled. No injection surface. **No new `@CrossOrigin` or permission bypass** — I scanned the diff for any new endpoint, permission annotation change, or auth bypass. None present. The three `@Transactional(readOnly=true)` additions are session-lifetime fixes, not auth changes. **`@BatchSize` on `trainingLabels`** — `trainingLabels` is an `@ElementCollection` of enum values, not an entity association. There is no `@JsonIgnoreProperties` needed here because enum values are serialized as strings, not as entity proxies. Correct. **One observation (not a blocker):** if a future service method loads a `Document` outside a transaction and accidentally serializes it via Jackson before the session is closed, the `@JsonIgnoreProperties` annotation on `Person`/`Tag` will silently produce `null` fields in the JSON output rather than throwing an error. This is the correct behavior (Jackson skips the unknown/proxy properties), but it could mask a missing `@Transactional(readOnly=true)` annotation in a new service method. The ADR documents this failure mode; team awareness is the mitigation.
Author
Owner

🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: Approved

What I checked

Test pyramid placement — the PR adds two types of tests at the right layers:

  • DocumentRepositoryTest query-count assertions: @DataJpaTest slice — repository layer, correct.
  • DocumentLazyLoadingTest smoke tests: @SpringBootTest(webEnvironment=NONE) — service integration layer, correct. No full HTTP stack loaded when HTTP is not under test.

Factory helper extractionsavedPerson, savedTag, savedDocument are shared private helpers, eliminating repeated builder boilerplate across 5 test methods. One-line-per-thing setup. Good.

@AfterEach cleanup strategy — the DocumentLazyLoadingTest uses deleteAll() in @AfterEach rather than @Transactional rollback. This is the right choice for integration tests that need to verify post-transaction behavior (you cannot test lazy-initialization after the session closes if the transaction rolls back before you get there). No issue.

Query-count assertions — the DocumentRepositoryTest wires EntityManagerFactory to get SessionFactory.getStatistics(), calls stats.setStatisticsEnabled(true) and stats.clear() before each measured call, and asserts getPrepareStatementCount() with a named as(...) message. The flush+clear before measurement ensures the entity manager cache does not absorb queries. Pattern is correct.

Test naming — all names read as sentences describing behavior:

  • findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements
  • getDocumentById_tagsAndReceiversAccessible_afterReturnFromService
  • getRecentActivity_collectionsAccessibleAfterReturn

The naming convention is consistent and diagnostic.

One observation (not a blocker): the query-count thresholds (≤5, ≤2, ≤5) are asserted as upper bounds, not exact values. This is intentional — the tests are performance regression guards, not exact-count contracts. The ADR documents the expected counts (≤10 for 24 HTTP requests). The bounds are wide enough to avoid flakiness under Hibernate internal optimizations but tight enough to catch an N+1 regression. Good calibration.

Missing coverage I considered flagging: there is no negative test proving that removing @EntityGraph from findAll(Spec, Pageable) would cause the query-count assertion to fail. But that is a meta-test of the test itself — the existing assertion would catch such a regression automatically if anyone removed the annotation. Not a gap.

No flaky patterns detected: no Thread.sleep, no ordering dependencies between test methods, no shared mutable state between @Test methods. The @AfterEach cleanup is order-independent.

## 🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: ✅ Approved** ### What I checked **Test pyramid placement** — the PR adds two types of tests at the right layers: - `DocumentRepositoryTest` query-count assertions: `@DataJpaTest` slice — repository layer, correct. - `DocumentLazyLoadingTest` smoke tests: `@SpringBootTest(webEnvironment=NONE)` — service integration layer, correct. No full HTTP stack loaded when HTTP is not under test. **Factory helper extraction** — `savedPerson`, `savedTag`, `savedDocument` are shared private helpers, eliminating repeated builder boilerplate across 5 test methods. One-line-per-thing setup. Good. **`@AfterEach` cleanup strategy** — the `DocumentLazyLoadingTest` uses `deleteAll()` in `@AfterEach` rather than `@Transactional` rollback. This is the right choice for integration tests that need to verify post-transaction behavior (you cannot test lazy-initialization after the session closes if the transaction rolls back before you get there). No issue. **Query-count assertions** — the `DocumentRepositoryTest` wires `EntityManagerFactory` to get `SessionFactory.getStatistics()`, calls `stats.setStatisticsEnabled(true)` and `stats.clear()` before each measured call, and asserts `getPrepareStatementCount()` with a named `as(...)` message. The flush+clear before measurement ensures the entity manager cache does not absorb queries. Pattern is correct. **Test naming** — all names read as sentences describing behavior: - `findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements` - `getDocumentById_tagsAndReceiversAccessible_afterReturnFromService` - `getRecentActivity_collectionsAccessibleAfterReturn` The naming convention is consistent and diagnostic. **One observation (not a blocker):** the query-count thresholds (`≤5`, `≤2`, `≤5`) are asserted as upper bounds, not exact values. This is intentional — the tests are performance regression guards, not exact-count contracts. The ADR documents the expected counts (≤10 for 24 HTTP requests). The bounds are wide enough to avoid flakiness under Hibernate internal optimizations but tight enough to catch an N+1 regression. Good calibration. **Missing coverage I considered flagging:** there is no negative test proving that removing `@EntityGraph` from `findAll(Spec, Pageable)` would cause the query-count assertion to fail. But that is a meta-test of the test itself — the existing assertion would catch such a regression automatically if anyone removed the annotation. Not a gap. **No flaky patterns detected:** no `Thread.sleep`, no ordering dependencies between test methods, no shared mutable state between `@Test` methods. The `@AfterEach` cleanup is order-independent.
Author
Owner

🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

What I checked

This PR is a pure backend Java change — no Docker Compose files, no CI workflow files, no Dockerfile, no infrastructure configuration was modified. My review scope narrows to: CI impact, test execution overhead, and operational consequences.

CI impact — two new test classes are added:

  • DocumentRepositoryTest (existing class, extended): adds 4 new @DataJpaTest methods. @DataJpaTest is a slice test — it does not boot the full application context. Estimated CI time addition: negligible (these tests share a Testcontainers PostgreSQL instance via the existing PostgresContainerConfig).
  • DocumentLazyLoadingTest (new): 5 @SpringBootTest(webEnvironment=NONE) tests. These boot the full Spring context (minus HTTP layer) against a real Postgres container. Boot time is shared across tests in the class. Estimated addition: 10–20 seconds of CI wall time, acceptable.

No new infrastructure dependencies — the change does not add new Docker services, new environment variables, or new external integrations. MinIO is mocked via @MockitoBean S3Client. No operational changes required.

Testcontainers PostgreSQL usage@Import(PostgresContainerConfig.class) is the established project pattern. The container is reused across the test class. No resource leak risk.

Observability note — the PR documents that Hibernate statistics (SessionFactory.getStatistics()) are used only in tests, not in production. In production, query counts are not instrumented. If the team wants to validate the performance improvement in production (e.g., via Grafana JDBC metrics or slow-query logging), that would be a separate observability issue. Not required for this PR to merge.

No secrets, no hardcoded ports, no bind mounts — nothing in the diff touches infrastructure configuration. LGTM from a platform perspective.

## 🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** ### What I checked This PR is a pure backend Java change — no Docker Compose files, no CI workflow files, no Dockerfile, no infrastructure configuration was modified. My review scope narrows to: CI impact, test execution overhead, and operational consequences. **CI impact** — two new test classes are added: - `DocumentRepositoryTest` (existing class, extended): adds 4 new `@DataJpaTest` methods. `@DataJpaTest` is a slice test — it does not boot the full application context. Estimated CI time addition: negligible (these tests share a Testcontainers PostgreSQL instance via the existing `PostgresContainerConfig`). - `DocumentLazyLoadingTest` (new): 5 `@SpringBootTest(webEnvironment=NONE)` tests. These boot the full Spring context (minus HTTP layer) against a real Postgres container. Boot time is shared across tests in the class. Estimated addition: 10–20 seconds of CI wall time, acceptable. **No new infrastructure dependencies** — the change does not add new Docker services, new environment variables, or new external integrations. MinIO is mocked via `@MockitoBean S3Client`. No operational changes required. **Testcontainers PostgreSQL usage** — `@Import(PostgresContainerConfig.class)` is the established project pattern. The container is reused across the test class. No resource leak risk. **Observability note** — the PR documents that Hibernate statistics (`SessionFactory.getStatistics()`) are used only in tests, not in production. In production, query counts are not instrumented. If the team wants to validate the performance improvement in production (e.g., via Grafana JDBC metrics or slow-query logging), that would be a separate observability issue. Not required for this PR to merge. **No secrets, no hardcoded ports, no bind mounts** — nothing in the diff touches infrastructure configuration. LGTM from a platform perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

What I checked

This PR addresses issue #467. My lens: does the implementation match what the issue required, are there uncovered edge cases, and does the PR description accurately represent the change?

Requirements traceability — the issue targeted N+1 queries on document list and detail endpoints. The PR description documents the original symptom (2,733 queries / 24 HTTP requests), the fix (EAGER→LAZY + entity graphs + @BatchSize), and the verification method (query-count tests + LazyInitializationException smoke tests). The description is accurate and verifiable.

Acceptance criteria coverage — the PR test plan lists:

  • DocumentRepositoryTest — query-count assertions for 3 repository paths
  • DocumentLazyLoadingTest — 5 smoke tests for the service layer
  • Full CI suite green — marked incomplete (expected: CI runs after merge)

The two implemented test categories cover the primary risk areas: regression of query count (performance) and regression of lazy-initialization safety (correctness).

Edge cases considered:

  • Receiver sort path: tested via searchDocuments_receiverSort_doesNotThrowLazyInitializationException
  • Sender sort path: tested via searchDocuments_senderSort_doesNotThrowLazyInitializationException
  • Dashboard getResume path: tested via dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException
  • Recent activity path: tested via getRecentActivity_collectionsAccessibleAfterReturn

One open question (not a blocker): the PR covers the document-list and document-detail paths. The findBySenderId and findByReceiversId methods were given @EntityGraph("Document.full") annotations, but there are no dedicated query-count tests for those paths. These are called from PersonService (to show documents per person). Given that the sender/receiver detail pages likely load at most ~dozens of documents, the absence of query-count tests for these paths is acceptable — the entity graph is still applied, so N+1 is structurally prevented.

Non-functional requirements — performance: verified by test. Correctness: verified by LazyInitializationException smoke tests. Documentation: ADR-022 and CLAUDE.md updated. No NFRs missing for the scope of this issue.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** ### What I checked This PR addresses issue #467. My lens: does the implementation match what the issue required, are there uncovered edge cases, and does the PR description accurately represent the change? **Requirements traceability** — the issue targeted N+1 queries on document list and detail endpoints. The PR description documents the original symptom (2,733 queries / 24 HTTP requests), the fix (EAGER→LAZY + entity graphs + `@BatchSize`), and the verification method (query-count tests + LazyInitializationException smoke tests). The description is accurate and verifiable. **Acceptance criteria coverage** — the PR test plan lists: - [x] `DocumentRepositoryTest` — query-count assertions for 3 repository paths - [x] `DocumentLazyLoadingTest` — 5 smoke tests for the service layer - [ ] Full CI suite green — marked incomplete (expected: CI runs after merge) The two implemented test categories cover the primary risk areas: regression of query count (performance) and regression of lazy-initialization safety (correctness). **Edge cases considered:** - Receiver sort path: tested via `searchDocuments_receiverSort_doesNotThrowLazyInitializationException` - Sender sort path: tested via `searchDocuments_senderSort_doesNotThrowLazyInitializationException` - Dashboard `getResume` path: tested via `dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException` - Recent activity path: tested via `getRecentActivity_collectionsAccessibleAfterReturn` **One open question (not a blocker):** the PR covers the document-list and document-detail paths. The `findBySenderId` and `findByReceiversId` methods were given `@EntityGraph("Document.full")` annotations, but there are no dedicated query-count tests for those paths. These are called from `PersonService` (to show documents per person). Given that the sender/receiver detail pages likely load at most ~dozens of documents, the absence of query-count tests for these paths is acceptable — the entity graph is still applied, so N+1 is structurally prevented. **Non-functional requirements** — performance: verified by test. Correctness: verified by LazyInitializationException smoke tests. Documentation: ADR-022 and CLAUDE.md updated. No NFRs missing for the scope of this issue.
Author
Owner

🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist

Verdict: Approved

What I checked

This PR makes no changes to frontend components, Svelte templates, Tailwind classes, or accessibility attributes. My review is limited to verifying that the backend change does not introduce user-facing regressions.

User-visible impact — the change reduces query count from ~2,733 to ≤10 for the document list and detail flows. For the primary audience (transcribers aged 60+ on laptops, readers on phones), this translates to measurably faster page load times for document list and detail views. Faster responses reduce the time users spend waiting — a direct usability improvement, even if invisible in the code diff.

No frontend files modified — no Svelte components, no route files, no i18n message files, no Tailwind configuration were changed. No risk of visual regression, broken focus states, or accessibility attribute changes.

API response shape unchanged — the entity-level changes (EAGER→LAZY, @EntityGraph, @BatchSize, @JsonIgnoreProperties) do not alter the JSON response structure visible to the frontend. Person and Tag objects serialized from entity graphs will contain the same fields as before. The @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) only suppresses Hibernate proxy internals that were never part of the intended API contract.

Loading state consideration (observation only, not a blocker): with faster query times, any existing loading skeletons or spinners on the document list page will complete sooner. This is an improvement, not a regression. No UX change needed.

## 🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** ### What I checked This PR makes no changes to frontend components, Svelte templates, Tailwind classes, or accessibility attributes. My review is limited to verifying that the backend change does not introduce user-facing regressions. **User-visible impact** — the change reduces query count from ~2,733 to ≤10 for the document list and detail flows. For the primary audience (transcribers aged 60+ on laptops, readers on phones), this translates to measurably faster page load times for document list and detail views. Faster responses reduce the time users spend waiting — a direct usability improvement, even if invisible in the code diff. **No frontend files modified** — no Svelte components, no route files, no i18n message files, no Tailwind configuration were changed. No risk of visual regression, broken focus states, or accessibility attribute changes. **API response shape unchanged** — the entity-level changes (EAGER→LAZY, `@EntityGraph`, `@BatchSize`, `@JsonIgnoreProperties`) do not alter the JSON response structure visible to the frontend. `Person` and `Tag` objects serialized from entity graphs will contain the same fields as before. The `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` only suppresses Hibernate proxy internals that were never part of the intended API contract. **Loading state consideration (observation only, not a blocker):** with faster query times, any existing loading skeletons or spinners on the document list page will complete sooner. This is an improvement, not a regression. No UX change needed.
marcel added 1 commit 2026-05-19 08:45:18 +02:00
chore(document): address non-blocking review feedback on lazy-fetch PR
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m13s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m24s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
3d458f6e03
- Add @BatchSize(50) fallback comments on findBySenderId / findByReceiversId
- Replace silent size() discard in getRecentActivity test with assertThat isNotEmpty()
- Add ADR-022 reference comment above @JsonIgnoreProperties on Person and Tag
- Document within-open-transaction limitation in DocumentLazyLoadingTest Javadoc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏗️ Markus Keller (@mkeller) — Application Architect

Verdict: Approved

This is a well-structured performance fix with the right long-term properties. The two-tier strategy (named entity graphs as the deterministic first tier, @BatchSize as the fallback second tier) is architecturally sound, and the ADR captures the decision space correctly. Here is my review across the checklist I apply to every structural PR.


Layer boundaries

No violations. DocumentService owns DocumentRepository; cross-domain access still flows through PersonService/TagService. The @EntityGraph annotations live on the repository interface, which is exactly where fetch strategy decisions belong — not in the service layer.

ADR coverage

ADR-022 is present, properly scoped, and covers:

  • Context (2,733 queries / 24 HTTP requests — quantified, reproducible)
  • Decision (two named graphs, @BatchSize fallback, @Transactional(readOnly=true) for session lifetime)
  • Three concrete alternatives with explicit rejection reasons
  • Consequences including maintenance rules for future contributors

That is exactly what an ADR should do. Well done.

Documentation checklist

Trigger Expected doc Status
Architectural decision with lasting consequences New ADR in docs/adr/ ADR-022 present
CLAUDE.md update for the @Transactional(readOnly=true) exception backend/CLAUDE.md §Services Updated
New Flyway migration? DB diagrams N/A — no schema change
New backend package or service? CLAUDE.md package table, C4 L3 diagram N/A — no new classes

No documentation blockers.

Transaction boundary design

The choice to put @Transactional(readOnly=true) on the service methods rather than on the repository methods is correct — the ADR explains why (transaction lifecycle belongs at the service boundary). The exception to the "read methods are not annotated" convention is narrow, documented in both CLAUDE.md and the ADR, and justified. Future contributors who hit the pattern will find the explanation.

One observation (not a blocker)

updateDocumentTags now has @Transactional added to it. The diff shows it was previously missing. That is the correct fix — a write method that calls documentRepository.findById and mutates the tag set must be transactional. Worth noting for reviewers: this is a correctness fix bundled with the performance PR, not just a perf annotation.

Consequences maintenance rules

The ADR lists four maintenance rules in the Consequences section. I would suggest the team adds a Checkstyle or ArchUnit rule in a follow-up issue to enforce at least the named-graph string sync (prevent graph name drift between Document.java and DocumentRepository.java). This is a nice-to-have, not a blocker for this PR.


Summary: Structurally clean, well-documented, correct transaction boundary placement. No blockers. Merge when CI is green.

## 🏗️ Markus Keller (@mkeller) — Application Architect **Verdict: ✅ Approved** This is a well-structured performance fix with the right long-term properties. The two-tier strategy (named entity graphs as the deterministic first tier, `@BatchSize` as the fallback second tier) is architecturally sound, and the ADR captures the decision space correctly. Here is my review across the checklist I apply to every structural PR. --- ### Layer boundaries No violations. `DocumentService` owns `DocumentRepository`; cross-domain access still flows through `PersonService`/`TagService`. The `@EntityGraph` annotations live on the repository interface, which is exactly where fetch strategy decisions belong — not in the service layer. ### ADR coverage ADR-022 is present, properly scoped, and covers: - Context (2,733 queries / 24 HTTP requests — quantified, reproducible) - Decision (two named graphs, `@BatchSize` fallback, `@Transactional(readOnly=true)` for session lifetime) - Three concrete alternatives with explicit rejection reasons - Consequences including maintenance rules for future contributors That is exactly what an ADR should do. Well done. ### Documentation checklist | Trigger | Expected doc | Status | |---------|-------------|--------| | Architectural decision with lasting consequences | New ADR in `docs/adr/` | ✅ ADR-022 present | | `CLAUDE.md` update for the `@Transactional(readOnly=true)` exception | `backend/CLAUDE.md §Services` | ✅ Updated | | New Flyway migration? | DB diagrams | N/A — no schema change | | New backend package or service? | `CLAUDE.md` package table, C4 L3 diagram | N/A — no new classes | No documentation blockers. ### Transaction boundary design The choice to put `@Transactional(readOnly=true)` on the *service* methods rather than on the repository methods is correct — the ADR explains why (transaction lifecycle belongs at the service boundary). The exception to the "read methods are not annotated" convention is narrow, documented in both CLAUDE.md and the ADR, and justified. Future contributors who hit the pattern will find the explanation. ### One observation (not a blocker) `updateDocumentTags` now has `@Transactional` added to it. The diff shows it was previously missing. That is the correct fix — a write method that calls `documentRepository.findById` and mutates the tag set must be transactional. Worth noting for reviewers: this is a correctness fix bundled with the performance PR, not just a perf annotation. ### Consequences maintenance rules The ADR lists four maintenance rules in the Consequences section. I would suggest the team adds a Checkstyle or ArchUnit rule in a follow-up issue to enforce at least the named-graph string sync (prevent graph name drift between `Document.java` and `DocumentRepository.java`). This is a nice-to-have, not a blocker for this PR. --- **Summary:** Structurally clean, well-documented, correct transaction boundary placement. No blockers. Merge when CI is green.
Author
Owner

👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Verdict: Approved

Clean, focused performance work. No frontend changes (correct — this is a pure backend/persistence concern). All the standard backend patterns are respected. Let me walk through what I checked.


TDD evidence

The PR ships two test classes that were clearly written alongside or before the production changes:

  • DocumentRepositoryTest — four new @DataJpaTest query-count assertions, each with a named assertThat(...).as("descriptive message") that will produce a meaningful CI failure message if the entity graph regresses.
  • DocumentLazyLoadingTest — five @SpringBootTest smoke tests covering the service methods that were annotated with @Transactional(readOnly = true).

The Javadoc on DocumentLazyLoadingTest even documents a known testing limitation (calling the service from within an already-open transaction changes the session merge behaviour). That kind of honest documentation signals a developer who understands the mechanism, not just the surface fix.

Clean code check

Document.java — The two @NamedEntityGraph annotations are positioned directly above @Entity, which is the conventional placement. Attribute sets are minimal (no over-fetching). Good.

DocumentRepository.java — Each overridden method has a short comment explaining why no graph is needed when it isn't (e.g., findByStatus callers only touch scalar fields; findByTags_Id callers access tags to mutate the set). This is "why" commenting, not "what" commenting — exactly right.

DocumentService.java@Transactional(readOnly = true) added to getDocumentById and getRecentActivity. @Transactional (write) added to updateDocumentTags, which was previously missing this annotation — a correctness fix. Function bodies were not changed, which is correct: only the transaction boundary changed.

Person.java / Tag.java@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) added with a concise comment linking to ADR-022. The comment explains the threat ("prevents Jackson from serializing Hibernate proxy internals") without repeating what the annotation does.

Naming

All entity graph names (Document.full, Document.list) are semantically clear — full for detail views, list for search/dashboard paths. No abbreviations, no generic names.

Function size / responsibility

No functions grew. Annotations were added to existing methods; no method was modified to do more than one thing.

One minor note (not a blocker)

In DocumentLazyLoadingTest, the dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException test name is long but accurately describes the behaviour being guarded. I would accept this over a shorter name that loses specificity — this test name survives a copy-paste into a bug report.


Summary: TDD evidence is solid. Code is clean, names are clear, function responsibilities are unchanged. LGTM.

## 👨‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer **Verdict: ✅ Approved** Clean, focused performance work. No frontend changes (correct — this is a pure backend/persistence concern). All the standard backend patterns are respected. Let me walk through what I checked. --- ### TDD evidence The PR ships two test classes that were clearly written alongside or before the production changes: - `DocumentRepositoryTest` — four new `@DataJpaTest` query-count assertions, each with a named `assertThat(...).as("descriptive message")` that will produce a meaningful CI failure message if the entity graph regresses. - `DocumentLazyLoadingTest` — five `@SpringBootTest` smoke tests covering the service methods that were annotated with `@Transactional(readOnly = true)`. The Javadoc on `DocumentLazyLoadingTest` even documents a known testing limitation (calling the service from within an already-open transaction changes the session merge behaviour). That kind of honest documentation signals a developer who understands the mechanism, not just the surface fix. ### Clean code check **`Document.java`** — The two `@NamedEntityGraph` annotations are positioned directly above `@Entity`, which is the conventional placement. Attribute sets are minimal (no over-fetching). Good. **`DocumentRepository.java`** — Each overridden method has a short comment explaining *why* no graph is needed when it isn't (e.g., `findByStatus` callers only touch scalar fields; `findByTags_Id` callers access `tags` to mutate the set). This is "why" commenting, not "what" commenting — exactly right. **`DocumentService.java`** — `@Transactional(readOnly = true)` added to `getDocumentById` and `getRecentActivity`. `@Transactional` (write) added to `updateDocumentTags`, which was previously missing this annotation — a correctness fix. Function bodies were not changed, which is correct: only the transaction boundary changed. **`Person.java` / `Tag.java`** — `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` added with a concise comment linking to ADR-022. The comment explains the threat ("prevents Jackson from serializing Hibernate proxy internals") without repeating what the annotation does. ### Naming All entity graph names (`Document.full`, `Document.list`) are semantically clear — full for detail views, list for search/dashboard paths. No abbreviations, no generic names. ### Function size / responsibility No functions grew. Annotations were added to existing methods; no method was modified to do more than one thing. ### One minor note (not a blocker) In `DocumentLazyLoadingTest`, the `dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException` test name is long but accurately describes the behaviour being guarded. I would accept this over a shorter name that loses specificity — this test name survives a copy-paste into a bug report. --- **Summary:** TDD evidence is solid. Code is clean, names are clear, function responsibilities are unchanged. LGTM.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This PR is a pure persistence-layer performance change. No new endpoints, no new authentication paths, no input parsing. My security audit is therefore narrow and confirmatory.


Attack surface review

No new HTTP endpoints added. The changed files are: Document.java, DocumentRepository.java, DocumentService.java, Person.java, Tag.java, and test classes. No new controllers, no new routes, no changes to SecurityConfig.

@RequirePermission coverage unchanged. The service methods that received @Transactional(readOnly=true) (getDocumentById, getRecentActivity) are read methods — they were already protected by the controller layer's @RequirePermission annotations (not shown in this diff, but the controllers have not changed). The new transaction annotation does not alter the authorization path.

updateDocumentTags correctness. This method now has @Transactional where it was previously missing. This is a correctness fix. The method does not add any new data access paths — it loads an existing document and mutates its tag set. No authorization gap introduced.

@JsonIgnoreProperties analysis

@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) on Person and Tag is the standard mitigation for Jackson serializing Hibernate proxy internals. This annotation suppresses fields from serialization — it does not add any fields, expand the response surface, or bypass access control. It is the correct, minimal fix.

CWE classification: this annotation prevents a class of information leak (CWE-200) where Hibernate proxy metadata (including class names and internal state) could be serialized into API responses. Good defensive addition.

JPQL / injection posture

The new @EntityGraph annotations do not introduce any dynamic query construction. The named entity graph strings ("Document.full", "Document.list") are compile-time constants matched against @NamedEntityGraph definitions on the entity — not user-controlled input. No injection risk.

No concerns

This PR does not introduce any new attack surface, does not change authentication or authorization rules, and does not affect input handling. The @Transactional(readOnly=true) annotations reduce the transaction scope relative to a fully transactional read, which is strictly a security improvement from a privilege-minimization standpoint.


Summary: Clean from a security perspective. The JSON proxy suppression is a minor defensive improvement. No blockers, no concerns.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This PR is a pure persistence-layer performance change. No new endpoints, no new authentication paths, no input parsing. My security audit is therefore narrow and confirmatory. --- ### Attack surface review **No new HTTP endpoints added.** The changed files are: `Document.java`, `DocumentRepository.java`, `DocumentService.java`, `Person.java`, `Tag.java`, and test classes. No new controllers, no new routes, no changes to `SecurityConfig`. **`@RequirePermission` coverage unchanged.** The service methods that received `@Transactional(readOnly=true)` (`getDocumentById`, `getRecentActivity`) are read methods — they were already protected by the controller layer's `@RequirePermission` annotations (not shown in this diff, but the controllers have not changed). The new transaction annotation does not alter the authorization path. **`updateDocumentTags` correctness.** This method now has `@Transactional` where it was previously missing. This is a correctness fix. The method does not add any new data access paths — it loads an existing document and mutates its tag set. No authorization gap introduced. ### `@JsonIgnoreProperties` analysis `@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` on `Person` and `Tag` is the standard mitigation for Jackson serializing Hibernate proxy internals. This annotation *suppresses* fields from serialization — it does not add any fields, expand the response surface, or bypass access control. It is the correct, minimal fix. CWE classification: this annotation prevents a class of information leak (CWE-200) where Hibernate proxy metadata (including class names and internal state) could be serialized into API responses. Good defensive addition. ### JPQL / injection posture The new `@EntityGraph` annotations do not introduce any dynamic query construction. The named entity graph strings (`"Document.full"`, `"Document.list"`) are compile-time constants matched against `@NamedEntityGraph` definitions on the entity — not user-controlled input. No injection risk. ### No concerns This PR does not introduce any new attack surface, does not change authentication or authorization rules, and does not affect input handling. The `@Transactional(readOnly=true)` annotations reduce the transaction scope relative to a fully transactional read, which is strictly a security improvement from a privilege-minimization standpoint. --- **Summary:** Clean from a security perspective. The JSON proxy suppression is a minor defensive improvement. No blockers, no concerns.
Author
Owner

🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: Approved

Strong test coverage for a persistence-layer change. Two new test classes with clear responsibilities, real Postgres (Testcontainers), and meaningful assertions. Let me walk through each layer.


Test pyramid placement

Layer New coverage Assessment
Unit None Correct — this is a persistence/session concern, not business logic; unit tests with mocks wouldn't catch the problem
Integration (@DataJpaTest) DocumentRepositoryTest — 4 new query-count tests Right layer: real Postgres, real entity graphs, measurable statement counts
Integration (@SpringBootTest) DocumentLazyLoadingTest — 5 smoke tests Right layer: full service context required to verify session lifetime
E2E None Correct — LazyInitializationException is a server-side error, not a UI behaviour

DocumentRepositoryTest — query-count assertions

All four new tests follow the same pattern:

  1. Seed data → flush → clear the entity manager (correct — ensures no first-level cache interference)
  2. Enable Hibernate statistics → clear counts
  3. Execute the target query
  4. Assert getPrepareStatementCount() <= N

The assertThat(...).as("descriptive message") on each assertion means CI failure output will immediately tell you which query regressed and what the expected bound was. This is the correct way to write performance regression tests.

One observation: The threshold values (≤5 for list queries, ≤2 for findById) are reasonable upper bounds given the join structure, but they are not proven-tight bounds — Hibernate may satisfy them in fewer statements under some conditions. This is fine: the goal is to prevent N+1 regression, not to enforce exact statement counts. The as-documented thresholds are conservative enough to catch real regressions.

DocumentLazyLoadingTest — lazy initialization smoke tests

The test class Javadoc honestly documents the known testing limitation: when a service method is called from within an already-open transaction, the @Transactional on the service method merges into the outer transaction, so the guard behaves differently than in a non-transactional caller. This is transparent and acceptable for @SpringBootTest tests, which do not start transactions by default around @Test methods (unlike @DataJpaTest which does).

The split between assertThatCode(...).doesNotThrowAnyException() (guards against LazyInitializationException) and stand-alone assertThat(...) assertions (value correctness) is clean — failures will surface as the right exception type with the right message.

Factory helpers

The savedPerson, savedTag, and savedDocument helpers at the bottom of DocumentLazyLoadingTest are well-named and minimal. The @AfterEach cleanup() is correctly ordered (documents before tags/persons to respect foreign key constraints). Good.

@MockitoBean usage

S3Client and AuditLogQueryService are mocked where they would block test startup or introduce non-deterministic external calls. This is the correct mockery discipline for @SpringBootTest(webEnvironment=NONE) integration tests.

Coverage gap (suggestion, not a blocker)

The findBySenderId and findByReceiversId repository methods received @EntityGraph("Document.full") overrides. There are no dedicated query-count tests for these paths. Since these are lower-traffic paths (used from PersonService to list a person's documents), the gap is acceptable for now, but a follow-up issue to add query-count assertions for them would close the coverage.


Summary: Integration test coverage is solid and placed at the correct pyramid layer. Factory helpers are clean, cleanup is correct, and assertion messages will produce useful CI output. Merge when CI is green.

## 🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: ✅ Approved** Strong test coverage for a persistence-layer change. Two new test classes with clear responsibilities, real Postgres (Testcontainers), and meaningful assertions. Let me walk through each layer. --- ### Test pyramid placement | Layer | New coverage | Assessment | |-------|-------------|------------| | Unit | None | Correct — this is a persistence/session concern, not business logic; unit tests with mocks wouldn't catch the problem | | Integration (`@DataJpaTest`) | `DocumentRepositoryTest` — 4 new query-count tests | ✅ Right layer: real Postgres, real entity graphs, measurable statement counts | | Integration (`@SpringBootTest`) | `DocumentLazyLoadingTest` — 5 smoke tests | ✅ Right layer: full service context required to verify session lifetime | | E2E | None | Correct — `LazyInitializationException` is a server-side error, not a UI behaviour | ### `DocumentRepositoryTest` — query-count assertions All four new tests follow the same pattern: 1. Seed data → flush → clear the entity manager (correct — ensures no first-level cache interference) 2. Enable Hibernate statistics → clear counts 3. Execute the target query 4. Assert `getPrepareStatementCount() <= N` The `assertThat(...).as("descriptive message")` on each assertion means CI failure output will immediately tell you *which* query regressed and *what the expected bound was*. This is the correct way to write performance regression tests. **One observation:** The threshold values (≤5 for list queries, ≤2 for `findById`) are reasonable upper bounds given the join structure, but they are not proven-tight bounds — Hibernate may satisfy them in fewer statements under some conditions. This is fine: the goal is to prevent N+1 regression, not to enforce exact statement counts. The as-documented thresholds are conservative enough to catch real regressions. ### `DocumentLazyLoadingTest` — lazy initialization smoke tests The test class Javadoc honestly documents the known testing limitation: when a service method is called from within an already-open transaction, the `@Transactional` on the service method merges into the outer transaction, so the guard behaves differently than in a non-transactional caller. This is transparent and acceptable for `@SpringBootTest` tests, which do not start transactions by default around `@Test` methods (unlike `@DataJpaTest` which does). The split between `assertThatCode(...).doesNotThrowAnyException()` (guards against `LazyInitializationException`) and stand-alone `assertThat(...)` assertions (value correctness) is clean — failures will surface as the right exception type with the right message. ### Factory helpers The `savedPerson`, `savedTag`, and `savedDocument` helpers at the bottom of `DocumentLazyLoadingTest` are well-named and minimal. The `@AfterEach cleanup()` is correctly ordered (documents before tags/persons to respect foreign key constraints). Good. ### `@MockitoBean` usage `S3Client` and `AuditLogQueryService` are mocked where they would block test startup or introduce non-deterministic external calls. This is the correct mockery discipline for `@SpringBootTest(webEnvironment=NONE)` integration tests. ### Coverage gap (suggestion, not a blocker) The `findBySenderId` and `findByReceiversId` repository methods received `@EntityGraph("Document.full")` overrides. There are no dedicated query-count tests for these paths. Since these are lower-traffic paths (used from `PersonService` to list a person's documents), the gap is acceptable for now, but a follow-up issue to add query-count assertions for them would close the coverage. --- **Summary:** Integration test coverage is solid and placed at the correct pyramid layer. Factory helpers are clean, cleanup is correct, and assertion messages will produce useful CI output. Merge when CI is green.
Author
Owner

🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no Compose file updates, no CI workflow changes, no new Docker services. My review is therefore brief and confirmatory.


Infrastructure impact

Docker Compose: Unchanged. No new services, no new volumes, no port changes.

CI pipeline: No .gitea/workflows/ changes. The new @SpringBootTest and @DataJpaTest tests will run within the existing mvnw test step. Both use PostgresContainerConfig with Testcontainers — they pull postgres:16-alpine, which is the same image already used by the CI database service. No new pull dependencies.

CI time impact: Two new test classes:

  • DocumentRepositoryTest additions: 4 tests in an existing @DataJpaTest class — the Postgres container is already started for this class; marginal overhead.
  • DocumentLazyLoadingTest: 5 tests in a new @SpringBootTest(webEnvironment=NONE) class. This starts a full application context with a Testcontainers Postgres instance. Expect ~10-15 seconds added to CI wall-clock time for context startup. Acceptable.

No new dependencies added to pom.xml. The @EntityGraph, @BatchSize, and @JsonIgnoreProperties annotations are from existing transitive dependencies (hibernate-core, jackson-databind). No supply chain impact.

Positive note

The @MockitoBean S3Client and @MockitoBean AuditLogQueryService in DocumentLazyLoadingTest prevent the test from attempting to connect to MinIO or any audit log backend during CI runs where those services may not be available. That is the correct approach for a @SpringBootTest that only needs the database.


Summary: No infrastructure changes, minimal CI time increase, no new external dependencies. LGTM from a platform perspective.

## 🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no Compose file updates, no CI workflow changes, no new Docker services. My review is therefore brief and confirmatory. --- ### Infrastructure impact **Docker Compose:** Unchanged. No new services, no new volumes, no port changes. **CI pipeline:** No `.gitea/workflows/` changes. The new `@SpringBootTest` and `@DataJpaTest` tests will run within the existing `mvnw test` step. Both use `PostgresContainerConfig` with Testcontainers — they pull `postgres:16-alpine`, which is the same image already used by the CI database service. No new pull dependencies. **CI time impact:** Two new test classes: - `DocumentRepositoryTest` additions: 4 tests in an existing `@DataJpaTest` class — the Postgres container is already started for this class; marginal overhead. - `DocumentLazyLoadingTest`: 5 tests in a new `@SpringBootTest(webEnvironment=NONE)` class. This starts a full application context with a Testcontainers Postgres instance. Expect ~10-15 seconds added to CI wall-clock time for context startup. Acceptable. **No new dependencies added to `pom.xml`.** The `@EntityGraph`, `@BatchSize`, and `@JsonIgnoreProperties` annotations are from existing transitive dependencies (`hibernate-core`, `jackson-databind`). No supply chain impact. ### Positive note The `@MockitoBean S3Client` and `@MockitoBean AuditLogQueryService` in `DocumentLazyLoadingTest` prevent the test from attempting to connect to MinIO or any audit log backend during CI runs where those services may not be available. That is the correct approach for a `@SpringBootTest` that only needs the database. --- **Summary:** No infrastructure changes, minimal CI time increase, no new external dependencies. LGTM from a platform perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing from a requirements traceability and acceptance criteria perspective. This PR closes issue #467 and the deliverable aligns with the problem statement.


Requirement traceability

Issue #467 stated problem: N+1 query explosion on document list/detail endpoints — 2,733 queries per 24 HTTP requests in pre-production audit.

Delivered solution addresses the root cause:

  • EAGER fetch eliminated on all four associations (receivers, tags, trainingLabels, sender)
  • Named entity graphs provide deterministic, per-path load strategies
  • @BatchSize(50) covers the fallback case
  • @Transactional(readOnly=true) prevents LazyInitializationException for callers that access lazy associations post-return

Acceptance criteria verification:

Acceptance criterion from PR Evidence in diff
findAll(Spec, Pageable) ≤5 statements for 10 docs findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements test
findById ≤2 statements findById_loadsSenderReceiversAndTagsInAtMostTwoStatements test
findAll(Pageable) ≤5 statements findAll_withPageable_loadsSenderWithoutNPlusOne test
No LazyInitializationException on getDocumentById getDocumentById_tagsAndReceiversAccessible_afterReturnFromService
No LazyInitializationException on getRecentActivity getRecentActivity_collectionsAccessibleAfterReturn
No LazyInitializationException on searchDocuments searchDocuments_receiverSort_* and searchDocuments_senderSort_*
No LazyInitializationException on DashboardService.getResume dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException

All documented acceptance criteria have corresponding test evidence. The one unchecked item in the PR description ("Full CI suite green") is a pipeline concern, not a requirement gap.

Non-functional requirement coverage

The issue was a performance NFR. The PR quantifies the improvement in the ADR (~2,733 → ≤10 statements per 24 HTTP requests) and backs it with measurable test thresholds. This is good NFR documentation — the threshold is testable, not qualitative.

No scope creep observed

The updateDocumentTags @Transactional fix is a correctness fix surfaced during the LAZY migration (the method would fail without an active session once collections became lazy). It is in-scope: you cannot fully migrate to LAZY without ensuring all write paths that touch those collections are transactional.

One open question (not a blocker)

The ADR lists "Query count reduced from ~2,733 to ≤10 statements per 24 HTTP requests" as a consequence. The test coverage validates specific code paths (findAll, findById, service methods). A production profiling session after deploy would confirm the end-to-end HTTP request count improvement. Recommend adding a note to the issue to re-run the original audit after deployment.


Summary: Requirements are fully traced, acceptance criteria are verifiable and tested, scope is contained. Approved.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing from a requirements traceability and acceptance criteria perspective. This PR closes issue #467 and the deliverable aligns with the problem statement. --- ### Requirement traceability **Issue #467 stated problem:** N+1 query explosion on document list/detail endpoints — 2,733 queries per 24 HTTP requests in pre-production audit. **Delivered solution addresses the root cause:** - EAGER fetch eliminated on all four associations (`receivers`, `tags`, `trainingLabels`, `sender`) - Named entity graphs provide deterministic, per-path load strategies - `@BatchSize(50)` covers the fallback case - `@Transactional(readOnly=true)` prevents `LazyInitializationException` for callers that access lazy associations post-return **Acceptance criteria verification:** | Acceptance criterion from PR | Evidence in diff | |------------------------------|-----------------| | `findAll(Spec, Pageable)` ≤5 statements for 10 docs | `findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements` test | | `findById` ≤2 statements | `findById_loadsSenderReceiversAndTagsInAtMostTwoStatements` test | | `findAll(Pageable)` ≤5 statements | `findAll_withPageable_loadsSenderWithoutNPlusOne` test | | No `LazyInitializationException` on `getDocumentById` | `getDocumentById_tagsAndReceiversAccessible_afterReturnFromService` | | No `LazyInitializationException` on `getRecentActivity` | `getRecentActivity_collectionsAccessibleAfterReturn` | | No `LazyInitializationException` on `searchDocuments` | `searchDocuments_receiverSort_*` and `searchDocuments_senderSort_*` | | No `LazyInitializationException` on `DashboardService.getResume` | `dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException` | All documented acceptance criteria have corresponding test evidence. The one unchecked item in the PR description ("Full CI suite green") is a pipeline concern, not a requirement gap. ### Non-functional requirement coverage The issue was a performance NFR. The PR quantifies the improvement in the ADR (`~2,733 → ≤10 statements per 24 HTTP requests`) and backs it with measurable test thresholds. This is good NFR documentation — the threshold is testable, not qualitative. ### No scope creep observed The `updateDocumentTags @Transactional` fix is a correctness fix surfaced during the LAZY migration (the method would fail without an active session once collections became lazy). It is in-scope: you cannot fully migrate to LAZY without ensuring all write paths that touch those collections are transactional. ### One open question (not a blocker) The ADR lists "Query count reduced from ~2,733 to ≤10 statements per 24 HTTP requests" as a consequence. The test coverage validates specific code paths (findAll, findById, service methods). A production profiling session after deploy would confirm the end-to-end HTTP request count improvement. Recommend adding a note to the issue to re-run the original audit after deployment. --- **Summary:** Requirements are fully traced, acceptance criteria are verifiable and tested, scope is contained. Approved.
Author
Owner

🎨 Leonie Voss (@leonievoss) — UI/UX & Accessibility

Verdict: Approved

This is a backend persistence change with no frontend modifications. No Svelte components were added or changed, no routes were added or changed, no API response shapes were altered. My UX and accessibility checklist has nothing to flag.


Frontend impact assessment

Zero frontend files changed. The diff touches:

  • backend/CLAUDE.md — developer documentation
  • backend/src/main/java/... — Java entity, repository, service
  • backend/src/test/java/... — Java tests
  • docs/adr/022-eager-to-lazy-fetch-strategy.md — ADR

No Svelte components, no TypeScript, no CSS, no route files, no API type regeneration.

User-experience impact (positive)

While no UI code changed, the performance improvement from ~2,733 → ≤10 SQL statements per 24 HTTP requests will have a direct user-visible benefit: faster page loads on the document list and detail views. For the senior audience (60+) who are our primary transcription users on slower connections, this reduction in server-side query time matters. The fix is invisible in the UI and beneficial to the experience.

Accessibility regression risk

None. No template markup, no ARIA attributes, no color tokens, no touch targets, no focus handling were changed.

Brand compliance

Not applicable to this PR.


Summary: No UI changes to review. The performance fix benefits all users, particularly those on slower devices. LGTM.

## 🎨 Leonie Voss (@leonievoss) — UI/UX & Accessibility **Verdict: ✅ Approved** This is a backend persistence change with no frontend modifications. No Svelte components were added or changed, no routes were added or changed, no API response shapes were altered. My UX and accessibility checklist has nothing to flag. --- ### Frontend impact assessment **Zero frontend files changed.** The diff touches: - `backend/CLAUDE.md` — developer documentation - `backend/src/main/java/...` — Java entity, repository, service - `backend/src/test/java/...` — Java tests - `docs/adr/022-eager-to-lazy-fetch-strategy.md` — ADR No Svelte components, no TypeScript, no CSS, no route files, no API type regeneration. ### User-experience impact (positive) While no UI code changed, the performance improvement from ~2,733 → ≤10 SQL statements per 24 HTTP requests will have a direct user-visible benefit: faster page loads on the document list and detail views. For the senior audience (60+) who are our primary transcription users on slower connections, this reduction in server-side query time matters. The fix is invisible in the UI and beneficial to the experience. ### Accessibility regression risk None. No template markup, no ARIA attributes, no color tokens, no touch targets, no focus handling were changed. ### Brand compliance Not applicable to this PR. --- **Summary:** No UI changes to review. The performance fix benefits all users, particularly those on slower devices. LGTM.
marcel merged commit 1247b51d9e into main 2026-05-19 09:23:32 +02:00
marcel deleted branch worktree-feat+issue-467-lazy-fetch-entity-graph 2026-05-19 09:23:33 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#622