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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal 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`.
|
||||
Reference in New Issue
Block a user