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>
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/geschichten→GeschichteSummary(projection; never carries items; author exposes names only — never email)GET /api/geschichten/{id}→GeschichteView(withAuthorView,PersonView,JourneyItemViewitems)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
@Transactionalon 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 rawAppUserauthor 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
@JsonIgnoreon 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:apimust run after any view change — the generatedGeschichteschema no longer exists; frontend consumers useGeschichteView/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) andGeschichteListProjectionTest.