You inherit a large service that renders entity graphs and depends on the persistence context staying open until the response is written. Design the fetching boundary you would move it to, and how you would sequence the migration without a big-bang change.
answer
- projections/DTO out of the transaction
- nothing managed reaches the view
- statement-count tests before refactoring
- endpoint by endpoint, hottest first
- flip the flag last, non-prod first
basics
~10 sMove to transaction-per-request-handler: the service fetches exactly what the response needs and returns immutable projections, so nothing lazy survives into rendering. Migrate endpoint by endpoint behind statement-count tests, then disable the request-scoped session last.
solid answer
~60 s**Target design.** Each handler delegates to one transactional service call that fetches the full graph it needs — join fetches or a projection query — and returns a DTO or record. The presentation layer receives data with no proxies, so it cannot issue queries, and the persistence context can close at the transaction boundary. **Sequencing.** 1. Instrument first: per-request statement counts and pool acquisition timing, so you have a before picture and can rank endpoints by damage. 2. Add a test harness that asserts the query count of an endpoint, so regressions are caught mechanically. 3. Migrate the worst endpoints one at a time: define the response DTO, write the fetching query, remove the entity from the response path. 4. Where entities must still be returned, detach at the boundary so nothing can lazily load or be written. 5. Only when the endpoint set is clean, turn the request-scoped session off — in a non-production environment first, so remaining offenders fail loudly. 6. Keep the guard: a test that fails if the setting comes back. The risk is that disabling it early converts hidden inefficiency into visible errors, so the flag flips last.
code
java · 11 linespublic record OrderRow(Long id, BigDecimal total, String customerName) {}
@Transactional(readOnly = true)
public List<OrderRow> recent(int limit) {
return em.createQuery("""
select new com.example.OrderRow(o.id, o.total, c.name)
from Order o join o.customer c
order by o.createdAt desc""", OrderRow.class)
.setMaxResults(limit)
.getResultList();
}go deeper
Know the destination: load what the response needs inside the transaction and return plain data objects instead of entities.
Describe join fetch versus projection, and why the view receiving DTOs removes lazy loading from rendering entirely.
Own the mechanics of a safe migration: measurement, statement-count tests, endpoint-by-endpoint change, awkward graph cases, flag flipped last.
Argue the boundary as architecture — who is allowed to touch the database, what the response contract is, the cost and timeline of the migration, and when not doing it is the right call.
## Why the boundary moves at all Keeping the persistence context open through rendering couples two things that should be independent: what the service loaded, and what the view decided to display. The view's decisions become database queries at runtime, outside any transaction, invisible in review and unbounded in count. The target design makes the service the sole owner of database access, with a contract the view cannot extend. ## The target: transaction-per-handler plus projections - **One transaction per request handler**, opened in the service layer and committed before rendering starts. Read-only endpoints get read-only transactions. - **The service fetches the complete graph the response needs.** Two tools: `join fetch` in JPQL (or an entity graph) when you need real entities, and a constructor/record projection when you need a subset of columns. Projections are usually better for read endpoints — they narrow the columns, avoid loading entities into the context at all, and cannot be lazily extended. - **The response type is not an entity.** A DTO or record with only the fields the endpoint publishes. This also removes the accidental API coupling where changing a column changes the JSON contract. - **Nothing managed escapes.** If an entity must be returned, detach it at the boundary so it can neither lazy-load nor be dirtied by later code. A secondary benefit: with data fully materialized before rendering, serialization becomes pure CPU work, so response time stops depending on database availability once the transaction ends. ## Sequencing on a large codebase **Step 0 — measure.** Enable per-request statement counting and record the distribution per endpoint, plus pool acquisition wait time. This gives a prioritized list (query count times request rate) and the baseline you will defend in review. **Step 1 — build the safety net before changing code.** Add tests that assert the number of statements a given endpoint issues. This is the crucial instrument: it makes the invisible cost visible and locks in each improvement. Without it, a later refactor silently reintroduces lazy loads. **Step 2 — migrate hot endpoints first.** For each one: define the DTO from what the response actually contains, write the fetching query, switch the handler, then assert the new statement count. Do not attempt to reshape the domain model at the same time. **Step 3 — handle the awkward cases explicitly.** Polymorphic graphs, conditional rendering, and deeply nested trees are where the pattern was hiding the most. Options: multiple queries composed in the service (bounded and intentional), batch fetching to turn N queries into a few, or accepting a second round trip for a rarely used branch. The requirement is that the count is a decision, not an accident. **Step 4 — flip the flag last, and not in production first.** Disabling the request-scoped session converts silent extra queries into loud failures on any path you missed. Do it in a staging or test profile, let integration tests and a manual pass surface the remainder, fix, then ship. Flipping it early is the classic mistake: it turns a performance problem into an availability incident. **Step 5 — make the change stick.** Keep the configuration explicit rather than relying on a default, add an architecture test that fails if entity types appear in controller return signatures, and keep the statement-count assertions in CI. ## Trade-offs to state out loud - **More code.** DTOs and explicit queries are more lines than letting the view pull what it likes. The return is predictability: the number of queries an endpoint issues becomes a reviewable property. - **Duplication risk.** Per-endpoint projections can multiply. Accept some duplication; a shared "one big DTO for everything" reintroduces over-fetching. - **Timeline.** On a large codebase this is quarters of background work, not a sprint. Sequencing by endpoint rather than by layer is what keeps it shippable at every point. - **When to stop.** For a small internal tool with modest traffic the pattern's cost may never matter; the honest principal answer includes deciding *not* to migrate and instead capping the damage with query-count alerts. ## What a weak answer looks like "Turn the setting off and fix whatever breaks." It ignores that the breakage is spread across every rendering path, arrives as runtime failures rather than compile errors, and has no measurement backing the claim that it helped.
- Why disable the request-scoped session at the end of the migration rather than at the start?Because disabling it converts every remaining unfetched lazy access into a runtime failure on the rendering path, across endpoints you have not yet reviewed. Migrating first keeps the system shippable at every step and lets statement-count tests prove each improvement; the flag flip then becomes a low-risk confirmation rather than the change that breaks production.
- When would you decide not to migrate at all?When traffic and graph sizes are small enough that the extra queries never threaten the pool or the latency budget — an internal admin tool, for instance. Then the responsible move is to cap the risk instead: alert on per-request query counts, keep response graphs shallow, and revisit the decision if traffic or payload sizes grow.
Packing a lunchbox before leaving instead of letting people wander back to the kitchen all afternoon. More work at the boundary, but the kitchen is closed and nobody's trips are a surprise.
saying these in an interview costs you the question
- Flipping the configuration flag first and fixing runtime breakage afterwards
- Migrating without any measurement, so nobody can tell whether it helped
- Building one universal DTO per aggregate, reintroducing over-fetching
- Assuming eager fetch mappings on the entity are a substitute for per-endpoint fetch plans
- Presenting the migration as a sprint-sized task on a large codebase