1. Как выбирать query-инструмент
Когда derived query, JPQL, projection, native query и Specification уже знакомы по отдельности, появляется другая проблема: в одном репозитории они легко смешиваются без всякой логики. Тогда любой способ чтения данных кажется одинаково хорошим: «Ну работает же!». И это правда… до первого реального проекта (или до второго репозитория, который вы открыли через неделю и не узнали). В Spring Data JPA есть несколько параллельных дорог к одному результату: derived query, JPQL, projection, native query, Specification. Если не иметь простого правила выбора, вы неизбежно начнёте смешивать всё сразу — и репозиторий станет похож на кухню студента в конце сессии: формально еда есть, но жить там страшно.
Ключевая мысль этой лекции: выбор query-инструмента начинается не с того, «что я умею», и не с того, «что модно», а с того, какой use case мы закрываем и какую форму результата мы хотим получить. То есть мы сначала формулируем вопрос к данным, а уже потом выбираем, на каком языке этот вопрос задавать. Здесь нужен не новый синтаксис, а простое правило выбора.
Use case и форма результата важнее синтаксиса
Обычно ошибка новичка выглядит так: он сначала выбирает инструмент, а потом пытается «впихнуть» в него задачу. Получается как в жизни: купил дрель — и начинаешь думать, что всё в мире должно быть просверлено. В data-layer ровно то же самое: полюбил derived queries — начнёшь делать derived-монстров; выучил JPQL — начнёшь писать @Query на каждый чих; узнал native SQL — и внезапно Spring Data JPA превращается в «SQL в интерфейсах».
Более здоровый старт — задать себе два вопроса.
Первый вопрос: что именно я пытаюсь показать или вычислить? Например, «найти товар по SKU», «показать страницу каталога», «дать админский поиск с опциональными фильтрами», «построить отчёт по остаткам».
Второй вопрос: в каком виде я хочу вернуть результат? Иногда нужна полноценная Product как сущность (например, для команды изменения статуса). Иногда нужны 3–4 поля для списка (имя, цена, категория) — это уже прямой кандидат на projection. Иногда нужен только long (сколько активных товаров) — это вообще не история про чтение сущностей. И именно форма результата часто «сама» подталкивает вас к правильному инструменту.
Чтобы закрепить это в голове, давайте на примерах из mini-shop.
Если вам нужно «по SKU найти товар, чтобы изменить цену», то сущность — разумный выбор: вы действительно хотите Product. Если вам нужно «отрисовать каталог товаров», где показывается только name, price, categoryName, то тащить полноценную Product с половиной ненужных полей — странно: вы строите read-модель под список, а не готовитесь к записи.
И вот только после этого вы выбираете механизм: derived / JPQL / projection / native / Specification. Теперь разберём каждый инструмент «в его родной среде обитания».
2. Derived queries: имя метода как запрос
Derived query — это тот случай, когда Spring Data берёт имя метода, парсит его и сам генерирует запрос. В первые дни обучения это выглядит как магия уровня «я написал findByStatus и получил SQL». Но на самом деле магия здесь вполне инженерная: derived queries отлично работают, пока ваш запрос простой, условия стабильные, а имя метода остаётся читабельным.
В проекте shop-data-jpa derived-подход уместен для вещей вроде «найти товары по статусу», «найти товар по SKU», «посчитать количество товаров в статусе». Это короткие, понятные и повторяемые сценарии. Здесь важен именно принцип: derived query — про простую формулировку намерения, а не про попытку заставить имя метода стать полноценным языком запросов.
Пример «здорового» derived метода для каталога:
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query: Spring Data сам построит запрос по имени метода.
// SKU ожидается уникальным, поэтому 0..1 результат выражаем через Optional.
Optional<Product> findBySku(String sku);
}
Ещё один классический случай — когда нам нужен именно счётчик, а не список:
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Возвращаем скаляр (long), потому что use case — "сколько", а не "что именно".
long countByStatus(ProductStatus status);
}
Обратите внимание на психологический эффект: по одной сигнатуре видно и намерение, и форму результата. Это и есть «самодокументируемость», которой мы добиваемся.
Граница derived queries проявляется примерно там, где вы ловите себя на мысли: «Ну да, читается… если долго смотреть и не моргать». Как только имя метода становится сложнее самого запроса, derived перестаёт быть преимуществом. Это не «плохо», это просто сигнал: пора сменить инструмент.
3. JPQL и @Query: текст запроса вместо романа
JPQL (Java Persistence Query Language) — язык запросов по entity-модели, а не по таблицам. Он удобен, когда derived-метод начинает распухать, а вам нужно выразить условие явно. Здесь важно не превращать JPQL в «ещё одну магию»: это обычный текст запроса, просто он говорит на языке сущностей.
Главная причина выбрать JPQL — выразимость и читаемость. Да, это строка (и да, строки иногда ломаются при рефакторинге). Но в какой-то момент строка становится намного честнее и короче, чем derived-имя длиной в абзац.
Пример: нам нужен кусок каталога «активные товары с ценой от X», отсортированные по имени. В derived-стиле это уже может получиться не очень красиво, а JPQL выглядит понятно:
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;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Явный JPQL: читаем запрос как текст и не пытаемся "зашить" логику в имя метода.
// Именованные параметры проще читать и безопаснее поддерживать, чем позиционные.
@Query("""
select p
from Product p
where p.status = :status
and p.price >= :min
order by p.name
""")
List<Product> findCatalogChunk(@Param("status") ProductStatus status,
@Param("min") BigDecimal min);
}
Обратите внимание на два момента, которые стоит взять как привычку. Мы используем именованные параметры :status и :min, потому что это легче читать и сложнее случайно перепутать. Мы используем многострочную строку, чтобы запрос выглядел как нормальный текст, а не как «пельмень из кавычек».
Ещё один признак, что JPQL подходит: вам нужен join по ассоциации (например, фильтр по категории). Derived queries тоже умеют ходить по свойствам, но JPQL зачастую делает намерение гораздо яснее:
@Query("""
select p
from Product p
where p.category.id = :categoryId
and p.status = :status
""")
List<Product> findByCategoryAndStatus(
@Param("categoryId") Long categoryId, // Фильтрация по ассоциации (категория)
@Param("status") ProductStatus status // Доп. условие по статусу
);
Почему это «честнее»? Потому что читатель видит запрос глазами и не вынужден разбирать «лингвистику имени метода». И это особенно ценно, когда код живёт дольше недели (а он обычно так и делает, хитрец).
4. Projections: лёгкая read-модель
Projections — это способ сказать: «Мне не нужна целая сущность, отдайте мне только нужные поля». И это не каприз. В реальном backend-коде половина чтений — это списки и summary-вьюхи, где нужна лёгкая read-модель, а не полноценный объект, который потенциально тянет за собой лишние детали.
Важно: projection — это не DTO «для web-слоя». Мы сейчас находимся в data-layer курсе, и projection здесь — это инструмент read-модели на стороне backend. То есть: write-модель у нас часто entity-ориентированная, а read-модель — use-case-ориентированная.
Начнём с интерфейсной projection (самый простой вход). Для каталога нам может понадобиться «строчка списка»:
import java.math.BigDecimal;
public interface ProductCatalogRow {
// В интерфейсной projection методы-аксессоры должны совпадать с алиасами/именами полей.
Long getId();
String getName();
BigDecimal getPrice();
}
И теперь мы можем вернуть не Product, а ProductCatalogRow, причём даже на derived-методе:
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query + projection: фильтр простой, а форма результата — "лёгкая read-модель".
List<ProductCatalogRow> findByStatus(ProductStatus status);
}
С точки зрения читателя репозитория это очень аккуратно: фильтр простой, derived уместен, а форма результата уже «лёгкая». И вот здесь вы начинаете видеть, как инструменты комбинируются, а не конкурируют.
Если вам нужна более явная структура (и вы хотите получать её как нормальный Java-тип, а не как «интерфейс с геттерами»), можно использовать record-projection:
import java.math.BigDecimal;
// Record как read-модель: удобно, компактно, нельзя случайно "дописать сеттеры".
public record ProductPriceRow(Long id, BigDecimal price) {
}
И JPQL, который создаёт этот record через constructor expression:
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.id, p.price)
from Product p
where p.status = :status
order by p.id
""")
List<ProductPriceRow> findActivePrices(@Param("status") ProductStatus status); // Возвращаем именно read-модель, а не entity
Да, строка стала чуть длиннее из-за полного имени класса. Зато контракт абсолютно прозрачный: вы точно знаете, что вернётся, и не тащите лишнее.
Граница projections обычно там, где вы начинаете путать их с сущностями. Projection — это read-модель, она не предназначена для изменения и сохранения. Если вы поймали себя на желании сделать productCatalogRow.setPrice(...) — значит, вы эмоционально вернулись в DTO-мышление, и projection перестала выполнять свою роль.
5. Native query: когда SQL действительно нужен
Native query — это «говорим с базой на её родном языке», то есть на SQL. Это мощно. И именно поэтому опасно в качестве привычки. В здоровом проекте native query — точечный инструмент для случаев, когда JPQL становится неудобной, либо когда вы делаете отчёт, где SQL-мышление естественнее.
В нашем mini-shop классический оправданный пример — отчёт по низким остаткам (low-stock report). Он часто выглядит как табличка из пары полей, и писать это на SQL может быть действительно проще и понятнее.
Сделаем projection для отчёта:
public interface LowStockRow {
// В native query особенно важно, чтобы имена совпадали с алиасами (см. запрос ниже).
String getSku();
Integer getAvailableQuantity();
}
И native query в репозитории остатков:
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface StockItemReportRepository extends Repository<StockItem, Long> {
// Native SQL: используем, когда "SQL-first" читается проще, чем JPQL.
// Алиасы (as sku / as availableQuantity) делают маппинг в projection предсказуемым.
@Query(value = """
select p.sku as sku, si.available_quantity as availableQuantity
from stock_item si
join product p on p.id = si.product_id
where si.available_quantity < :threshold
order by si.available_quantity asc
""", nativeQuery = true)
List<LowStockRow> findLowStock(int threshold); // threshold — входной параметр отчёта
}
Обратите внимание на две дисциплины. Во-первых, мы возвращаем не сущность, а report-projection: это подчёркивает отчётный характер чтения. Во-вторых, мы используем алиасы as sku и as availableQuantity, чтобы маппинг был предсказуемым. В native SQL «дисциплина алиасов» — это не эстетика, а способ не ловить сюрпризы.
Native query особенно легко превращается в «завод по производству техдолга», если вы начинаете писать на SQL всё подряд. Тогда Spring Data JPA у вас остаётся только в названии зависимостей, а код переезжает в «строки в интерфейсах». Поэтому правило простое: native query — когда действительно есть причина, а не когда «я так привык».
6. Specification: динамические фильтры
Specification — это ответ на типичную боль «админского поиска», когда фильтров много, и каждый из них может быть указан или не указан. В derived-мире вы бы получили комбинаторный взрыв: findByStatusAndCategoryAndMinPriceAndMaxPriceAndNameContaining и так далее. В JPQL-мире вы бы получили очень длинный запрос с кучей (:param is null or ...). Работает, но читаемость начинает страдать.
Specification позволяет собирать условия как конструктор, аккуратно добавляя их только тогда, когда параметр реально задан. Это не значит, что Specification нужно использовать всегда. Это значит, что у Specification есть своя «родная среда обитания»: динамические фильтры.
В проекте это обычно выглядит так. Сначала мы описываем фильтр как простой record:
import java.math.BigDecimal;
// Входные параметры поиска: каждое поле может быть null (то есть фильтр не задан).
public record ProductAdminFilter(
ProductStatus status,
Long categoryId,
BigDecimal minPrice,
BigDecimal maxPrice,
String text
) {
}
Потом делаем набор «кирпичиков» спецификаций:
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;
public final class ProductSpecs {
private ProductSpecs() {
// Утилитный класс: инстансы не нужны
}
public static Specification<Product> status(ProductStatus status) {
// Спецификация "статус равен": используем только если status != null (см. сервис ниже).
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Product> priceGte(BigDecimal min) {
// Спецификация "цена >= min": тоже применяем только при непустом параметре.
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("price"), min);
}
}
И в сервисе (или query-сервисе) собираем запрос из тех условий, которые реально пришли:
import org.springframework.data.jpa.domain.Specification;
public class ProductAdminSearchService {
public Specification<Product> buildSpec(ProductAdminFilter filter) {
// Стартовая точка: "пустая" спецификация без условий
Specification<Product> spec = Specification.where(null);
// Добавляем условия только когда параметр действительно задан
if (filter.status() != null) spec = spec.and(ProductSpecs.status(filter.status()));
if (filter.minPrice() != null) spec = spec.and(ProductSpecs.priceGte(filter.minPrice()));
return spec;
}
}
Здесь мы не обсуждаем тонкости API и не строим «универсальный конструктор запросов на все случаи жизни». Идея проще: Specification должна оставаться прагматичным инструментом. Если у вас два стабильных условия — derived или JPQL будут проще. Если у вас «пять фильтров, и любой может быть пустым» — Specification становится очень уместной.
7. Шпаргалка выбора инструмента
В голове новичка всё это может звучать как «ещё пять способов всё усложнить». Поэтому нам нужен не энциклопедический обзор, а простая схема выбора. Представьте, что вы в мастерской: у вас есть отвёртка, ключ, плоскогубцы, паяльник и молоток. Можно, конечно, открыть банку краски молотком. Но лучше всё-таки открыть её открывашкой. В data-layer ровно так же.
Сначала — табличка, как «шпаргалка выбора». Не как догма, а как ориентир.
| Инструмент | Когда брать | Что обычно возвращаем | Главный плюс | Главный минус |
|---|---|---|---|---|
| Derived query | Фильтр простой, условия стабильные, имя метода короткое и читаемое | Optional, List, иногда projection или long | Минимум кода, хороший intent | Не масштабируется на сложные кейсы |
| JPQL (@Query) | Имя метода становится монстром или нужен явный запрос/join/логика | List, projection, агрегаты | Читаемость запроса, выразимость | Строка запроса требует дисциплины |
| Projection | Для списков/summary, где нужна часть полей | interface/record projection | Читаете ровно то, что нужно use case | Нельзя путать с сущностью и «редактировать» |
| Native query | Отчёты, vendor-специфика, сложный SQL-read | projection/скаляры | Максимальная свобода SQL | Цена сопровождения, SQL-дисциплина |
| Specification | Много опциональных фильтров, динамическая композиция | обычно List/paged result, часто + projection | Нет комбинаторного взрыва методов | Лишняя сложность для простых кейсов |
Теперь — мини-алгоритм, который реально можно применять в голове. Он специально короткий и «по-человечески».
flowchart TD
A["Есть read use case"] --> B{"Нужна вся сущность?"}
B -->|Да| C{"Фильтр простой и стабилен?"}
B -->|Нет, нужна часть полей| D["Projection"]
C -->|Да| E["Derived query"]
C -->|Нет| F{"Много опциональных фильтров?"}
F -->|Да| G["Specification"]
F -->|Нет| H{"Нужен SQL-first отчет/специфика БД?"}
H -->|Да| I["Native query"]
H -->|Нет| J["JPQL @Query"]
Обратите внимание: projection стоит очень близко к началу. Это специально. Частая «детская болезнь» — читать сущность по умолчанию всегда. А реальный мир часто хочет «вьюшки», то есть лёгкие read-модели.
8. Примеры выбора в mini-shop
Чтобы правило не осталось на уровне «красивой схемы», проговорим несколько сценариев в стиле «что выбрать и почему», прямо по домену mini-shop.
Если бизнес-вопрос звучит как «найти товар по SKU» — это практически идеальный derived query. Он короткий, понятный, и вы ожидаете 0 или 1 результат. Тут нет смысла писать JPQL: derived выглядит чище.
Если вопрос звучит как «показать страницу каталога, где в строке товара нужно только name и price» — это кандидат на projection. И вот здесь у вас два варианта: либо derived + projection (если фильтр простой), либо JPQL + projection (если фильтр сложнее). Смысл один: вы подстраиваете read-модель под список.
Если вопрос звучит как «админский поиск: статус опциональный, категория опциональная, диапазон цены опциональный, текст опциональный» — тут Specification очень быстро выигрывает у derived (слишком много комбинаций) и у JPQL (слишком много if param is null). Вы описываете каждое условие один раз, и собираете запрос так, как вы собираете лего.
Если вопрос звучит как «отчёт по низким остаткам, нужен join таблиц и особая сортировка/условия, которые проще выразить SQL» — native query становится нормальным выбором. Но это именно отчётный, локальный сценарий. Вы не переезжаете всей архитектурой в SQL-first подход.
И, наконец, если вопрос звучит как «нужна сложная логика, но при этом это всё ещё чтение по entity-модели, без необходимости SQL-специфики» — JPQL остаётся золотой серединой. Он позволяет явно выразить запрос, но оставляет вас в мире entity-модели (а значит, вы меньше зависите от схемных деталей таблиц).
Вся эта картина важна ещё и потому, что она защищает репозиторий от «стилистического зоопарка». Если команда договорилась, что простые фильтры делаем derived, сложные — JPQL, списки — projection, динамика — Specification, отчёты — native, то репозитории становятся предсказуемыми. Предсказуемость — это суперсила, которую почему-то редко ценят в начале пути. А потом очень ценят. Иногда даже плачут.
И как только эта матрица становится понятной, следующая типичная путаница возникает уже не в выборе между @Query и projection. Она возникает в самих базовых глаголах репозитория: где код хотел прочитать сущность, где — взять ссылку, где — просто проверить существование.
9. Типичные ошибки при выборе query-инструмента
Когда в вашем распоряжении пять способов сделать чтение данных, рука сама тянется сделать «как получится». Это нормально: мозг ленив, и ему хочется повторять то, что уже сработало. Но у этих привычек есть характерные поломки.
Ошибка №1: «Я всегда возвращаю entity, потому что так проще».
Сначала это правда проще, а потом вы внезапно начинаете читать много данных там, где нужен короткий список. Код распухает, контракт чтения размывается, и ваш read-side перестаёт быть use-case-ориентированным. Привычка думать о projection как о нормальной read-модели лечит это почти сразу.
Ошибка №2: derived query как “универсальный язык запросов”.
Когда вы превращаете имена методов в романы, вы платите не производительностью, а поддерживаемостью. Самое обидное, что через месяц вы уже сами с трудом разбираете, что там написано, а IDE превращается в «помощника-переводчика с вашего же языка на человеческий». Как только имя перестало читаться — это не “ещё чуть-чуть потерпеть”, это сигнал сменить инструмент.
Ошибка №3: JPQL на любой чих «потому что я научился @Query».
JPQL полезен, но это строка, а значит она требует дисциплины: именованные параметры, читаемое форматирование, понятное намерение. Писать @Query там, где derived был бы проще, — это тоже форма техдолга, просто более «интеллигентная».
Ошибка №4: Native query как стиль по умолчанию.
Native SQL — инструмент, который требует реальной SQL-ответственности. Как только вы начинаете массово писать native queries для обычных чтений, вы незаметно превращаете Spring Data JPA в дорогую обёртку вокруг строк SQL. Это редко то, чего вы хотите в приложении, где ORM выбран как базовый подход.
Ошибка №5: Specification ради одного фильтра.
Specification — это про динамику и композицию. Если у вас один-два стабильных параметра, Specification будет выглядеть как «я построил космический корабль, чтобы сходить за хлебом». Работает, но вызывает вопросы у окружающих. Лучше держать Specification как инструмент для тех мест, где она действительно спасает от комбинаторного взрыва.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ