From 663ffad49bc5d48c36d4dfc06aa2307674e2ae70 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 14:28:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(adr):=20add=20ADR-043=20=E2=80=94=20derive?= =?UTF-8?q?d=20person=20life-events=20on-read=20strategy=20(Proposed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers three pre-implementation decisions for issue #776: 1. On-read assembly, never persisted (no migration) 2. Synthetic prefixed String ids (birth:/death:/marriage:) 3. assembleDerivedEvents() as the public cross-issue contract on TimelineService Refs #776 Co-Authored-By: Claude Sonnet 4.6 --- docs/adr/043-derived-person-events.md | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/adr/043-derived-person-events.md diff --git a/docs/adr/043-derived-person-events.md b/docs/adr/043-derived-person-events.md new file mode 100644 index 00000000..9b50de01 --- /dev/null +++ b/docs/adr/043-derived-person-events.md @@ -0,0 +1,110 @@ +# ADR-043 — Derived person life-events: on-read assembly strategy + +**Status:** Proposed +**Date:** 2026-06-13 +**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat) + +--- + +## Context + +The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside +manually curated `TimelineEvent` rows. This data already exists in the `Person` entity +(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in +`PersonRelationship` rows with `relationType = SPOUSE_OF`. + +Three architectural decisions needed before implementation could start: + +1. **Computation strategy:** should derived events be materialised to the `timeline_events` + table, or assembled on every read from the source tables? +2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide + with real `TimelineEvent` UUIDs and signal read-only semantics to consumers? +3. **Service contract:** where does the assembly method live, and what is its public API? + +--- + +## Decision 1 — On-read assembly, never persisted + +Derived events are computed on every call to `assembleDerivedEvents()` and are never written +to any table. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. | +| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. | +| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. | + +**Consequences:** +- No schema changes. No Flyway migration. +- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open + across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH. +- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member + persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale. + +--- + +## Decision 2 — Synthetic prefixed String ids + +Derived events receive ids of the form `birth:{personId}`, `death:{personId}`, +`marriage:{relationshipId}`, where the suffix is the UUID of the source entity. + +**Format rules:** +- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`. +- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is + structurally non-UUID by construction. +- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages; + the in-memory `Set` used during assembly is a defensive assertion, not primary + enforcement. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. | +| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. | +| Numeric sequence | No natural source sequence; would require a counter, adding state. | + +**Consequences:** +- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and + serves a different purpose (CRUD admin view); it is not changed. +- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that + do not parse as `UUID` — enforced and tested in issue #5, not here. +- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side + caching and deduplication. + +--- + +## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract + +The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the +`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean. + +**Domain boundary rules enforced by this decision:** +- `TimelineService` reaches `Person` and `PersonRelationship` data **only through + `PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**. + It never injects `PersonRepository` or `PersonRelationshipRepository`. +- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`, + `buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public. +- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling + endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller + outside #5 must do the same — this obligation is documented in the Javadoc of the method. + +**Alternatives rejected:** + +| Alternative | Reason rejected | +|-------------|-----------------| +| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. | +| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. | + +--- + +## Related decisions + +- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this + issue reads) +- ADR-040 — Timeline domain data model (establishes the `timeline/` package and + `TimelineEvent` entity this issue extends) +- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns + `List`, not raw `Person` or `PersonRelationship` entities)