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
|
### 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.
|
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ public class GeschichteService {
|
|||||||
g.setPublishedAt(LocalDateTime.now());
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
Geschichte saved = geschichteRepository.save(g);
|
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());
|
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