# 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)