Files
familienarchiv/docs/adr/036-geschichte-responses-are-views-not-entities.md
Marcel d6ac88a211 docs(adr): ADR-036 — geschichte responses are views, never entities
Captures the read-model invariant that was only living in a code comment:
views are assembled in-transaction, entities never cross the controller
boundary in the geschichte domain. CLAUDE.md §DTOs now names the exception
so the convention text stops contradicting the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:29:21 +02:00

3.2 KiB

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/geschichtenGeschichteSummary (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.