Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.1 KiB
ADR-038 — Domain event drives note-less journey-item cleanup on document delete
Status: Accepted Date: 2026-06-11 Issue: #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
Context
Two constraints in V72 encode contradictory rules for a journey item that has a
document_id but no note:
fk_journey_items_document→ON DELETE SET NULL— when a document is deleted, Postgres nulls outdocument_id.chk_journey_item_not_empty— requires at least one ofdocument_idornoteto be non-null.
A note-less item (document_id set, note IS NULL) satisfies the CHECK while the
document exists. Deleting the document causes Postgres to attempt SET NULL, which
would leave both columns null — a direct CHECK violation. Postgres aborts the
transaction with a 500 that bypasses GlobalExceptionHandler.
The natural fix — delete note-less items inside DocumentService.deleteDocument before
deleteById runs — cannot call JourneyItemService directly: JourneyItemService
already injects DocumentService, and Spring Framework 7 (used by Spring Boot 4)
fully prohibits constructor-injection cycles. The application will not start if such
a cycle is introduced.
Decision
DocumentService.deleteDocument publishes a DocumentDeletingEvent (plain record,
payload: documentId UUID only) via ApplicationEventPublisher before
documentRepository.deleteById. A dedicated @Component
JourneyItemDocumentDeleteListener in the geschichte.journeyitem package consumes
this event and calls journeyItemRepository.deleteNoteLessByDocumentId(documentId)
directly — bypassing JourneyItemService to avoid re-introducing the cycle and to
suppress the per-item JOURNEY_ITEM_REMOVED audit emission (see audit decision below).
Load-bearing listener-phase choice: plain @EventListener
The listener is annotated with @EventListener (not
@TransactionalEventListener(AFTER_COMMIT), not @Async). This choice is
load-bearing:
AFTER_COMMITwould break the fix entirely.AFTER_COMMITfires after the surrounding transaction has committed. By that point,documentRepository.deleteByIdhas already executed and Postgres has already triedON DELETE SET NULL— the constraint violation fires before the listener ever runs.@Asyncwould break rollback atomicity (AC-5). An async listener runs on a separate thread in its own transaction. IfdeleteDocumentsubsequently rolls back (e.g. due to an unrelated failure), the listener's deletes are in a committed async transaction and cannot be undone.- Plain
@EventListenerruns synchronously in the publisher's thread and transaction. The listener's JPQL delete and thedeleteByIdare a single atomic unit: if either fails, both roll back together.
Repository method
@Modifying(clearAutomatically = true)
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
i.document.id (the real association path) is used instead of i.documentId: the
transient getDocumentId() getter on JourneyItem makes Spring Data unable to resolve
a derived query path (same trap documented at JourneyItemRepository:33-44).
clearAutomatically = true invalidates the L1 cache so subsequent reads in the same
session do not return stale entities.
The predicate (note IS NULL OR note = '') covers the note = '' edge case that the
service layer can never produce (normalizeNote converts blank strings to null), but that
may exist via raw SQL inserts or legacy data. Whitespace-only notes (note = ' ')
do not match and are preserved as note-carrying placeholders.
Audit decision
The listener calls the repository directly rather than routing through
JourneyItemService.delete. This deliberately bypasses the JOURNEY_ITEM_REMOVED
audit emission: a document used in multiple journeys would otherwise produce N audit
rows for a single user action. The DOCUMENT_DELETED entry written by deleteDocument
is the sole audit record for the operation.
Boundary: documents must not depend on journey
The event direction is document → journey, never the reverse. DocumentService
publishes events it knows nothing about the consumers of; JourneyItemService's
dependency on DocumentService is unchanged and remains the only cross-domain
reference. This direction is the prerequisite for the cycle constraint to hold.
Alternatives rejected
- DB trigger on
journey_items— trigger logic is opaque to Java developers, invisible to code review, and not covered by the JPA test harness. - RESTRICT instead of SET NULL — breaks the existing note-carrying placeholder UX: deleting a document with a note-carrying journey item would 409 instead of preserving the item as a placeholder.
- Relax
chk_journey_item_not_empty— the constraint enforces a real invariant (every item must have at least document or note). Removing it would allow empty rows. @Lazyon theJourneyItemService → DocumentServiceinjection — Spring Boot 4 / Spring Framework 7 prohibits constructor-injection cycles regardless of@Lazy.- Make
DocumentServicecallJourneyItemService— introduces the prohibited cycle. Rejected at design time.
Consequences
- No schema change — no new Flyway migration, no
db-orm.puml/db-relationships.pumlupdate. - This is the first custom domain event in the codebase. No prior
ApplicationEventPublisherusage existed inmain/. New cross-domain cleanup that cannot use direct service calls should follow this pattern. - All tests that delete documents and then assert journey-item state must route
through
DocumentService.deleteDocument, notdocumentRepository.deleteById. The existingJourneyItemIntegrationTesttests that covered the note-carrying placeholder UX have been updated accordingly. - The
DOCUMENT_DELETEDAuditKindwas added as part of this fix to give AC-7's audit assertion a positive check (absence-only assertions pass vacuously if all auditing regresses).