Files
familienarchiv/docs/adr/043-derived-person-events.md
Marcel 663ffad49b docs(adr): add ADR-043 — derived person life-events on-read strategy (Proposed)
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 <noreply@anthropic.com>
2026-06-13 14:28:48 +02:00

5.5 KiB

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<UUID> 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.

  • 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<TimelineEntryDTO>, not raw Person or PersonRelationship entities)