JPQL і @Query: межа derived queries

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

1. Derived queries на старті

Derived queries — це як автодоповнення в IDE: поки завдання просте, воно економить час і робить код приємнішим. Ви пишете метод на кшталт findBySku(...) або findByStatus(...), і Spring Data JPA сам будує запит, надсилає SQL до бази та повертає вам результат. Для новачка це ідеальний вхід: менше синтаксису, більше сенсу.

Почнімо з простого прикладу: короткий метод, одна умова, а читається легко і в репозиторії, і в сервісі.

Нижче я спеціально братиму локальні шматки ProductRepository: так простіше побачити саму межу derived queries, не затягуючи за собою весь інтерфейс.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

// Репозиторій Spring Data JPA: базові CRUD-операції вже є в JpaRepository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query: Spring Data сам побудує запит за іменем методу (за полем sku)
    Optional<Product> findBySku(String sku);
}

Тут імʼя методу — коротке, майже розмовне. У сервісному коді це виглядає ще краще: productRepository.findBySku(sku). Навіть якщо ви на мить забули деталі, мозок читає це без «дешифрування».

І в цьому головний плюс derived queries: вони працюють ідеально, доки запит можна виразити коротко й очевидно, і при цьому імʼя методу залишається людяним, а не перетворюється на «метод-ковбасу».

2. Довгі імена методів

Щойно запит стає складнішим, derived query починає вимагати, щоб ви «прописали» умови прямо в імені. Спочатку це виглядає терпимо, потім — як пароль від Wi‑Fi, який вам диктують телефоном: начебто все логічно, але хочеться перепитати, де тут дефіси й великі літери.

Уявімо, що в каталозі ми хочемо вибрати товари за категорією, за статусом і за максимальною ціною. У реальному житті це звичайний фільтр. У derived query це починає виглядати так:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.math.BigDecimal;
import java.util.List;

// Репозиторій для читання каталогу: поки що derived query, але назва вже зростає
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Важливо: всі умови (category.code, status, price <=) заховані прямо в назву методу
    List<Product> findByCategory_CodeAndStatusAndPriceLessThanEqual(
            String categoryCode, ProductStatus status, BigDecimal maxPrice);
}

Поки що терпимо, але вже помітно, що ми читаємо формулу, а не сценарій. Назва методу починає описувати не «навіщо ми це робимо», а «якими конкретно умовами відбираємо». І далі буде тільки веселіше, бо фільтри мають властивість розмножуватися.

У цей момент важливо зловити думку: derived queries не «зламалися» і не стали поганими. Просто вони за своєю природою переїжджають із зони «зручно» в зону «занадто багато деталей у назві». І це нормальний інженерний етап дорослішання проєкту.

3. Переходи за звʼязками в derived queries

У нашому міні-магазині сутності повʼязані: у Product є Category, у OrderItem є Product, у замовлення є позиції. Це добре — так модель стає ближчою до реальності. Але є побічний ефект: у запитах ми починаємо фільтрувати не лише за полями Product, а й за полями повʼязаних сутностей, і derived query змушує «виписувати» це в імені.

Наприклад, ви хочете: «дай товари з активних категорій». В обʼєктній моделі це виглядає природно, але в derived query починає рости «доріжка» з імен:

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

// Приклад переходу за зв’язком: category -> active
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Category_ActiveTrue означає фільтрацію за полем active у пов’язаній сутності category
    List<Product> findByCategory_ActiveTrue();
}

А тепер додамо ще один крок: категорія активна, код категорії такий, товар активний, ціна не більша за, назва містить підрядок. У бізнес-словах це один сценарій. У derived query — уже літературний твір:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.math.BigDecimal;
import java.util.List;

// Приклад «глибокого» derived query: багато умов + кілька переходів за зв’язками
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Чим більше умов, тим більший ризик перетворити назву методу на «рядок запиту без пробілів»
    List<Product> findByCategory_CodeAndCategory_ActiveTrueAndStatusAndPriceLessThanEqualAndNameContainingIgnoreCase(
            String categoryCode, ProductStatus status, BigDecimal maxPrice, String namePart);
}

Зараз саме час чесно запитати себе: це ви проєктуєте репозиторій, чи репозиторій проєктує вас?

Назва методу стала настільки довгою, що вона вже виконує роль тексту запиту, тільки написаного дивною мовою. І найприкріше: ця мова гірше читається, ніж нормальний запит, бо в ній немає пробілів, немає візуальної структури й вона погано «сканується» очима.

4. Комбінаторний вибух фільтрів

Є окремий клас болю, який майже неминуче приходить у будь-який живий проєкт: фільтри бувають не лише «багато умов», а й «частина умов необов’язкова». Тобто сьогодні користувач указав тільки status, завтра status + category, післязавтра status + діапазон цін тощо. Якщо намагатися робити це суто derived queries, репозиторій починає розпухати.

Спочатку ви пишете пару методів і радієте. Потім додаєте третій. Потім помічаєте, що у вас зʼявилася колекція методів, які відрізняються однією деталлю, і ви вже не дуже впевнені, який із них викликає сервіс.

Ось типова «драбина» варіантів, яка виглядає невинно, доки ви не розумієте, що це лише початок:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.math.BigDecimal;
import java.util.List;

// Репозиторій починає «обростати» комбінаціями фільтрів
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Лише за статусом
    List<Product> findByStatus(ProductStatus status);

    // Статус + категорія
    List<Product> findByStatusAndCategory_Code(ProductStatus status, String categoryCode);

    // Статус + діапазон цін
    List<Product> findByStatusAndPriceBetween(ProductStatus status, BigDecimal min, BigDecimal max);

    // Статус + категорія + діапазон цін (і далі цей список зазвичай починає рости)
    List<Product> findByStatusAndCategory_CodeAndPriceBetween(
            ProductStatus status, String categoryCode, BigDecimal min, BigDecimal max);
}

Це ще не катастрофа. Але логіка тут дуже неприємна: репозиторій починає відображати комбінації параметрів, а не продумані сценарії читання.

У якийсь момент ви розумієте, що ви не проєктуєте API, а просто намагаєтеся встигати за хаотичним «вибухом варіантів». І це той момент, де derived queries починають уже не допомагати, а створювати шум.

5. Коли сервіс страждає від імен

Репозиторій — це не просто місце, де міститься доступ до даних. Це ще й частина мови проєкту: сервіси читаються через виклики репозиторіїв. Якщо репозиторій виражає сценарії акуратно, сервіс виглядає як текст: «отримали те-то, перевірили те-то, зробили те-то». Якщо репозиторій виражає формули відбору, сервіс перетворюється на набір «магічних заклинань».

Подивіться на різницю на рівні звичайного читання коду. Перший варіант: сервіс викликає метод із довгою назвою, і ви змушені читати формулу прямо в місці виклику.

// Виклик виглядає як «формула», і її доводиться читати прямо в сервісі
var products = productRepository
        .findByCategory_CodeAndCategory_ActiveTrueAndStatusAndPriceLessThanEqualAndNameContainingIgnoreCase(
                categoryCode, status, maxPrice, namePart);

Другий варіант: сервіс викликає метод, назва якого передає сценарій. Деталі відбору при цьому винесені в сам запит. Але навіть зараз, на рівні ідеї, різниця очевидна:

// Виклик виглядає як сценарій: «отримай товари каталогу»
var products = productRepository.findCatalogProducts(categoryCode, status, maxPrice, namePart);

У другому випадку сервіс читається по-людськи: «отримай товари каталогу». У першому — «отримай... е-е-е... зачекайте, я дочитаю цей метод до кінця». І ось це — реальна інженерна ціна. Не продуктивність, не SQL, а банально читабельність коду.

6. Ознаки: час на явний запит

Дуже хочеться мати одну магічну цифру на кшталт «після трьох умов завжди переходьте на JPQL». У реальності так не працює: іноді й пʼять умов читаються нормально, а іноді вже дві умови виглядають дивно, якщо вони йдуть за кількома звʼязками та вимагають хитрої логіки. Але практичні ознаки все ж є — їх можна сприймати як «інженерний тест на здоровий глузд».

Нижче — невелика таблиця, яка допомагає ухвалити рішення без філософії та релігії (derived queries vs ручні запити), а просто за ознаками підтримуваності:

Симптом у коді Що це означає Що зазвичай роблять далі
Назву методу важко прочитати вголос без паузи й дихальних вправ Метод почав зберігати формулу запиту замість сценарію Виносять формулу в явний текст запиту, а назву залишають про сценарій
У назві зʼявляються кілька переходів за звʼязками (Category, потім ще щось) Запит став «графовим» і погано виражається назвою Обирають запит, який можна красиво відформатувати та підтримувати
Репозиторій росте через комбінації варіантів (то так, то інакше) Починається комбінаторний вибух методів Намагаються описувати сценарії інакше, щоб не плодити методи
У code review доводиться розбирати метод по частинах Читабельність втрачена, підтримка дорожчає У команді домовляються про стиль: просте — derived, складне — явний запит
У сервісі виклик репозиторію виглядає як «рядок запиту без пробілів» Технічна деталь пролізла в бізнес-код Зсувають деталі відбору ближче до репозиторію та роблять їх читабельними

Тут важлива думка: «час» — це не про те, що derived queries неправильні. Це про те, що ваш репозиторій — теж продукт для читання людьми. І якщо люди перестають швидко розуміти код, то це вже технічний борг, навіть якщо все ідеально працює на локальній базі.

7. Гібридний підхід: derived + @Query

Дуже типова помилка новачка — побачити, що derived queries можуть стати незручними, і різко «переписати все» на щось інше. Це майже завжди погана ідея. Правильна стратегія дорослішання — гібридна: ви залишаєте derived queries там, де вони прекрасні, і перестаєте мучити їх там, де вони вже починають заважати.

Наприклад, findBySku залишається абсолютно нормальною, бо це коротко, ясно і відображає простий сценарій:

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

// Прості derived queries й надалі доречні та читабельні
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Сценарій «знайти товар за SKU» — коротко і зрозуміло
    Optional<Product> findBySku(String sku);
}

А от «каталожний фільтр», який уже містить кілька умов і переходів за звʼязками, можна виразити так, щоб назва була про сценарій, а деталі відбору жили окремо. Сьогодні нам важливо побачити сам підхід на рівні ідеї: у репозиторії зʼявляється метод із короткою назвою, а складність перестає жити в назві.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.util.List;

// Гібридний стиль: назва методу — про сценарій, а умови — в JPQL
public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQL (не SQL): працюємо з сутністю Product та її полями/зв’язками, а не з таблицями
    // Прив’язку параметрів краще робити явно, щоб запит не залежав від збігу назв аргументів
    @Query("select p from Product p where p.category.code = :code and p.status = :status and p.price <= :maxPrice")
    List<Product> findCatalogProducts(@Param("code") String code,
                                      @Param("status") ProductStatus status,
                                      @Param("maxPrice") BigDecimal maxPrice);
}

Тут нам важливіше інше: метод знову виглядає як API, а не як серіалізований запит. Параметри привʼязані явно через @Param, тому запит не залежить від збігу назв аргументів і налаштувань компіляції. Репозиторій стає читабельнішим, сервіс стає читабельнішим, і проєкт у цілому починає виглядати як система, а не як збірка «заклинань з імен методів».

8. Типові помилки під час роботи з derived queries

У цей момент часто виникають помилки не через брак знань, а через занадто прямолінійну реакцію на проблему: або ми терпимо незручність занадто довго, або починаємо лагодити все одразу, перетворюючи невеликий дискомфорт на великий рефакторинг. Давайте проговоримо найчастіші граблі, щоб ви наступали на них хоча б у зручному взутті.

Помилка №1: переписувати в «явні запити» взагалі все підряд, включно з простими випадками.
Коли ви один раз побачили, що довгі імена погані, зʼявляється бажання «зачистити» репозиторій і позбутися derived queries повністю. Зазвичай це призводить до зворотного ефекту: прості методи стають довшими, зникає зручність, і код починає виглядати важчим. Правильніше тримати derived queries як інструмент для коротких і очевидних сценаріїв і не соромитися використовувати їх там, де вони справді виграють.

Помилка №2: терпіти «методи-ковбаси» просто тому, що Spring Data так уміє.
Spring Data справді вміє багато, але «вміти» і «бути виправданим» — різні речі. Якщо метод читається гірше, ніж звичайний запит, значить ви платите читабельністю за автоматизацію. У реальних командах читабельність — не розкіш, а швидкість розробки: чим швидше людина розуміє код, тим менше випадкових багів.

Помилка №3: називати метод переліком умов замість назви сценарію.
Назва на кшталт findByCategory_CodeAndStatusAndPriceLessThanEqual чесно описує умови, але вона не описує сенс. Це часто призводить до того, що сервісний код починає «говорити мовою фільтрів», а не мовою бізнесу. Коли ви бачите, що назва методу стала формулою, корисно зупинитися і запитати: а як називається цей сценарій по-людськи?

Помилка №4: плодити схожі методи під комбінації параметрів, не помічаючи вибуху варіантів.
Перші чотири методи виглядають нормально, але далі починається снігова куля: завтра зʼявиться createdAt, потім manufacturer, потім «лише товари в наявності»… і репозиторій перетворюється на список варіацій. На ранньому етапі корисно хоча б помічати цей тренд: якщо ви почали писати «ще один метод майже такий самий, але з однією додатковою умовою», це вже сигнал, що derived queries починають виходити із зони комфорту.

Помилка №5: вважати, що проблема лише в репозиторії, а сервіс «якось переживе».
Насправді біль найчастіше видно саме в сервісі: там ви читаєте код як сценарій, і там довгі назви стріляють максимально неприємно. Якщо виклик репозиторію займає половину екрана, це не «гарно оформлений доступ до даних», а технічний шум усередині бізнес-логіки. Хороший репозиторій має допомагати сервісу бути читабельним, інакше ви отримаєте проєкт, який важко підтримувати навіть за правильних SQL-запитів.

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