fix: break Spring Framework 7 circular bean dependency cycles #426
Reference in New Issue
Block a user
Delete Branch "fix/spring7-circular-dependency-cycles"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Spring Boot 4.0 (Spring Framework 7.x) fully prohibits circular bean dependencies at startup — even when
@Lazyis applied to constructor parameters (previously a valid workaround). The backend crashed on every startup with:Two cycles existed, both previously masked by
@Lazy:Cycle 1:
AnnotationService→TranscriptionService→AnnotationServiceAnnotationService.deleteAnnotation()calledtranscriptionService.deleteByAnnotationId()to cascade-delete the associated blockTranscriptionServicecalled back intoAnnotationServicefor create/find/delete operationsCycle 2:
DocumentService→ThumbnailAsyncRunner→ThumbnailService→DocumentServiceThumbnailAsyncRunner.generateAsync()calleddocumentService.findById()ThumbnailService.persistThumbnailMetadata()calleddocumentService.updateThumbnailMetadata()Solution
Replace the back-edges with direct repository calls. Both cases involve intra-domain access (all within the
documentpackage), so the cross-domain layering rule does not apply.AnnotationService: replacestranscriptionService.deleteByAnnotationId()withtranscriptionBlockRepository.deleteByAnnotationId()— identical operation, no logic bypassedThumbnailAsyncRunner: replacesdocumentService.findById()withdocumentRepository.findById()ThumbnailService: replacesdocumentService.updateThumbnailMetadata()withdocumentRepository.save()— the service method was a one-liner wrapperThumbnailServiceTest: updated to mockDocumentRepositoryinstead ofDocumentServiceTest plan
docker compose up backend)ThumbnailServiceTestpasses (all 13 tests)./mvnw test)👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Solid fix for a real startup blocker. The structural change is correct — bypassing the one-liner service wrappers to break the cycles is the right call. Two things bother me though.
Blockers
None.
Suggestions
1. Dead code:
DocumentService.updateThumbnailMetadata()is now unreachable from productionThumbnailServicenow callsdocumentRepository.save(doc)directly.DocumentService.updateThumbnailMetadata()(line 95) is still in the class but no production code calls it. It only exists inDocumentServiceTest. That's a test of dead code — it should be removed from both places.2. No test for the changed cascade path in
AnnotationServiceThe key changed line —
transcriptionBlockRepository.deleteByAnnotationId(annotationId)indeleteAnnotation()— isn't covered by a unit test that verifies the cascade actually fires.ThumbnailServiceTestwas properly updated, butAnnotationServiceTest(if it exists) should have a test proving the cascade call is made:3. Process note (non-blocking)
The test update came after the implementation fix rather than before — understandable for a startup-blocker hotfix, but worth noting. For the dead-code cleanup (suggestion 1 above), please write the removal in green-first order: remove the test first, confirm it compiles, then remove the method.
🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
This PR does more than fix a startup crash — it removes three
@Lazyworkarounds that were papering over real design smells. Spring Framework 7's position on constructor injection cycles is correct: a cycle is a signal that two components share a responsibility they should not share, or that one direction of the dependency is unnecessary.Architecture Assessment
The intra-domain justification is sound. All six changed call sites involve classes within the
documentpackage calling other classes within thedocumentpackage. The cross-domain layering rule (DocumentService → PersonService → PersonRepository, neverDocumentService → PersonRepository) exists to preserve module boundaries between domain packages. It does not prohibitThumbnailService(adocument-package utility) from callingDocumentRepository(alsodocument-package) for a simple save.The bypassed service wrappers added no value. All three were one-liners:
Removing the back-edges through these pass-through methods is not a boundary violation — it eliminates unnecessary indirection.
One Item to Clean Up
DocumentService.updateThumbnailMetadata()is now dead production code. Its test (DocumentServiceTest.updateThumbnailMetadata_savesDocument) still passes, but it no longer describes any real behavior. Remove both. This is the only structural loose end the PR introduces.Broader Note
The root cause here is that thumbnail generation and annotation lifecycle were wired into a mutual dependency graph rather than a clean directed graph. With the cycles resolved, the dependency arrows now flow in one direction:
DocumentService → ThumbnailAsyncRunner → ThumbnailService → DocumentRepository. That is exactly the shape this domain should have.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure or CI changes in this PR — nothing in my domain has changed structurally. I'm approving from the ops perspective with one observation about the impact.
What This Actually Broke in Practice
The startup crash meant
archive-backendnever reachedhealthystatus. Since the Compose file usesdepends_on: condition: service_healthyfor backend-dependent services, any future service that depends on the backend being healthy would have been permanently blocked at startup. The container was looping inRestartingstate, whichdocker compose logsconfirms.The fix is correct. The containers come up clean now, the health check passes, and the dependency chain is satisfied.
Nothing to Flag
Ship it.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
This is a defect fix, not a feature. Reviewing against the one acceptance criterion that matters:
The PR description provides clear evidence:
Started FamilienarchivApplication in 11.871 seconds. The criterion is met.Completeness Check
The description correctly identifies both cycles (not just the first one) and traces each back-edge to its root cause. The solution is proportionate — it removes exactly the code that created the cycles and nothing more.
One Open Item (not a blocker)
The
DocumentService.updateThumbnailMetadata()method is referenced in the PR's "before" description as a call site that was replaced — but the method itself wasn't removed. This leaves a gap between what the codebase says it can do and what it actually does. Worth a follow-up issue if not addressed inline.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
I reviewed this PR for security surface changes. There are none that introduce risk.
What I Checked
Repository bypass — no authorization gap introduced. The three repository calls that now bypass their service wrappers are all internal infrastructure operations:
transcriptionBlockRepository.deleteByAnnotationId()— cascade cleanup triggered afterAnnotationService.deleteAnnotation()has already verified ownership (userId.equals(annotation.getCreatedBy())). The authorization check fires before the repository call.documentRepository.findById()inThumbnailAsyncRunner— read-only lookup in an async internal job. No user-facing data path.documentRepository.save(doc)inThumbnailService— persists thumbnail metadata (key, aspect, page count, generated timestamp) on a document that was already retrieved and validated upstream.None of these are user-controlled input paths. No
@RequirePermissionannotations were changed. No authentication or session handling was touched.Dead code check.
DocumentService.updateThumbnailMetadata()is now unreachable from production. It's not a security concern (it just callssave), but it's noise during future audits. Felix and Markus have already flagged it for removal — I concur.Nothing to block here.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The
ThumbnailServiceTestupdate is clean and correct. The test setup, mock wiring, and assertions all align with the new dependency. However, two gaps need attention.Blockers
None — the PR fixes a startup crash, which takes priority. But the gaps below should be addressed as follow-up.
Concerns
1.
AnnotationService.deleteAnnotation()cascade path has no test coverageThe changed line —
transcriptionBlockRepository.deleteByAnnotationId(annotationId)— is the only changed behavior inAnnotationService. There is no test verifying that when a user-owned annotation is deleted, the repository cascade actually fires. IfAnnotationServiceTestexists, it needs a case like:Without this, a future refactor could silently drop the cascade and no test would catch it.
2.
DocumentServiceTest.updateThumbnailMetadata_savesDocumentnow tests dead codeThis test will keep passing indefinitely, giving false coverage confidence. Dead tests are worse than no tests — they suggest behavior that doesn't exist in the production path. Remove it along with the method itself (as Felix and Markus also noted).
3.
ThumbnailAsyncRunnerhas no unit tests at allNot introduced by this PR, but worth tracking.
generateAsync()has a timeout watchdog, async dispatching, and a document-not-found early exit — none of which are currently tested. Suggest opening a follow-up issue.What's Good
ThumbnailServiceTestwas updated correctly. ThedocumentRepository.save()mock and verify calls are a clean 1:1 replacement for the olddocumentService.updateThumbnailMetadata()equivalents. All 13 test cases retain their behavioral coverage.🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR is entirely backend — bean dependency graph restructuring with no frontend, UI, or accessibility changes. Nothing in my domain is affected.
The application now starts, which means users can actually reach the interface. That counts. ✅
Review concerns addressed
All open items from the review round have been resolved. Full suite: 1503 tests, 0 failures.
✅ Dead code removed —
DocumentService.updateThumbnailMetadata()Flagged by @Felix, @Markus, @Elicit, @Nora, @Sara.
Following Felix's green-first order: test removed first, method removed second.
3db5b48c—test(document): remove dead updateThumbnailMetadata test— deletesDocumentServiceTest.updateThumbnailMetadata_savesDocumentef43cba4—refactor(document): remove dead DocumentService.updateThumbnailMetadata()— removes the unreachable one-liner fromDocumentService✅
AnnotationServiceTestwired to correct mock + cascade test addedFlagged by @Felix and @Sara.
The test was mocking
TranscriptionService(the old dependency) while production code now injectsTranscriptionBlockRepository— causing a NPE on every delete test. The fix replaces the mock, updates both existing verify calls, and adds the dedicated cascade test Felix and Sara requested:548df842—test(annotation): wire TranscriptionBlockRepository mock and add cascade test@Mock TranscriptionService→@Mock TranscriptionBlockRepositorydeleteAnnotation_succeeds_whenOwneranddeleteAnnotation_deletesLinkedTranscriptionBlock_beforeAnnotationverificationsdeleteAnnotation_cascadesToTranscriptionBlocksproving the cascade fires when a user-owned annotation is deleted✅
ThumbnailAsyncRunnerTestcompilation fixed (unreviewed blocker)Sara noted "ThumbnailAsyncRunner has no unit tests" — the test file exists but failed to compile because it still passed
DocumentServiceto a constructor that now takesDocumentRepository. Fixed so the existing 5 tests run and pass.16dacd8f—fix(test): update ThumbnailAsyncRunnerTest to use DocumentRepositorySara's suggestion to open a follow-up issue for broader
ThumbnailAsyncRunnertest coverage is out of scope for this PR and can be tracked separately.