skip to content

You need one row per order carrying the customer's name and the number of line items on that order. How do you shape that as a JPQL DTO projection, and what goes wrong when a projection spans a to-many association?

level: seniorimportance: should knowfreq 35%

answer

  1. one DTO per SQL row — cardinality rule
  2. to-one flattens; to-many multiplies
  3. count/sum + group by, or correlated subquery
  4. distinct does not fix line-level differences
  5. children needed → second query by parent ids

basics

~20 s

Join the to-one customer flatly and aggregate the to-many: select new View(o.id, c.name, count(l)) from Order o join o.customer c left join o.lines l group by o.id, c.name. Without the aggregate, joining a to-many multiplies rows — one per line item — so the DTO is instantiated repeatedly per order.

solid answer

~60 s

```java select new com.x.OrderRow(o.id, c.name, count(l)) from Order o join o.customer c left join o.lines l group by o.id, c.name ``` Two different association kinds, two different treatments: - **To-one (`customer`)** flattens naturally — one joined row per order, so `c.name` sits beside `o.id` in the constructor. Use an explicit join rather than path navigation when the association is optional, so a missing customer does not silently drop the order. - **To-many (`lines`)** multiplies: a plain `join o.lines l` yields one row per line item, so the DTO would be built once per line and the "per order" contract is broken. Either aggregate it (`count`, `sum`, `min`) with `group by`, or leave it out. `distinct` is the wrong fix here: it de-duplicates identical rows, and rows differing by line item are not identical. If the DTO genuinely needs the **children**, a constructor expression cannot build it — run a second query for the lines of the fetched order ids and group them in Java, which is two predictable queries instead of N+1.

code

java · 14 lines
java
public record OrderRow(Long id, String customer, Long lineCount, Long units) {}

List<OrderRow> rows = em.createQuery("""
        select new com.example.OrderRow(
                 o.id, c.name, count(l), coalesce(sum(l.qty), 0L))
        from Order o
        join o.customer c
        left join o.lines l
        where o.createdAt >= :since
        group by o.id, c.name
        order by o.id
        """, OrderRow.class)
    .setParameter("since", since)
    .getResultList();

go deeper

for a junior

Show the to-one join flattened into the constructor and know that joining a collection produces one row per child.

for a middle

Add the count/sum with group by, the left join so zero-child parents survive, and why distinct is not the fix.

for a senior

Handle the two-query pattern for real children, the double-aggregate cross product, and honest pagination on a flat projection.

for a principal

Decide the read-model shape overall: how many queries a screen may cost, where aggregation belongs, and when a denormalised read model or view is the better answer.

## The cardinality rule A projection produces **one object per SQL result row**. Everything about flat projections follows from that. - A **to-one** association (`@ManyToOne`, `@OneToOne`) does not change the row count, so its columns flatten straight into the DTO. - A **to-many** association multiplies the row count by the number of children, so anything you project alongside it is repeated. ## The to-one side ```java select new com.x.OrderRow(o.id, o.createdAt, c.name, c.country, o.total) from Order o join o.customer c ``` One join, one row per order, and the SQL selects exactly the five columns named. Two details worth stating: **Explicit join versus path navigation.** `o.customer.name` inside the constructor also works and renders a join — but always an **inner** join. If `customer` is optional, that silently drops orders without one. Write `left join o.customer c` when the association is nullable and you want those rows. **Depth.** `join o.customer c join c.address a` flattens two levels the same way. Each additional to-one join adds a table but not a row. ## The to-many side This is where projections go wrong. Consider: ```java select new com.x.OrderRow(o.id, c.name, l.sku) -- one row per LINE from Order o join o.customer c join o.lines l ``` An order with four lines produces four `OrderRow`s. Callers who expected one per order now double-count totals — a defect that survives testing on a fixture where every order has one line. Three correct responses: **1. Aggregate.** If what you need is a number, compute it in SQL: ```java select new com.x.OrderRow(o.id, c.name, count(l), coalesce(sum(l.qty), 0)) from Order o join o.customer c left join o.lines l group by o.id, c.name ``` Use `left join` so orders with zero lines survive with `count = 0`. Every non-aggregated select item must appear in `group by` (some databases relax this when grouping by a primary key; do not rely on it portably). Beware the classic double-aggregate trap: joining **two** independent to-many collections and summing both inflates each by the other's cardinality — compute them in separate queries or with subqueries in the select list. **2. A correlated scalar subquery.** Sometimes clearer, and avoids `group by` entirely: ```java select new com.x.OrderRow(o.id, c.name, (select count(l) from Line l where l.order = o)) from Order o join o.customer c ``` **3. Two queries.** When you need the actual children rather than a number, no constructor expression can help — a DTO holding a `List<LineRow>` cannot be built one row at a time. Run a flat query for the parents, then a second query `where l.order.id in :ids` for the children, and group them in memory (`Collectors.groupingBy`). Two queries with bounded result sets, no N+1, no row multiplication. Keep the `in` list chunked (typically a few hundred to a couple of thousand ids) so the second statement stays plan-friendly. ## Why `distinct` is not the answer Reaching for `select distinct` when rows multiply is the reflex to unlearn. `distinct` compares the **selected values**; rows that differ by `l.sku` are distinct, so nothing collapses. And when it does collapse rows, it does so by making the database sort or hash the whole result — cost paid to undo a join you should not have made. (`distinct` on an *entity* fetch-join query is a different, legitimate use — and that belongs to the fetch-join discussion, not here.) ## Ordering and paging Because a flat projection is one row per result object, `setMaxResults` means exactly what it says — unlike a to-many fetch join, where limiting rows truncates children. This is a real advantage of projections for paginated screens: the SQL can carry `limit`/`offset` and the count is honest. Add `order by` on a deterministic column (include the id as a tiebreaker) or paging across requests will shuffle. ## Nested DTO shapes JPA 3.2 and Hibernate permit a nested `new` for **to-one** structure — `new OrderRow(o.id, new CustomerRow(c.id, c.name))` — which stays one row per order. There is still no way to nest a *collection*, and there is no partial-entity trick that makes it safe: the moment you want children, it is the two-query pattern. ## What to say in an interview State the cardinality rule first, then map each association kind to its treatment, then name the row-multiplication failure and why `distinct` does not fix it. Mentioning `left join` for optional to-ones and for zero-count aggregates is the detail that distinguishes someone who has actually shipped this from someone reciting syntax.

  • Why not just add distinct to the query that joins the to-many?
    Because distinct compares the selected values, and rows that differ by a line-level column are genuinely different rows, so nothing collapses. If you drop the line columns so distinct does collapse them, you have paid for a join whose only effect is filtering — an exists subquery expresses that better and cheaper. Distinct also forces the database to sort or hash the entire result, and it interacts badly with limit/offset.
  • How do you keep the two-query pattern from turning into N+1?
    Fetch the parents first, collect their ids into a single list, and issue exactly one child query with an in-clause over those ids — never one query per parent. Chunk the id list (a few hundred to a couple of thousand) so the in-clause stays reasonable for the database's plan cache and any parameter limits. Then group the children in memory by parent id and attach them.
  • You aggregate over two different collections on the same root in one query and the numbers are inflated. What happened?
    Joining two independent to-many associations produces a cross product of their rows, so each collection's aggregate is multiplied by the other's cardinality. The fixes are to compute each aggregate in its own query, or to use correlated scalar subqueries in the select list so each aggregate is evaluated independently of the other join.

saying these in an interview costs you the question

  • Adding select distinct to fix duplicated parents caused by a to-many join
  • Believing a constructor expression can populate a DTO field that is a list of children
  • Using path navigation on an optional to-one and silently losing rows to the inner join
  • Using an inner join to the collection in a count aggregate, dropping parents with zero children
  • Aggregating over two to-many collections in one query without noticing the cross product

context