1. Границы projections
Projections выглядят простыми: «ну что там, вернуть не Product, а ProductRow». Но в реальности вы строите контракт между тремя мирами: вашим Java-кодом (где есть интерфейсы, конструкторы и records), JPQL-строкой (которая компилятором Java не проверяется) и результатом, который отдаёт JPA-провайдер (Hibernate). На стыке этих миров ошибки любят прятаться особенно хитро.
Давайте честно: когда вы пишете @Query(""" ... """), вы создаёте строку. Строка не знает, что такое «переименовали поле в сущности», «поменяли порядок аргументов в record», «написали alias не тем именем». Поэтому почти все проблемы с projections — это не «ошибка компиляции», а «всё было хорошо, пока не случилось в проде». И да, мы учимся так, чтобы это случалось в учебном проекте, а не в 3 часа ночи.
Чтобы лучше запомнить, держите в голове простую картинку: у interface-based projections слабое место — имена (getter ↔ alias), а у DTO/record-проекций слабое место — конструктор (позиции и типы аргументов). А ещё всегда есть третий класс проблем: «мы забыли, откуда берём данные», то есть запрос не описывает join, но projection внезапно требует поле из связанной сущности.
Набросаем схему, чтобы ощущение «откуда вообще берутся значения в projection» было визуальным:
flowchart TD
Repo["Repository method
return type = projection"]
Q["JPQL / derived query"]
H["Hibernate (JPA provider)"]
R["Result rows / tuples"]
M["Spring Data mapping
(projection)"]
P["Interface proxy
или DTO/record"]
Repo --> Q --> H --> R --> M --> P
Вся лекция дальше — это про то, как сделать так, чтобы этот конвейер работал предсказуемо, а не «ну иногда почему-то null».
Дальше инженерный вопрос уже не в том, как написать очередную projection, а где именно она ломается и по какому признаку выбрать форму под use case.
2. Имя против конструктора: где ломаются разные projections
У двух рабочих форм projections разные слабые места. Полезно держать их в одной компактной карте, чтобы потом не угадывать проблему по симптомам.
| Вид projection | На что опирается маппинг | Хрупкая точка | Когда обычно удобно |
|---|---|---|---|
| Interface-based | getter → property/alias | имена getter-ов, alias-ы в @Query, неожиданные nested properties | нужен очень лёгкий read-контракт без отдельного класса |
| DTO/record через select new | constructor expression | FQDN, порядок и типы аргументов, alias внутри select new | нужен явный именованный тип результата |
Для interface-based projection главное — имена. Spring Data должен понять, какое значение положить в getOrderNumber() или getTotalAmount(). Поэтому derived query обычно опирается на имена свойств entity, а в @Query лучше явно писать alias, совпадающий с ожидаемым именем свойства.
Для DTO/record projection логика другая: select new не маппит результат по именам, а вызывает конструктор. Значит, важны полное имя класса, порядок аргументов и их типы. Alias внутри select new здесь не помогает: в конструктор передаются значения, а не именованные колонки.
3. Когда брать interface, а когда DTO/record
Выбор здесь не идеологический, а чисто прикладной.
Interface-based projection обычно удобно брать там, где нужен очень лёгкий read-контракт: несколько getter-ов, простой list/page use case, derived query или короткий @Query, и при этом отдельный именованный класс в проекте не даёт заметного выигрыша. Это быстрый способ сказать репозиторию: «дай именно эти поля и не притворяйся сущностью».
DTO/record projection удобнее там, где read-модель становится самостоятельной единицей кода: её хочется явно назвать, спокойно передавать через сервисный слой, логировать, тестировать и держать рядом с конкретной фичей. Именно поэтому в проектных сценариях mini-shop естественно появляются ProductCatalogRow и OrderSummaryRow.
Оба варианта решают одну и ту же проблему — не тащить entity туда, где нужен узкий результат. Если projection начинает распухать в «почти вся сущность», это уже сигнал не о победе одного подхода над другим, а о том, что use case плохо отделён.
4. Projections со связями: join и поля
Самое коварное место начинается там, где строке чтения нужны поля из нескольких сущностей. Типичная просьба звучит так: «в каталоге товара надо показать не только название товара, но и название категории». У нас Product связан с Category через @ManyToOne, и это прекрасно… но projection не умеет читать мысли. Если вы хотите получить categoryName, вы должны явно показать запросу, откуда это поле берётся.
С record-проекциями обычно всё предельно честно: хотите три поля — выбираете три поля.
// Здесь намерение простое: хотим читать поля из Product + одно поле из связанной Category
public record ProductWithCategoryRow(
String sku,
String productName,
String categoryName
) {
}
Запрос:
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Важно: поле categoryName берётся из c.name, поэтому join обязателен
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductWithCategoryRow(
p.sku, p.name, c.name
)
from Product p
join p.category c
where p.id = :id
""")
Optional<ProductWithCategoryRow> findRowById(@Param("id") Long id);
}
Здесь важна мысль, а не синтаксис: join p.category c делает c доступным в запросе, и только поэтому мы можем выбрать c.name.
С interface-based projection логика похожая, просто добавляется дисциплина alias’ов. Например:
// Alias'ы в запросе ниже должны совпасть со свойствами: sku, productName, categoryName
public interface ProductWithCategoryView {
String getSku();
String getProductName();
String getCategoryName();
}
И запрос:
// Важно: p.name переименовываем в productName — и alias должен быть именно productName
@Query("""
select p.sku as sku,
p.name as productName,
c.name as categoryName
from Product p
join p.category c
where p.id = :id
""")
Optional<ProductWithCategoryView> findViewById(@Param("id") Long id);
Обратите внимание на один тонкий момент: в entity поле называется name, а в projection вы решили назвать его productName (чтобы читалось понятнее). Это нормально, но тогда alias тоже должен быть именно productName, а не name. То есть вы выбираете p.name as productName. Это не «лишняя болтовня», это способ сделать контракт чтения явным.
Ещё один практический вывод: как только projection начинает включать данные из нескольких сущностей, запрос становится немного более «ручным». И это нормально. Projections — как раз тот момент, где лучше быть чуть более явным, чем надеяться на «оно само».
5. projection и entity: роли и ожидания
Тут полезно удержать одно простое различие. Entity живёт по правилам ORM: у неё есть @Id, lifecycle, связи и участие в persistence context. Projection — это результат чтения, а не часть этого lifecycle.
Отсюда быстро следуют практические вещи. Projection нельзя честно save(...), от неё нельзя ждать dirty checking, cascade или эффекта «изменил поле — база сама догадается». Даже если в projection случайно попали те же поля, что и в Product, это всё равно не Product.
Поэтому правило спокойное. Если сценарий читает данные ради изменения состояния модели, обычно нужна entity. Если сценарий хочет узкую форму данных под список, сводку или лёгкую карточку, projection честнее и дешевле. А если projection распухла до «почти вся сущность», вы просто переименовали проблему.
6. Шпаргалка проверки projection
Когда вы начинаете активно писать projections, очень хочется иметь «внутренний чекер»: правильно ли я всё состыковал? Держите компактную таблицу, которая работает как здравый смысл, а не как ритуал.
| Что вы используете | Как связываются поля | Что должно совпасть | Что обычно ломается |
|---|---|---|---|
| Interface-based projection + derived query | Spring Data берёт свойства из entity-модели | Имена getter-ов ↔ свойства entity | Странные имена getter-ов, попытка «сделать красиво» и получить getProduct_title() |
| Interface-based projection + @Query | По alias’ам результата | alias ↔ имя свойства (getTotalAmount() → totalAmount) | alias не совпал, забыли as ..., получили null |
| DTO/record projection + select new | По позиции аргументов | Порядок и типы ↔ конструктор DTO/record | переставили поля в record, поменяли тип, добавили выражение не того типа |
| Любая projection со связями | Вы сами описываете путь чтения | В запросе должен быть путь (join) к нужным данным | пытались взять c.name, но не сделали join, либо перепутали alias |
И два «человеческих» правила, которые обычно спасают даже больше, чем таблица. Первое: projection должна быть маленькой и называться под use case (например, OrderSummaryRow), а не под сущность (OrderDtoButNotDto). Второе: если вы переименовали поле в entity или поменяли record — вспомните, что запросы у вас строками, и «рефакторинг сам всё не сделает». IDE помогает, но не всемогуща.
7. Типичные ошибки при работе с projections
Ошибка №1: alias в @Query не совпадает с ожидаемым именем свойства интерфейса.
Это классика interface-based projections. Запрос возвращает o.orderNumber as number, а интерфейс ждёт getOrderNumber(). В итоге либо прилетают null значения, либо вы начинаете подозревать, что Hibernate вас «не уважает». Hibernate тут ни при чём: контракт чтения вы описали разными словами в двух местах.
Ошибка №2: попытка «улучшить читаемость» и случайно перепутать порядок аргументов в select new.
С record-проекциями это особенно легко: вы поменяли порядок компонентов в record (потому что «так красивее»), а запрос оставили как был. Java всё скомпилировала, приложение стартовало, а в момент выполнения запроса внезапно выяснилось, что конструктор не совпадает. Это тот случай, когда «красивее» неожиданно становится «дороже».
Ошибка №3: использование alias’ов внутри списка аргументов select new.
В select new аргументы позиционные. Писать p.sku as sku внутри круглых скобок не нужно. Часто это заканчивается ошибкой парсинга запроса, а иногда — просто лишней путаницей в голове: кажется, что alias участвует в маппинге. Не участвует. Участвуют позиция и тип.
Ошибка №4: projection требует поле из связанной сущности, но запрос не содержит join.
Вы сделали ProductWithCategoryRow(sku, productName, categoryName) и в запросе пишете c.name, но c нигде не объявлен. В SQL-мире это выглядело бы как «в SELECT поле из таблицы, которую вы не присоединили». В JPQL логика такая же. Связи в entity не означают, что join случится сам собой именно так, как вам нужно.
Ошибка №5: попытка использовать projection как entity.
Иногда хочется «чуть-чуть поправить данные» и потом save(). Но projection — не persistence-модель. Она не живёт в ORM-цикле. Её нельзя каскадить, нельзя «обновить и забыть», нельзя ожидать, что она поведёт себя как Product. Это «снимок чтения», и относиться к нему нужно как к снимку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ