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>
66 lines
3.2 KiB
Markdown
66 lines
3.2 KiB
Markdown
# 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`.
|