A team repeatedly ships bugs where persistent objects leave the data-access layer still holding unfetched associations, and the failure only appears when the response is rendered. As a technical lead, how would you decide whether persistent entities may cross the service boundary at all, versus mandating projections?
answer
- Undocumented completeness contract = the real defect
- Reads project, writes keep entities inside the tx
- Declared graph + test assertion if entities escape
- Eager / lazy-outside-tx = removing the detector
- Enforce: arch tests, statement counts, graph asserts
basics
~20 sDecide per read path, then enforce it. Query-side paths should return projections: no proxies, no over-fetching, explicit contracts. Command-side paths keep entities inside the transaction and never publish them. Whatever the rule, make it mechanical — architecture tests on layer boundaries, statement counts and graph assertions in tests.
solid answer
~60 sI frame it as a contract problem, not a Hibernate problem. An object that leaves the transaction carries an implicit promise about which parts of it are loaded; nobody writes that promise down, so it drifts. My default split: - **Reads** (lists, API responses, exports, views) return **projections** built by the query. No proxies exist, so the class of bug disappears, over-fetching stops, and the query documents the payload. - **Writes** keep **entities**, inside the transaction, mutated through the domain model and never handed outward. Where entities must be published — small internal apps, a rich domain reused by several callers — the rule becomes: the service declares the graph (named entity graph or fetch join), the graph is asserted in a test, and the mapping to the outside happens inside the transaction. Then I make it enforceable: layer rules that forbid entity types in controller signatures, statement-count assertions on hot paths, initialization assertions, and disabling any global setting that loads lazily outside a transaction so mismatches still fail loudly.
go deeper
Focus on the practical rule: don't hand persistent objects to code that runs after the transaction; return the data the caller needs instead.
Contrast the two options concretely — projection queries versus fetch-joined entities — and note that returning entities implies a promise about what is loaded.
Argue per read path with cost and blast radius, and describe how you would test the promise: statement counts, initialization assertions, boundary rules.
Own the policy and the migration: default reads to projections, allow declared-graph entities where justified, forbid the global escape hatches, and make every rule mechanically checkable with a funded path to get existing code there.
## Reframe the problem The recurring exception is a symptom; the defect is that **object graphs have an undocumented completeness contract**. A service returns an entity, and the caller has no way — in the type system, in the signature, in review — to know which associations are loaded. Every consumer discovers it by running the code. That is why the same bug recurs: nothing changed to prevent it, only individual sites got patched. So the decision is not "lazy or eager" but **where the completeness contract is expressed and how it is enforced**. ## Option A: projections at the boundary (my default for reads) Every query-side use case gets a query that selects exactly the columns it needs into a purpose-built record. Benefits: - The failure mode is impossible: there are no proxies to touch later. - The contract is the query plus the record — reviewable, greppable, versionable. - Payloads shrink; no snapshots, no dirty checking, no accidental writes on read paths. - Read paths become independently tunable and can bypass the ORM entirely later without changing callers. Costs: - More classes; several similar records for similar screens. This is usually cheaper than it looks and much cheaper than a shared "fat" DTO that pulls everything for everyone. - Duplication of derived logic if the domain computes things the projection also needs; mitigate by computing in the query or by a small shared function over primitives. ## Option B: entities may cross, with a declared graph Sometimes justified: a rich domain model with behaviour worth reusing, a small team, an internal application, or a service whose consumers all need the same aggregate. Then the rules must be explicit: - Each service method that returns an entity **declares its graph** — a named entity graph or an explicit fetch join — and documents it. - The graph is **asserted in tests** (`Hibernate.isInitialized(...)` on exactly the declared nodes, and nothing more). - Serialization never decides the fetch plan; if a serializer runs outside the transaction, it may only see a declared graph. - Entities are never mutated outside the transaction that loaded them, so returning them cannot become an accidental write path. ## Option C (rejected): make it stop failing Widening mappings to eager, or enabling lazy loading outside transactions, removes the error and keeps the defect: graphs assembled from many snapshots, per-object queries after commit, and no signal when a new read path drifts. I treat both as review-blocking, and if either is already enabled I schedule its removal by turning it off in test environments first so the exceptions enumerate the work. ## How I actually decide Four questions: 1. **Does anything outside the transaction mutate these objects?** If no — and for API/read paths the answer is always no — the argument for entities is weak. 2. **Do consumers share one graph shape?** If they diverge, entities force the union of all needs; projections let each take its own. 3. **What is the blast radius of drift?** A public API or a high-traffic list is where a hidden N+1 costs the most; those get projections first. 4. **What can we enforce mechanically?** A rule nobody can check is not a rule. If the team cannot add architecture tests and statement counting, prefer the option whose failure mode is impossible rather than merely forbidden. ## Making it stick - **Boundary rules** in an architecture test: entity types must not appear in controller or public-API signatures (if Option A), or must appear only from methods annotated as graph-declaring (if Option B). - **Statement-count assertions** on the top read paths, so an added mapper line that triggers 200 selects fails CI rather than a dashboard. - **Graph assertions** so "loaded exactly this much" is a test, not a habit. - **Keep the failure loud**: default settings, no global lazy-outside-transaction escape hatch, and integration tests that exercise the real boundary rather than keeping a context open around assertions. - **Migration order**: start with the paths that are hottest and most public; leave stable internal ones alone. This is refactoring with a budget, not a rewrite. ## What I would tell the team The goal is that a reader of a method signature knows what they are getting, and that a wrong assumption fails in CI. Whether we achieve that with projections everywhere or with declared graphs matters less than achieving it at all — but projections get there with fewer rules, which is why they are the default for reads.
- Doesn't mandating projections duplicate the domain model?It duplicates shapes, not behaviour. Projections are read shapes for specific use cases; domain rules stay on the entities used by command paths. The duplication people fear comes from trying to make one shared DTO serve every screen, which reintroduces over-fetching. Small, per-use-case records are cheap and change independently.
- How do you enforce the rule rather than just documenting it?Architecture tests that fail when entity types appear in boundary signatures, statement-count assertions on hot read paths so hidden per-object queries fail CI, and initialization assertions that pin exactly which associations a service returns loaded. Combined with keeping the default failure behaviour on, mismatches surface in the build instead of in production.
- When is publishing entities genuinely the right call?Small internal applications with one consumer, a rich domain model whose behaviour callers legitimately reuse, and teams that can commit to declaring and testing the graph per method. Even then the mapping to the transport format should happen inside the transaction, so the fetch plan and the serialization live together.
saying these in an interview costs you the question
- Treating this as a Hibernate configuration decision rather than a contract-and-enforcement decision.
- Proposing one shared fat DTO that carries every association "just in case".
- Relying on the team's discipline with no automated check.
- Solving it by making mappings eager or by enabling lazy loading outside transactions.
- Assuming keeping a persistence context open around the whole request removes the need for a declared graph.