1. Query-репозитории вместо findAll()
Со стандартными CRUD-методами уже видно, что короткое имя репозиторного метода вообще не гарантирует простой семантики: save() решает, идти в persist или merge, getReferenceById() может вообще не читать данные сразу, delete бывает lifecycle- и bulk-подобным. На query-side магия не исчезает — она просто прячется уже не в state-модели сущности, а в форме SQL, ширине результата и цене count-запроса.
Если вы когда-нибудь писали админку или backoffice (а наш Commerce Persistence Lab как раз про это), то знаете боль: сначала нужен список товаров «просто показать», потом — «показать только активные», потом — «поиск по SKU», потом — «сортировка», потом — «страницы», потом — «чтобы не тормозило». И вот в этот момент findAll() начинает звучать как план из серии «я буду питаться воздухом и солнечным светом» — благородно, но долго не протянешь.
Проблема findAll() не в том, что он плох. Проблема в том, что он безличный. Он ничего не говорит о целях чтения. А в persistence layer цель чтения определяет всё: ширину SELECT, форму JOIN, наличие/отсутствие lazy-инициализаций, стоимость count-запроса, и даже flush-поведение (потому что любой query в транзакции может триггернуть flush в режиме AUTO).
В нашем проекте типичный «список товаров» для админки — это не «дать мне Product со всем графом». Это обычно небольшой набор колонок: sku, name, status, иногда цена. А «карточка товара» — это уже другой read use case: там может понадобиться ProductDetails, категории, и так далее. Ровно поэтому в прошлых днях мы постоянно повторяли мысль: entity — это write-model, а read-model часто должен быть тоньше.
Удобный mental model: представьте, что репозиторий — это не «кладовщик, который тащит вам весь склад», а «официант». Хороший официант уточняет, что вы хотите: кофе, обед или банкет. Плохой официант приносит вам всё меню, кухню и повара, потому что «ну вы же клиент». В ORM-мире «принести всё» часто означает лишний SQL, лишние данные и лишние проблемы.
Чтобы не строить каждый раз запрос заново вручную, Spring Data даёт нам набор инструментов под query-слой: JpaSpecificationExecutor для динамических фильтров, projections для тонкой модели результата и paging (Pageable) для списков. Давайте разбирать их по одному, но держать в голове одну цель: контракт репозитория должен быть честным про форму чтения.
2. Specification: динамические фильтры
Когда вы впервые добавляете фильтры к списку, рука сама тянется к коду вида «если фильтр задан — добавим условие». И это нормально… ровно до тех пор, пока фильтров не становится много. Потом код превращается в макаронину: вроде вкусно, но разбирать вилкой неудобно. Specification — это способ сказать: «фильтр — это объект, а не кусок случайного if».
Технически Specification<T> — это обёртка вокруг Criteria API: функция, которая на вход получает root, query, criteriaBuilder, а на выход отдаёт Predicate. То есть буквально «кусочек WHERE», который можно комбинировать с другими кусочками через and()/or(). И это удобно именно для backoffice-поиска: сегодня фильтр по статусу и тексту, завтра добавили фильтр по категории, послезавтра по цене, и при этом не переписываем всё с нуля.
Начинается всё с того, что репозиторий должен уметь исполнять спецификации.
package com.example.commerce.catalog.repository;
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
// JpaSpecificationExecutor добавляет findAll(spec) и findAll(spec, pageable)
// То есть репозиторий начинает "понимать" динамические фильтры.
}
С этого момента у вас появляется возможность писать findAll(spec) и findAll(spec, pageable) — то есть передавать фильтр как объект.
Теперь сделаем маленькую «библиотеку фильтров» под наш Product. Обычно её кладут в catalog.query или рядом (в зависимости от того, как вы у себя организуете код), но сейчас важнее сама техника.
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecifications {
public static Specification<Product> hasStatus(ProductStatus status) {
// Важно: это НЕ выполнение запроса, а только "кусочек WHERE"
// Репозиторий позже превратит это в SQL.
return (root, query, cb) ->
// status = :status
cb.equal(root.get("status"), status);
}
}
Обратите внимание на важную практическую деталь: Specification — это не «выполнить запрос». Это «описать условие». SQL появится только когда репозиторий реально выполнит query. И вот тут у Hibernate включается вся его знакомая механика: если вы сидите в транзакции, если flush mode AUTO, если у вас есть pending changes — перед выполнением query может произойти flush, чтобы запрос видел консистентные данные.
Теперь добавим текстовый фильтр. В админке обычно ищут «по кусочку SKU» или «по кусочку имени». Мы сделаем простой вариант LIKE, без отдельного курса по полнотекстовому поиску (нам его здесь не надо).
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecifications {
public static Specification<Product> nameContains(String text) {
return (root, query, cb) ->
// Простейший вариант: lower(name) like '%text%'
// Для продакшена часто добавляют нормализацию/экранирование и отдельные индексы,
// но для админского "кусочного" поиска этого обычно достаточно.
cb.like(
cb.lower(root.get("name")),
"%" + text.toLowerCase() + "%"
);
}
}
Комбинировать фильтры можно так (и это выглядит заметно аккуратнее, чем цепочка if в сервисе):
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.catalog.query.ProductSpecifications;
import org.springframework.data.jpa.domain.Specification;
Specification<Product> spec = Specification.where(ProductSpecifications.hasStatus(ProductStatus.ACTIVE))
.and(ProductSpecifications.nameContains("usb"));
И теперь репозиторий может выполнить запрос:
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<Product> page = productRepository.findAll(spec, pageable);
И вот здесь мы подходим к важному месту: findAll(spec, pageable) возвращает entity. Это нормально, но для списков часто не идеально. С entity вы получаете managed-объекты, которые потенциально могут участвовать в dirty checking (даже если вы их не меняете) и могут тянуть за собой граф. В модуле про projections мы как раз будем делать результат тоньше.
Есть ещё одна практическая тонкость: спецификации удобны, пока вы держите их простыми. Если вы начинаете в спецификациях делать сложные join’ы и подзапросы ради отчётов, это часто означает, что вы приближаетесь к границе «может быть, лучше native SQL». Но в админском поиске по сущности (наш обычный backoffice use case) спецификации — очень рабочий инструмент.
3. Projections: читаем только нужное
Списки в админке — это место, где очень легко «случайно» начать читать слишком много данных. Особенно когда кажется, что «ну это же ORM, он разберётся». ORM разберётся… и честно принесёт вам то, что вы попросили, а вы потом честно будете разбираться, почему это грузится долго. Projections — это способ заранее поставить рамку: «для этого чтения мне нужны только эти поля».
В Spring Data есть два самых практичных типа проекций: interface-based и class-based (DTO/record). Оба варианта позволяют вернуть не Product, а «снимок» нужных полей. Такой результат обычно не становится managed entity, не участвует в dirty checking и не провоцирует lazy loading по навигации графа (потому что графа как сущности уже нет).
Начнём с самого простого: интерфейс для строки списка товаров.
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.entity.ProductStatus;
public interface ProductSummary {
String getSku();
String getName();
ProductStatus getStatus();
}
Теперь можно попросить репозиторий отдавать Page<ProductSummary> вместо Page<Product>. Для derived queries это выглядит удивительно «как будто так и было задумано»:
package com.example.commerce.catalog.repository;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.catalog.query.ProductSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<ProductSummary> findByStatus(ProductStatus status, Pageable pageable);
}
Здесь важная мысль не в синтаксисе, а в контракте. Сигнатура уже говорит вам: «это метод для чтения списка, возвращает summary, работает постранично». Вызывающий код не тащит в себя Product и не получает искушение случайно начать дергать product.getDetails().getWarrantyMonths() в цикле.
Теперь про class-based projection. В Java 25 вполне приятно использовать record как DTO-результат:
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.entity.ProductStatus;
public record ProductSummaryRow(
String sku,
String name,
ProductStatus status
) { }
Для record-проекций часто удобно использовать явный @Query, с constructor expression, чтобы результат был максимально предсказуемым. Тогда вы буквально управляете тем, что окажется в SELECT.
import com.example.commerce.catalog.query.ProductSummaryRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
@Query("""
select new com.example.commerce.catalog.query.ProductSummaryRow(
p.sku, p.name, p.status
)
from Product p
where p.status = :status
""")
Page<ProductSummaryRow> loadRowsByStatus(ProductStatus status, Pageable pageable);
Интерфейс-проекция проще и чаще «просто работает» для типичных derived queries. DTO/record-проекция даёт больше контроля и обычно лучше читается на code review, потому что запрос явно показывает, что читаем.
Нужно ещё одно важное предупреждение, которое часто ломает ожидания новичка: projection не является «волшебным антипаттерном от всех бед». Она уменьшает ширину результата, но если вы начнёте в projection вытаскивать вложенные куски графа (например, «а давайте ещё категории»), то SQL может стать join-heavy. Иногда это нормально, иногда нет. Главное — снова держать принцип курса: смотрим на SQL и понимаем цену запроса, а не верим в магию аннотаций.
Ниже маленькая таблица, чтобы в голове не смешивались сущности и проекции:
| Что возвращаем из репозитория | Что это по смыслу | Плюс | Минус |
|---|---|---|---|
| Product | write-модель, managed (если в транзакции) | удобно менять и сохранять через dirty checking | легко случайно притащить граф/overfetch/dirty checking overhead |
| ProductSummary | read-модель (тонкая) | быстрый старт, короткая сигнатура, читаемый контракт | меньше контроля над формой запроса, нужно следить за nested полями |
| ProductSummaryRow | read-модель (тонкая) | максимальная предсказуемость, удобно для отчётных списков | чаще нужен @Query, чуть больше кода |
4. Paging: Page и Slice
Постраничное чтение — это одна из самых полезных привычек в backend-коде, потому что оно одновременно решает две проблемы: вы не тащите в память тысячи строк, и вы не заставляете базу формировать огромный result set «на всякий случай». Pageable в Spring Data — это как договор с базой: «дай мне ровно 20 строк, начиная с такой-то позиции, и отсортируй вот так».
В коде paging выглядит почти скучно, и это хорошо. Скучный код — часто самый надёжный.
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
Pageable pageable = PageRequest.of(
0, 20,
Sort.by("sku").ascending()
);
Теперь любой paging-метод репозитория может вернуть Page<T>. Но у Page есть особенность: чтобы посчитать totalElements и totalPages, Spring Data обычно выполняет второй запрос — count(*). То есть «страница» часто означает два SQL-запроса: один за данными, другой за количеством.
Это поведение в целом логично, но иногда дорого. Поэтому стоит различать Page и Slice. Slice — это «дай мне ещё одну порцию данных и скажи, есть ли следующая». Он обычно не требует count-запроса, поэтому дешевле, если вам не нужно знать «всего страниц 123».
Небольшая табличка для ориентировки:
| Возвращаемый тип | Что умеет | Цена |
|---|---|---|
| Page<T> | данные + общее количество + общее число страниц | часто 2 запроса (content + count) |
| Slice<T> | данные + hasNext() | обычно 1 запрос (без count) |
В админке часто нужен именно Page, потому что UI любит рисовать «страницы 1…10…последняя». Но в некоторых сценариях (например, «покажи последние события» или «лента») Slice может быть удобнее.
Важный практический нюанс: paging почти всегда требует стабильной сортировки. Если сортировки нет, или сортировка не уникальная, то при изменениях в таблице между запросами вы можете получать «прыгающие» результаты: одна и та же запись то на первой странице, то на второй. В реальных системах это превращается в «у нас пропадают товары из списка, наверное, база сломалась». База не сломалась, просто сортировка была «как получится».
Поэтому хорошая привычка: сортировать по чему-то устойчивому. Для Product таким кандидатом часто становится sku (у нас он уникален) или id. В Commerce Persistence Lab мы как раз держим уникальные ограничения на ключевые business keys (sku, email, orderNumber), и это помогает в таких вещах.
И ещё одна инженерная мысль, которую стоит держать рядом: сортировка и фильтры — это не только про Java-код. Это ещё и про индексы. Если вы сортируете по sku и фильтруете по status, логично иметь индекс под такие запросы. Мы подробно обсуждали индексы в mapping-модуле, так что сейчас просто связываем точки: paging — это не магия Spring Data, это соглашение «limit/offset + order by» на стороне SQL.
5. Сценарий: spec + projection + page
Давайте теперь соберём всё в один понятный сценарий из нашего домена: «админка каталога показывает список товаров, умеет фильтровать по статусу и по тексту, и делает это постранично, возвращая не entity, а summary-представление». Это очень типичный read use case, и он идеально показывает, зачем нам Specification + projection + paging одновременно.
Начнём с простого объекта критериев. В Java 25 для такого удобно использовать record. Он не делает никакой логики, просто переносит параметры.
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.entity.ProductStatus;
public record ProductSearchCriteria(
// По какому статусу фильтруем (если null — фильтра по статусу нет)
ProductStatus status,
// Текст для поиска (если null/blank — фильтра по тексту нет)
String text
) { }
Теперь собираем Specification из критериев. Смысл здесь в том, чтобы if’ы жили в одном месте и не размазывались по сервису.
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecFactory {
public static Specification<Product> from(ProductSearchCriteria c) {
// Пустая спецификация, к которой дальше "приклеиваем" условия
Specification<Product> spec = Specification.where(null);
// Добавляем фильтры только если параметры реально заданы
if (c.status() != null) spec = spec.and(ProductSpecifications.hasStatus(c.status()));
if (c.text() != null && !c.text().isBlank()) spec = spec.and(ProductSpecifications.nameContains(c.text()));
return spec;
}
}
Да, тут есть if. Но это «контролируемый if», который собирает объект фильтра. Он не превращает бизнес-сервис в условный суп.
Теперь ключевой момент: как получить projection + page на основании specification. Если мы просто сделаем findAll(spec, pageable), мы получим entity. А нам хочется тонкий результат. Для этого в Spring Data удобно использовать fluent query API через findBy(spec, queryFunction).
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.query.ProductSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<ProductSummary> page = productRepository.findBy(
spec,
q -> q.as(ProductSummary.class) // говорим: хотим projection, а не entity
.page(pageable) // говорим: хотим Page (обычно будет и content-query, и count-query)
);
В этом куске кода мне нравится то, что он честный. Мы явно говорим: «выполни фильтр spec, но результат отдай как ProductSummary, и сделай это постранично». Это намного ближе к мысли «репозиторий проектируется от чтения», чем «ну верни entity, а я там потом map() сделаю».
Если визуально, весь пайплайн выглядит так:
flowchart TD
A["ProductSearchCriteria"] --> B["Specification<Product>"]
B --> C["Repository.findBy(spec, ...)"]
C --> D["SQL SELECT ... LIMIT/OFFSET"]
C --> E["SQL SELECT COUNT(*)"]
D --> F["Page<ProductSummary>"]
E --> F
Почему тут два SQL? Потому что мы получили Page, а Page обычно хочет общее количество.
Очень полезно хотя бы один раз увидеть, как это выглядит в SQL (приблизительно). Для PostgreSQL и типичного paging это будет что-то в духе:
-- content query
select p.sku, p.name, p.status
from product p
where p.status = 'ACTIVE'
and lower(p.name) like '%usb%'
order by p.sku asc
limit 20 offset 0;
-- count query
select count(*)
from product p
where p.status = 'ACTIVE'
and lower(p.name) like '%usb%';
Это именно та точка, где deep-dive мышление становится практичным. Вы больше не воспринимаете репозиторный метод как «вызов Java». Вы видите: ага, у нас два запроса, один с limit/offset, один count. И дальше уже инженерные вопросы: «нормально ли это для админки», «есть ли индекс», «нет ли лишних join’ов», «не делаем ли мы flush перед чтением», и так далее. Вы начинаете разговаривать с системой на её родном языке — SQL. И здесь же хорошо видно, почему один универсальный ProductRepository быстро начинает скрывать два разных режима работы: write-side говорит языком managed-сущностей, а query-side — языком filters, projections и page-contract.
И ещё маленькая ремарка про transaction boundary. Чтение списка в админке обычно живёт в @Transactional(readOnly = true). Во-первых, так проще держать предсказуемость Hibernate-сессии. Во-вторых, это снижает риск «случайных» flush’ей и write-side эффектов (хотя совсем это не отменяет, если вы внутри транзакции всё же меняете managed-entity). То есть read use case и репозиторные запросы лучше делать «на чистых руках»: читаем — значит читаем.
6. Типичные ошибки: Specification, projection, paging
Самое неприятное в query-слое — то, что он легко начинает «казаться работающим», пока не придёт реальная нагрузка или реальный набор данных. Ошибки тут часто не компиляционные, а смысловые: код запускается, результаты «вроде те», но цена запроса внезапно становится неподъёмной или поведение оказывается непредсказуемым. Ниже — самые частые грабли, на которые наступают почти все.
Ошибка №1: делать универсальный метод findAll() и потом фильтровать в Java.
Это выглядит как «я же программист, я сейчас stream().filter() сделаю». Но это почти всегда перенос тяжёлой работы из базы в приложение, плюс вы всё равно сначала тащите данные из БД. В итоге база делает бесполезный SELECT *, сеть таскает лишнее, JVM греется, а вы получаете «медленно, но зато на Java». Правильнее фильтровать на стороне SQL через Specification или явный query.
Ошибка №2: возвращать entity для списков “потому что так проще”, а потом случайно провоцировать граф и dirty checking.
Вы загрузили Page<Product>, потом где-то в UI-слое или сервисе начали форматировать вывод и «случайно» полезли в ленивую связь или поменяли поле (даже неосознанно). Hibernate честно начал работать: lazy догрузил, dirty checking увидел изменения, flush отправил UPDATE. И вот вы уже лечите «почему список товаров внезапно делает лишние запросы». Для списка лучше сразу вернуть projection и не давать коду соблазна обращаться к сущности как к универсальной модели.
Ошибка №3: не учитывать, что Page почти всегда означает count(*), и удивляться двойному SQL.
Очень частая реакция: «почему у меня два запроса на одну страницу?». Потому что Page обещает totalElements. Если total не нужен — Slice может быть дешевле. Если total нужен — тогда два запроса нормальны, просто вы должны помнить об этом при анализе SQL и при оценке стоимости сценария.
Ошибка №4: paging без устойчивой сортировки и “прыгающие страницы”.
Сегодня у вас товар на первой странице, завтра — на второй, и пользователи уверены, что «он исчез». Обычно причина — сортировка по неуникальному полю или отсутствие сортировки. Paging без order by почти всегда превращается в лотерею: база не обязана возвращать строки в одном и том же порядке.
Ошибка №5: в Specification добавлять сложные join’ы без осознания, что вы меняете форму запроса.
Сама по себе спецификация — это «кусочек WHERE», но она может втянуть в запрос join, distinct, группировки, а дальше count query становится сложнее и дороже. Это не «запрещено», но это тот случай, когда нужно особенно дисциплинированно смотреть SQL и понимать, что ваш «маленький фильтр по категории» на самом деле превращается в join по link entity.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ