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>
111 lines
5.5 KiB
Markdown
111 lines
5.5 KiB
Markdown
# 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. |
|
|
|
|
---
|
|
|
|
## 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<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)
|