You are asked to speed up a read-heavy list endpoint that currently loads mapped entities and maps them to a response in Java. Walk through converting it to a DTO read path and what you have to watch out for.
answer
- start from the payload, not the entity
- one DTO per read use case
- push filter/sort/page/aggregate into SQL
- children: second query by parent ids
- writes stay on entities
basics
~20 sStart from the response payload, write a query selecting exactly those columns into a DTO, push filtering, sorting, paging and aggregates into SQL, and drop the entity load. Watch for lost lazy navigation, formatting logic that lived on the entity, and per-row work now missing.
solid answer
~50 s1. **Start from the response, not the entity.** List the fields the payload actually contains; that is the select list. 2. **Write one query per read use case** returning a DTO or record via a constructor expression, joining the associations the payload needs instead of relying on navigation. 3. **Push work into SQL**: filtering, sorting, `limit`/`offset` paging, counts and sums. A projection that still materialises every row and filters in Java has fixed nothing. 4. **Delete the entity load and the mapper.** Leaving both is how you end up paying twice. 5. **Verify**: the endpoint should now issue a fixed, small number of statements regardless of page size — no follow-up statements per row. Watch out for: derived values computed by entity methods (re-express them in SQL or in the DTO), collections that cannot go into a constructor argument (second query keyed by parent ids), authorisation checks that used to read the entity, and stale tests that asserted on entities. Keep the write path on entities.
code
java · 16 lines// before: all columns, managed, plus a statement per row for author
List<Post> posts = em.createQuery("from Post p where p.published = true", Post.class)
.setMaxResults(50).getResultList();
return posts.stream().map(p -> new PostRow(p.getId(), p.getTitle(),
p.getAuthor().getName(), p.getComments().size())).toList();
// after: one statement, four columns, nothing managed
return em.createQuery("""
select new com.app.PostRow(p.id, p.title, a.name, count(c))
from Post p join p.author a left join p.comments c
where p.published = true
group by p.id, p.title, a.name
order by p.id desc
""", PostRow.class)
.setMaxResults(50)
.getResultList();go deeper
Explain that the query should select only the fields the response needs into a DTO instead of loading whole entities.
Add pushing filter/sort/page/aggregates into SQL, the collection workaround, and that DTO results are unmanaged.
Cover verification (constant statement count), what breaks — entity methods, authorisation reads, caching, tests — and keeping writes on entities.
Frame it as a read-model boundary: which endpoints earn a dedicated projection, and the maintenance cost of proliferating query-shaped types.
## Why the entity read is the bottleneck here A list endpoint that loads entities pays for everything the ORM offers and uses almost none of it: all mapped columns are selected, each row becomes an instance plus a loaded-state snapshot held by the persistence context, and every flush in the transaction dirty-checks the lot. Worse, mapping to a response in Java tends to navigate associations, and each navigation on a lazily mapped association is another statement — the same trip repeated per row. ## The conversion, step by step ### 1. Derive the select list from the payload Open the response class or the serialised JSON and write down the fields. That set — not the entity's field list — is the query. If the payload has `id, title, authorName, commentCount`, the query has four expressions, one of which is an aggregate and one of which comes from a join. ### 2. One query per use case Write a dedicated DTO (a record is ideal) and a constructor expression: ``` select new com.app.PostRow(p.id, p.title, a.name, count(c)) from Post p join p.author a left join p.comments c where p.published = true group by p.id, p.title, a.name ``` Resist a single "wide" DTO shared across endpoints that each query half-fills — half-populated objects are as confusing as partially initialised entities. ### 3. Push work down Everything the database does better belongs in the query: predicates, `order by`, `limit`/`offset` (`setFirstResult`/`setMaxResults`), aggregates, and existence checks. A projection that fetches everything and then filters, sorts or counts in Java has moved the cost, not removed it. Where paging plus aggregation gets awkward, a second lightweight count query is usually cheaper than materialising the world. ### 4. Remove, do not layer Delete the entity query and the entity→response mapper. A common failed conversion keeps loading entities "because something might need them" and adds the projection alongside, doubling the work. ### 5. Verify the shape of the traffic After the change the endpoint should issue a predictable, constant number of statements: ideally one, plus a count query for paging. If statement count still scales with page size, some navigation survived. Compare wall-clock and allocation for a realistic page size, not for ten rows. ## What breaks, and how to handle it **Behaviour that lived on the entity.** `post.getDisplayTitle()` or a computed status may be a method on the mapped class. Options: re-express it in SQL, compute it inside the DTO from selected values (a record can have extra derived accessors), or keep it in a small mapper that takes the DTO. What you must not do is load the entity just to call the method — that reinstates the cost. **Collections.** A constructor expression cannot take `p.comments`. If the payload nests children, run a second query for the child rows filtered by the collected parent ids and stitch them by id in memory; that is two statements regardless of page size, and it avoids the duplicated-parent-row problem a join produces. Hibernate 6's `multiset()` function can build nested lists in one statement when you want it. **Lazy navigation you relied on.** With entities, a missing field was silently fetched. With DTOs it is simply absent, which is a feature: absence is a compile-time visible gap, not a runtime statement storm. **Authorisation and auditing.** If a check read fields from the loaded entity, those fields must appear in the projection or the predicate must move into the `where` clause. Filtering rows in SQL is usually the better outcome anyway. **Second-level caching.** If those entities were served from a cache, the DTO query goes to the database every time unless you cache the query results. Measure rather than assume the projection wins. **Tests.** Tests that asserted on returned entities need rewriting against the DTO, and because JPQL constructor expressions are strings, each new query needs a test that actually executes it — a mismatched constructor is a runtime failure. ## Keep the write path on entities The conversion applies to reads. Commands should still load the managed entity by id and mutate it, because dirty checking, cascades, versioning and lifecycle callbacks are exactly what you want there. The result is a read side of query-shaped types and a write side of mapped entities — the same idea in the small as a CQRS split, without any of its infrastructure. ## When not to convert A detail page returning one row, a lookup served from cache, or a screen that immediately edits what it loaded are all fine as entity reads. Convert the endpoints where result size, column width or statement count actually hurt, and leave the rest alone; a repository full of near-identical projections has its own maintenance cost.
- The payload needs each parent with a list of its children. How do you build that without a constructor expression taking a collection?Query the parent rows into parent DTOs, collect their ids, then run a second query returning child rows that includes the parent id and filters with `where child.parent.id in :ids`. Group the child rows by parent id in memory and attach them. It is two statements regardless of page size and avoids the duplicated parent rows a join would produce.
- After the conversion an endpoint got slower, not faster. What would you look at first?Whether the previous entity read was being served from the second-level cache while the projection always hits the database, and whether the new query's join or grouping forced a plan the entity query avoided — for example an aggregate over a large child table. Check the execution plan and the cache statistics before assuming the projection is at fault.
saying these in an interview costs you the question
- Adding a projection query but leaving the entity load in place, so the endpoint does both
- Selecting a projection and then filtering, sorting or paging the list in Java
- Loading the entity anyway just to call a derived getter
- Building one wide shared DTO that every query populates partially
- Converting the write path to DTOs and then wondering why updates no longer happen