1. DTO/record вместо interface-based projection
Когда вы впервые видите interface-based projection, возникает ощущение: «О, отлично! Сейчас я наделаю интерфейсов, и всё будет красиво». И действительно, для простых случаев это отличный инструмент: вы описываете только нужные геттеры, Spring Data JPA возвращает вам «лёгкий» результат, а вы не тащите целую entity в память.
Но в реальном проекте (и в нашем mini-shop тоже) довольно быстро появляется другая потребность: хочется иметь явный именованный тип результата, который можно передать дальше по коду как нормальный объект, положить в лог, сравнить, протестировать, и чтобы IDE с автодополнением не пыталась «угадать», что там под прокси спрятано. Именно здесь удобно перейти к DTO- или record-проекциям.
У interface-based подхода есть ещё один бытовой минус: интерфейс очень легко превратить в «универсальный пылесос для всего», когда в него начинают докидывать геттеры «на всякий случай». И вы снова незаметно приходите к ситуации «мы возвращаем почти всю сущность, только другими словами». С DTO/record обычно психологически проще: раз уж ты создаёшь отдельный тип, то он должен быть маленьким и одноназначным, иначе совесть (или тимлид) не даст спокойно спать.
В терминах Spring Data это называется class-based projections (DTOs), и документация прямо говорит, что это нормальный вариант проекций: отдельные value-типы, которые используются как результат запроса, без проксирования, в отличие от интерфейсных проекций.
2. Java record как «идеальная коробочка» для read-модели
Когда вы слышите слово record, может показаться, что это «какая-то новая штука, которой будут хвастаться на собеседовании». На самом деле это просто очень удобная форма для маленьких неизменяемых объектов-данных. И в проекциях record чувствует себя как дома: он короткий, прозрачный, и вы не пишете тонну шаблонного кода.
Важно поймать правильную ассоциацию: record в нашем контексте — это строка результата (row), а не объект поведения. Как чек в магазине: «SKU, цена». Он ничего не делает, он просто сообщает факты.
Spring Data в документации прямо подчёркивает, что Java records особенно хорошо подходят для DTO-типа, потому что у них value semantics: поля private final, автоматически генерируются equals()/hashCode()/toString(), и в целом это удобные носители данных.
Простейшая record-проекция для нашего каталога (например, «цены товаров по статусу») может выглядеть так:
import java.math.BigDecimal;
// Read-модель: это НЕ entity, а «строка результата» из запроса
public record ProductPriceRow(String sku, BigDecimal price) {
// У record есть канонический конструктор (sku, price),
// и именно он будет вызван JPQL-ом через `select new`.
}
Обратите внимание на приятную мелочь: у record уже есть конструктор «по умолчанию» (канонический), и он идеально подходит для JPQL select new, потому что нам нужен именно конструктор с аргументами.
3. JPQL select new: как запрос создаёт DTO/record прямо на лету
Самая важная идея этой лекции: JPQL умеет не только выбирать сущности или отдельные поля, но и создавать экземпляры вашего класса прямо в результате запроса. Это делается через constructor expression — конструкцию вида:
select new com.example.SomeDto(x, y, z) ...
Hibernate (и JPA в целом) описывает это так: select new «упаковывает» результаты запроса в пользовательский Java-класс вместо массива, и для этого класс должен быть указан по полному имени и обязан иметь подходящий конструктор.
И тут сразу два критичных следствия, которые важно принять спокойно, без драм.
Первое следствие: в JPQL нужно писать fully qualified name (FQDN) DTO/record-класса, то есть вместе с пакетом. Не «ProductPriceRow», а «com.example.shopdatajpa.catalog.query.ProductPriceRow». Это не из вредности — просто JPQL строка живёт отдельно от Java-импортов.
Второе следствие: результат select new — это не managed entity. Даже если вы случайно назовёте DTO так же, как entity, и даже если это будет entity-класс (что делать не надо), объект результата не становится частью persistence context и не начинает магически «сохраняться» при изменении полей. Это просто созданный объект-данные. Hibernate отдельно предупреждает об этом: такие экземпляры не являются управляемыми сущностями и не ассоциированы с сессией.
Чтобы не воспринимать select new как магию, полезно мысленно представлять такую схему:
flowchart TD
A[JPQL запрос] --> B[SQL к БД]
B --> C[Набор колонок в ResultSet]
C --> D[Вызов конструктора DTO/record]
D --> E[Готовый объект для чтения]
То есть «ORM построил SQL → база вернула значения → JPA вызвала ваш конструктор». Никакой телепатии. Просто аккуратная упаковка результата.
4. Пример 1: ProductPriceRow через select new в ProductRepository
Сейчас мы сделаем маленький, но очень показательный кусок нашего mini-shop. Представим, что в каталоге есть сценарий «показать только цены товаров определённого статуса», например для внутреннего отчёта или для пересчёта скидок. Нам не нужен ProductDetails, не нужна категория, не нужен id, нам нужна пара значений.
Создаём record в правильном пакете
По архитектуре проекта мы держим такие read-модели рядом с querying-кодом фичи. Поэтому логично положить record сюда:
com.example.shopdatajpa.catalog.query
import java.math.BigDecimal;
// Read-модель под конкретный use case: «SKU + цена»
// (как правило, DTO/record-результаты держим отдельно от entity)
public record ProductPriceRow(String sku, BigDecimal price) {
}
Пишем @Query с select new
Теперь добавим метод в ProductRepository. Здесь важно, что запрос пишется по entity-модели (Product p, p.sku, p.price), как мы делали на дне про JPQL, и при этом в select мы создаём record.
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductPriceRow;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
// Метод репозитория возвращает именно read-модель, а не entity:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.sku, p.price)
from Product p
where p.status = :status
order by p.sku
""")
List<ProductPriceRow> findPriceRowsByStatus(@Param("status") ProductStatus status);
// Важно:
// 1) FQDN в `select new` обязателен (импорты Java тут не работают)
// 2) Порядок аргументов должен совпадать с конструктором record
// 3) :status — named parameter, чтобы запрос легче жил при рефакторинге
Здесь намеренно видно сразу несколько дисциплин:
Мы возвращаем List<ProductPriceRow>, и этим фиксируем контракт чтения. Метод уже не «про сущности», он про цены.
Мы пишем полное имя record в JPQL, потому что @Query — это строка, и импорты Java здесь не помогают.
Мы используем named parameter :status, чтобы запрос читался спокойно и переживал рефакторинг лучше.
Как это выглядит в сервисе
Теперь сервис может работать напрямую с read-моделью, без промежуточного «читаем сущности → вручную берём два поля».
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductPriceRow;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CatalogQueryService {
private final ProductRepository productRepository;
public CatalogQueryService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ProductPriceRow> findActivePrices() {
// Сервис возвращает ровно то, что нужно use case:
// список «SKU+цена» для активных товаров, без entity и без лишних полей.
return productRepository.findPriceRowsByStatus(ProductStatus.ACTIVE);
}
}
Обратите внимание на «вкусную» архитектурную мелочь: сервис теперь явно говорит «я возвращаю список цен», а не «вот вам сущности, а вы там сами разберитесь». Это как раз то, ради чего мы вообще затеяли projections.
5. Пример 2: OrderTotalRow через select new
record — отличный инструмент, но иногда вы по каким-то причинам хотите обычный класс. Например, вы хотите назвать геттеры чуть иначе, добавить метод форматирования (хотя это уже спорно для read-модели), или вы просто ещё не подружились с records. В Spring Data это тоже нормальная class-based projection.
Сделаем DTO для сценария «кратко показать сумму заказа». Пусть нам нужны orderNumber и totalAmount.
DTO-класс
import java.math.BigDecimal;
// DTO-класс как read-модель: хранит только выбранные поля, без поведения ORM
public class OrderTotalRow {
private final String orderNumber;
private final BigDecimal totalAmount;
public OrderTotalRow(String orderNumber, BigDecimal totalAmount) {
// Эти значения прилетят из `select new ... (o.orderNumber, o.totalAmount)`
this.orderNumber = orderNumber;
this.totalAmount = totalAmount;
}
public String getOrderNumber() {
return orderNumber;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
}
Пока вы junior, лучше держать DTO максимально скучными. Чем меньше «умности», тем меньше неожиданных эффектов.
Репозиторий заказа: select new и Optional
И теперь объявим метод в CustomerOrderRepository, который вернёт эту проекцию.
import com.example.shopdatajpa.ordering.query.OrderTotalRow;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
// Optional здесь отражает предметную реальность: заказа может не быть
@Query("""
select new com.example.shopdatajpa.ordering.query.OrderTotalRow(o.orderNumber, o.totalAmount)
from CustomerOrder o
where o.id = :id
""")
Optional<OrderTotalRow> findTotalRowById(@Param("id") Long id);
// Обратите внимание:
// - Возвращаем DTO/проекцию, а не entity
// - FQDN обязателен внутри `select new`
// - Параметр :id — named parameter
Здесь важный момент: форма результата (Optional) выражает «может не существовать». И это намного честнее, чем возвращать null и потом ловить NullPointerException где-нибудь в неожиданном месте.
6. Matching конструктора для select new
Если interface-based projection часто «завязана» на имена геттеров, то select new завязан на конструктор. И он не читает ваши мысли. Он читает только список аргументов.
Именно поэтому в документации и Hibernate, и Spring Data постоянно повторяется одна и та же мысль разными словами: должен быть matching constructor. Hibernate прямо пишет, что класс обязан иметь подходящий конструктор.
Чтобы вам было проще это удерживать в голове, держите маленькую табличку:
| DTO/record ожидает | JPQL должен выбрать |
|---|---|
| new ProductPriceRow(String sku, BigDecimal price) | p.sku, p.price |
| new OrderTotalRow(String orderNumber, BigDecimal totalAmount) | o.orderNumber, o.totalAmount |
Очень типичная «тихая» ошибка новичка — случайно поменять порядок:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.price, p.sku)
from Product p
""")
List<ProductPriceRow> broken();
// Этот код скомпилируется (запрос — строка),
// но на runtime упадёт: у record нет конструктора (BigDecimal, String) в таком порядке.
Код компилируется (потому что это строка), но на запуске вы получите ошибку, потому что конструктор record не принимает (BigDecimal, String) в таком порядке. И это хороший урок: JPQL проверяется позже, чем Java-код.
Есть ещё один важный нюанс именно для constructor expressions: нельзя писать алиасы внутри аргументов select new. Для interface-based проекций алиасы — нормальная история, потому что они помогают сопоставить колонки с геттерами. Но для DTO constructor expression это недопустимо. Spring Data отдельно предупреждает: JPQL constructor expressions не должны содержать алиасы для выбранных элементов.
То есть так делать не надо:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(
p.sku as sku, p.price as price
)
from Product p
""")
List<ProductPriceRow> nope();
// Алиасы `as ...` внутри `select new` ломают constructor expression:
// сюда передаются значения, а не «именованные колонки».
В select new вы передаёте значения, а не «именованные колонки».
7. Хранение и имена DTO/record-проекций
Когда в проекте появляются проекции, у новичка обычно два сценария.
Первый сценарий: «Сложу все DTO в пакет dto, потому что так принято». Через неделю там будет 40 классов, из которых половина относится к каталогу, четверть — к заказам, и ещё кусок — вообще непонятно к чему. Это классическая коллекция “misc”.
Второй сценарий (намного здоровее): держать проекции рядом с фичей и рядом с querying-кодом. В нашем проекте это идеально ложится в catalog.query и ordering.query.
С именованием тоже есть простая бытовая дисциплина. Если проекция соответствует строке списка, часто удобно называть её ...Row или ...View. Если это именно «кусочек данных для отчёта/таблички», Row очень прямолинейно намекает: это не entity, это «строка результата». Именно такой стиль мы уже начали использовать в примерах (ProductPriceRow, OrderTotalRow).
Отдельно скажу про суффикс Dto. Он не запрещён. Но в data-layer проекте он иногда звучит слишком «web-слойно», потому что слово DTO многие привыкли связывать с REST-контрактами. А наша мысль сегодня — «это read-model под use case внутри backend», а не обязательно транспорт наружу. Поэтому Row/View/Summary обычно психологически точнее.
8. Типичные ошибки при DTO/record-проекциях через select new
Ошибка №1: писать в JPQL короткое имя класса и надеяться на импорты.
В Java мы привыкли: импортнул — и живёшь. Но @Query — это строка, она живёт своей жизнью. Hibernate ожидает, что в select new будет указан класс по fully qualified name, и иначе он просто не сможет его найти.
Ошибка №2: «почти совпало» с конструктором — и ладно.
select new требует совпадения по порядку и типам аргументов. Если вы переставили местами поля или поменяли тип, ошибка будет на runtime. Hibernate прямо говорит, что нужен matching constructor.
Ошибка №3: добавлять алиасы внутрь select new, как в interface-based projection.
Интерфейсные проекции часто требуют алиасов для сопоставления геттеров, и это может «приучить» вас писать as везде. Но Spring Data отдельно предупреждает, что для constructor expressions алиасы недопустимы.
Ошибка №4: пытаться относиться к DTO/record как к сущности.
Результат select new — не managed entity: он не находится под управлением persistence context и не является «живым объектом ORM». Это просто объект-данные. Hibernate подчёркивает, что такие экземпляры не ассоциированы с сессией.
Ошибка №5: делать DTO «на все случаи жизни» и превращать его в полу-сущность.
Как только в DTO появляются поля «на всякий случай», он перестаёт быть проекцией, а становится новой версией entity, только без аннотаций. В этот момент проще честно вернуть entity, либо сделать две маленькие проекции под два разных use case. Spring Data описывает DTO projections как типы для выбранных полей, то есть смысл именно в ограничении.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ