diff --git a/CLAUDE.md b/CLAUDE.md index 632213cc..552aeefc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional ### DTOs -Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs). +Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen. - `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index eb71ec19..9df6c131 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -148,6 +148,9 @@ public class GeschichteService { g.setPublishedAt(LocalDateTime.now()); } Geschichte saved = geschichteRepository.save(g); + // A freshly created Geschichte has no items by construction — items are only + // addable via the separate /items endpoints. Revisit if a create DTO ever + // accepts initial items. return toView(saved, List.of()); } diff --git a/docs/adr/036-geschichte-responses-are-views-not-entities.md b/docs/adr/036-geschichte-responses-are-views-not-entities.md new file mode 100644 index 00000000..144c71e0 --- /dev/null +++ b/docs/adr/036-geschichte-responses-are-views-not-entities.md @@ -0,0 +1,65 @@ +# ADR-036 — Geschichte responses are views assembled in-transaction, never entities + +**Status:** Accepted +**Date:** 2026-06-10 +**Issue:** #753 (JourneyEditor frontend), PR #792 review + +## Context + +The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model +entities themselves (no response DTOs)."* That convention assumed entities whose +associations are either eager or initialized by the time Jackson serializes. + +The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption: +Jackson serializes **after** the service transaction has closed, so any lazy +collection on a returned entity is a dead proxy. `Geschichte.items` (added with the +Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}` +(save draft, publish) failed with HTTP 500 +`LazyInitializationException: Geschichte.items … (no session)`. + +Per-endpoint force-initialization (`g.getItems().size()` inside the transaction) +worked for `getById()` but is a footgun: every new write method must remember the +trick, the entity carries a warning comment nobody reads, and the raw entity also +leaks the `author` `AppUser` graph (email, password hash, groups). + +## Decision + +In the **geschichte domain**, controllers never return entities. Every response is a +purpose-built read model assembled **inside** the service transaction: + +- `GET /api/geschichten` → `GeschichteSummary` (projection; never carries items; + author exposes names only — never email) +- `GET /api/geschichten/{id}` → `GeschichteView` (with `AuthorView`, `PersonView`, + `JourneyItemView` items) +- `POST /api/geschichten`, `PATCH /api/geschichten/{id}` → `GeschichteView` +- JourneyItem endpoints → `JourneyItemView` + +The invariant: **entities never cross the controller boundary in this domain.** +A view is constructed while the Hibernate session is open, so serialization can +never touch a lazy proxy, and the response shape is an explicit, security-reviewed +contract. + +## Alternatives rejected + +- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** — + fixes one endpoint at a time, silently regresses when the next write method is + added, and still serializes the raw `AppUser` author graph. +- **`open-in-view: true`** — re-opens the session during rendering; hides N+1 + queries and couples the HTTP layer to Hibernate session lifetime. Rejected + already by ADR-022. +- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs + (items ARE the journey) instead of loading it deliberately. + +## Consequences + +- CLAUDE.md §DTOs names the geschichte domain as the exception to the + entities-as-responses convention. Other domains (document, person, tag) still + return entities; they predate ADR-022's lazy collections on their hot paths and + migrate opportunistically when they grow lazy collections of their own. +- `npm run generate:api` must run after any view change — the generated + `Geschichte` schema no longer exists; frontend consumers use + `GeschichteView`/`GeschichteSummary`. +- New geschichte endpoints must add a view (or extend an existing one), not return + the entity. The regression guards are `GeschichteHttpTest` + (`update_returns_200_and_serializes_items_open_in_view_false`) and + `GeschichteListProjectionTest`.