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>
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:
- Computation strategy: should derived events be materialised to the
timeline_eventstable, or assembled on every read from the source tables? - Id format: how do we give derived events stable, unambiguous ids that cannot collide
with real
TimelineEventUUIDs and signal read-only semantics to consumers? - 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 thatbuildMarriageEvents()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:
idfield onTimelineEntryDTOis typedString, NOTUUID.UUID.fromString(derivedEvent.id())always throwsIllegalArgumentException— id is structurally non-UUID by construction.- The
unique_spouse_pairDB index (V55) is the authoritative dedup guard for marriages; the in-memorySet<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.idmust beString. The existingTimelineEventView.idisUUIDand 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 asUUID— 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:
TimelineServicereachesPersonandPersonRelationshipdata only throughPersonService.findAllFamilyMembers()andRelationshipService.findAllSpouseEdges(). It never injectsPersonRepositoryorPersonRelationshipRepository.- The three private builder methods (
buildBirthEvents,buildDeathEvents,buildMarriageEvents) are implementation details; onlyassembleDerivedEvents()is public. - Authorization:
assembleDerivedEvents()performs no authorization check. The calling endpoint in #5 must enforceREAD_ALLbefore 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 andTimelineEvententity this issue extends) - ADR-036 — Responses as views, never raw entities (why
assembleDerivedEvents()returnsList<TimelineEntryDTO>, not rawPersonorPersonRelationshipentities)