# 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 out `document_id`.
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
  to 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_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
  surrounding transaction has committed. By that point, `documentRepository.deleteById`
  has already executed and Postgres has already tried `ON DELETE SET NULL` — the
  constraint violation fires before the listener ever runs.
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
  separate thread in its own transaction. If `deleteDocument` subsequently rolls back
  (e.g. due to an unrelated failure), the listener's deletes are in a committed async
  transaction and cannot be undone.
- **Plain `@EventListener` runs synchronously in the publisher's thread and
  transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
  unit: if either fails, both roll back together.

### Repository method

```java
@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.
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
  Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
  cycle. Rejected at design time.

## Consequences

- **No schema change** — no new Flyway migration, no `db-orm.puml` /
  `db-relationships.puml` update.
- This is the **first custom domain event** in the codebase. No prior
  `ApplicationEventPublisher` usage existed in `main/`. 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`**, not `documentRepository.deleteById`.
  The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
  placeholder UX have been updated accordingly.
- The `DOCUMENT_DELETED` `AuditKind` was 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).
