JavaRush /Курси /Spring Data JPA /DTO/record-проєкції в JPQL

DTO/record-проєкції в JPQL select new

Spring Data JPA
Рівень 12 , Лекція 2
Відкрита

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 (повне імʼя класу) 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 — іменований параметр, щоб запит легше жив під час рефакторингу

Тут навмисно видно кілька важливих речей:

Ми повертаємо List<ProductPriceRow>, і цим фіксуємо контракт читання. Метод уже не «про сутності», він про ціни.

Ми пишемо повне імʼя record у JPQL, тому що @Query — це рядок, і Java-імпорти тут не допомагають.

Ми використовуємо іменований параметр :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 — іменований параметр

Тут важливий момент: форма результату (Optional) виражає «може не існувати». І це набагато чесніше, ніж повертати null і потім ловити NullPointerException десь у несподіваному місці.

6. Відповідність конструктора для 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();
// Цей код скомпілюється (запит — це рядок),
// але під час запуску впаде: у 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 як типи для вибраних полів, тобто сенс саме в обмеженні.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ