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>
This commit is contained in:
Marcel
2026-06-10 07:29:21 +02:00
parent d34afb2298
commit d6ac88a211
3 changed files with 69 additions and 1 deletions

View File

@@ -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.

View File

@@ -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());
}

View File

@@ -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`.