1. Когда нужна Specification вместо Criteria API
Мы уже собрали dynamic where через Criteria API. Specification не открывает новый язык запросов и не решает другую SQL-задачу. Это repository-friendly форма той же идеи: те же criteria-предикаты, только упакованные так, чтобы Spring Data репозиторий мог их комбинировать и исполнять.
Если Criteria API уже умеет собирать запрос без строковой склейки, логичный вопрос звучит так: «а что нам ещё надо?». Ответ очень житейский: нам нужно не просто собрать один запрос, а сделать так, чтобы фильтры не размножались копипастой по всему проекту. И чтобы через месяц вы сами понимали, почему «поиск товаров» в одном месте фильтрует по статусу, а в другом — почему-то нет.
Основной ориентир всё тот же — backoffice-поиск заказов. Но механику composable filters проще сначала показать на товарах: там меньше шума, и лучше видно, как один фильтр превращается в маленький переиспользуемый кирпичик.
Представьте типичный backoffice-кейс в Commerce Persistence Lab: «покажи товары, где статус = ACTIVE, имя содержит "pro", и sku начинается с "SKU-"». Сегодня так, завтра добавится фильтр по диапазону цены, послезавтра — «только товары, которые встречаются в заказах за последнюю неделю» (да, звучит как боль — и это нормально). Если вы пишете Criteria-запрос прямо в сервисе, вы быстро получаете длинный метод, где половина строк — это if (...) filters.add(...).
Specification — это способ сделать фильтры именованными, переиспользуемыми и комбинируемыми. В идеале вы хотите, чтобы сервис собирал запрос на уровне смысла: «есть статус», «имя начинается с», «клиент с таким email». А техническая механика Criteria остаётся внутри маленьких функций.
Чтобы почувствовать разницу, можно вспомнить «наивный» стиль. Не делайте так в проде (и вообще не делайте), но как антипример он полезен:
import java.util.ArrayList;
import java.util.List;
public class AntiExample {
// Антипример: так обычно начинается «динамический запрос на коленке»
public void pleaseNo() {
// Здесь люди часто начинают собирать where-условия строками или раздувать метод if'ами
List<String> where = new ArrayList<>();
// где-то тут вы начнёте склеивать строку JPQL или городить 30 if'ов в Criteria
// а потом добавится ещё один фильтр, и вы проклянёте эту строчку
}
}
Specification помогает не потому, что она «быстрее» или «умнее». Она помогает потому, что она делает код сопровождаемым: фильтры становятся маленькими кирпичиками, а сборка запроса — аккуратной композицией.
2. Specification<T>: идея и устройство
Слово Specification звучит как что-то из мира «архитектурных комитетов и больших презентаций», но на практике это очень приземлённая штука. Specification<T> — это функциональный интерфейс, который умеет построить Predicate (условие) для Criteria-запроса. То есть это буквально «кусочек where», который вы можете складывать с другими кусочками через and() и or().
В Spring Data JPA это выглядит так: спецификация получает три параметра — Root<T>, CriteriaQuery<?> и CriteriaBuilder. Это те же герои, которые мы уже видели в Criteria API. Просто вместо того, чтобы писать всё в одном месте, вы упаковываете отдельный фильтр в отдельную функцию.
Минимальный пример спецификации для Product может быть вот таким:
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public class SpecExample {
public static Specification<Product> skuEquals(String sku) {
// Возвращаем спецификацию: на вход Criteria-объекты, на выход Predicate для WHERE
return (root, query, cb) -> cb.equal(root.get("sku"), sku);
}
}
Важный нюанс для реального проекта: фильтр почти всегда опциональный. И тут появляется приятная «фишка» Specification: спецификация (или её Predicate) может быть null. Spring Data при композиции умеет трактовать это как «фильтра нет, пропускаем». Поэтому в живом коде вы почти всегда будете писать так, чтобы при null/blank входных параметрах спецификация возвращала null.
Иногда вместо null возвращают cb.conjunction(). По смыслу это то же самое «фильтра нет», просто через нейтральный predicate. Важно не воспринимать это как другую модель Specification: это лишь два разных способа выразить отсутствие условия.
Вот уже более жизненная версия:
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public class ProductSpecs {
public static Specification<Product> skuEquals(String sku) {
// Если фильтр не задан — возвращаем null, Spring Data «пропустит» его при композиции
if (sku == null || sku.isBlank()) {
return null;
}
// Иначе строим условие: product.sku = :sku
return (root, query, cb) -> cb.equal(root.get("sku"), sku);
}
}
И да, в этом месте можно позволить себе лёгкую самоиронию: Specification — это когда вы наконец перестаёте собирать запрос как бутерброд из строк, скотча и надежды, и начинаете собирать его как нормальный человек — из деталей, каждая из которых делает одну вещь.
3. JpaSpecificationExecutor в репозитории
В Spring Data JPA сама по себе спецификация — это просто объект. Чтобы она стала практичной, репозиторий должен уметь её исполнять. Для этого репозиторий расширяют не только JpaRepository, но и JpaSpecificationExecutor. Тогда появляются методы вроде findAll(spec), findAll(spec, pageable), count(spec) и даже exists(spec).
В нашем проекте это выглядит максимально просто. Например, для каталога:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import com.example.commerce.catalog.entity.Product;
public interface ProductRepository
// JpaSpecificationExecutor добавляет методы findAll(spec), count(spec) и т.п.
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
И для заказов:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import com.example.commerce.orders.entity.PurchaseOrder;
public interface PurchaseOrderRepository
// Та же идея: теперь репозиторий умеет выполнять спецификации
extends JpaRepository<PurchaseOrder, Long>, JpaSpecificationExecutor<PurchaseOrder> {
}
После этого у вас появляется ровно то, ради чего всё затевалось: возможность описать фильтры отдельно (в ...Specifications классе), а в сервисе сделать читаемую композицию. Это особенно полезно в лабораторных сценариях курса, где мы постоянно сравниваем «как выглядит SQL» и «почему запросов стало больше/меньше», и нам важно быстро менять фильтры, не переписывая запросы вручную.
И ещё одна дисциплина: JpaSpecificationExecutor — это не приглашение «впихнуть всю бизнес-логику в репозиторий». Репозиторий по-прежнему остаётся местом, где живёт запрос, а не место, где решают судьбу скидок, статусов заказа и смысла жизни.
4. Маленькие спецификации: один фильтр — одна функция
Самый здоровый стиль для Specification — это когда каждая спецификация отвечает за один фильтр, а имя метода читается как человеческая фраза. Тогда код не превращается в «spec1/spec2/spec3», а начинает выглядеть как нормальное предложение: hasStatus(ACTIVE).and(nameStartsWith("pro")).
Давайте заведём утилитный класс (или набор утилитных классов) в каталоге. Логично положить его в com.example.commerce.catalog.query, потому что это read-oriented часть, а не доменная модель.
Пример — фильтр по статусу:
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
public final class ProductSpecifications {
public static Specification<Product> hasStatus(ProductStatus status) {
// Фильтр опциональный: если статус не задан — спецификация отсутствует
if (status == null) {
return null;
}
// WHERE product.status = :status
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
}
Фильтр «имя начинается с префикса» (и сразу сделаем case-insensitive, потому что пользователи не обязаны помнить регистр, а мы не обязаны страдать):
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public final class ProductNameSpecifications {
public static Specification<Product> nameStartsWithIgnoreCase(String prefix) {
// Пустой префикс => фильтра нет
if (prefix == null || prefix.isBlank()) {
return null;
}
// LIKE 'pro%' в нижнем регистре (case-insensitive)
String like = prefix.toLowerCase() + "%";
return (root, query, cb) -> cb.like(cb.lower(root.get("name")), like);
}
}
Фильтр по SKU-префиксу:
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public final class ProductSkuSpecifications {
public static Specification<Product> skuStartsWith(String prefix) {
// Пустой sku => фильтра нет
if (prefix == null || prefix.isBlank()) {
return null;
}
// WHERE sku LIKE 'SKU-%'
return (root, query, cb) -> cb.like(root.get("sku"), prefix + "%");
}
}
Обратите внимание: мы не пытаемся «сразу сделать универсальную спецификацию на всё». Это частая ловушка новичков: написать один гигантский метод buildSpecification(filter) на 200 строк и радоваться, что «всё в одном месте». Через пару недель это место начинает пахнуть как забытый в холодильнике контейнер — технически он всё ещё контейнер, но открывать страшно.
Лучше держать спецификации маленькими, а сборку — отдельно.
5. Композиция: where, and, or и null
Самая кайфовая часть Specification — композиция. То есть вы можете собрать запрос из фильтров, как из кубиков. И это не метафора «для красоты»: это буквально spec1.and(spec2).or(spec3). При этом, если какой-то фильтр не задан, вы возвращаете null, и композиция остаётся чистой.
Давайте зададим небольшой объект фильтра. В лекциях про проекции мы уже привыкали к тому, что форма выдачи важна; здесь мы фиксируем ещё и «форму входа» для поиска.
import com.example.commerce.catalog.entity.ProductStatus;
// DTO для входных параметров поиска (то, что приходит в сервис)
public record ProductSearchFilter(
ProductStatus status,
String namePrefix,
String skuPrefix
) {
}
Теперь сервис чтения (read-oriented). Назовём его, например, ProductSearchService и сделаем read-only транзакцию — так мы дисциплинированно держим query execution внутри нормальной границы.
import java.util.List;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
@Service
public class ProductSearchService {
private final ProductRepository productRepository;
public ProductSearchService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional(readOnly = true)
public List<Product> search(ProductSearchFilter filter) {
// Собираем запрос из маленьких «кирпичиков»; null-спеки будут пропущены
Specification<Product> spec = Specification
.where(ProductSpecifications.hasStatus(filter.status()))
.and(ProductNameSpecifications.nameStartsWithIgnoreCase(filter.namePrefix()))
.and(ProductSkuSpecifications.skuStartsWith(filter.skuPrefix()));
// JpaSpecificationExecutor выполнит Criteria-запрос за нас
return productRepository.findAll(spec);
}
}
В этом примере есть две идеи, которые очень полезно удерживать в голове.
Первая идея: сервис собирает запрос на уровне смысла. Вы читаете where(hasStatus).and(nameStartsWith).and(skuStartsWith) и примерно понимаете, что будет в SQL, даже не открывая лог. Это уже большой шаг к «предсказуемому persistence layer».
Вторая идея: Specification.where(...) — удобная точка старта. Вы можете начать даже с where(null) (если вам так проще) и потом накидывать and(). Но чаще стартовать с первого фильтра удобнее чисто психологически: меньше ощущения «мы сейчас будем колдовать».
А теперь пример or() — допустим, мы хотим поиск по свободной строке: «пусть ищет либо в имени, либо в SKU». Да, по-хорошему это уже начинает напоминать full-text, но как учебный пример — отлично.
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public final class ProductTextSearchSpecifications {
public static Specification<Product> nameOrSkuContainsIgnoreCase(String q) {
// Пустой поисковый запрос => фильтра нет
if (q == null || q.isBlank()) {
return null;
}
// '%q%' в нижнем регистре, чтобы сравнение было case-insensitive
String like = "%" + q.toLowerCase() + "%";
// (lower(name) like :like) OR (lower(sku) like :like)
return (root, query, cb) -> cb.or(
cb.like(cb.lower(root.get("name")), like),
cb.like(cb.lower(root.get("sku")), like)
);
}
}
И в сервисе вы просто добавляете .and(nameOrSkuContainsIgnoreCase(filter.q())). Получается динамика без строки, без склейки, без слёз и без «почему тут пробел лишний, и запрос падает только в проде».
6. Join в Specification: фильтрация по связям
На товарах было удобнее увидеть композицию без лишнего шума. На заказах уже видно то, ради чего эта техника и нужна в обычном backoffice-поиске: часть условий живёт на связях, и join() нужен именно для фильтрации.
Очень быстро возникает следующий уровень сложности: фильтр не по полю корневой сущности, а по связи. Например, «заказы клиента с таким email», или «заказы со статусом X, где есть позиция с таким SKU». И вот тут Specification остаётся удобной, но появляется важная дисциплина: join() в спецификации — это прежде всего фильтрация, а не «загрузка всего графа».
Начнём с простого кейса: поиск заказов по email клиента. В домене проекта PurchaseOrder связан с Customer через ManyToOne, и это естественная навигация.
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.orders.entity.PurchaseOrder;
public final class OrderSpecifications {
public static Specification<PurchaseOrder> customerEmailEquals(String email) {
// Не задан email => нет фильтра
if (email == null || email.isBlank()) {
return null;
}
// JOIN customer и фильтрация по customer.email
return (root, query, cb) ->
cb.equal(root.join("customer").get("email"), email);
}
}
Теперь сервис (покажу коротко, без лишнего шума):
import java.util.List;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.repository.PurchaseOrderRepository;
@Service
public class OrderSearchService {
private final PurchaseOrderRepository repository;
public OrderSearchService(PurchaseOrderRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<PurchaseOrder> findByCustomerEmail(String email) {
// Одна спецификация — один фильтр; дальше выполняем через репозиторий
Specification<PurchaseOrder> spec = OrderSpecifications.customerEmailEquals(email);
return repository.findAll(spec);
}
}
Теперь «интересный» кейс: фильтр по коллекции, например «заказы, где есть item с определённым SKU». Здесь обычно делается join на items, потом join на product, и сравнение по sku. И вот здесь почти всегда всплывает тема дублей: один заказ может иметь несколько позиций, и join даст несколько строк результата на один root. Поэтому часто нужно query.distinct(true).
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.orders.entity.PurchaseOrder;
public final class OrderItemSpecifications {
public static Specification<PurchaseOrder> hasItemWithProductSku(String sku) {
// Пустой sku => фильтра нет
if (sku == null || sku.isBlank()) {
return null;
}
return (root, query, cb) -> {
// Важно: join по коллекции может «размножить» root-строки, distinct это чинит
query.distinct(true);
// JOIN items -> product и фильтрация по product.sku
return cb.equal(
root.join("items").join("product").get("sku"),
sku
);
};
}
}
И вот здесь важно не перепутать два понятия. join(...) в спецификации не означает, что вы «решили проблему lazy loading» или «всё сразу загрузили». Это join для построения условия. Fetch-план и борьба с N+1 — это отдельная дисциплина, и вы её уже проходили на предыдущих днях (JOIN FETCH, EntityGraph, batch fetching). Если вы начнёте решать fetching через Specification, вы легко получите запросы, которые непредсказуемо ведут себя при пагинации и вообще хуже читаются.
7. Specification + Pageable/Sort: count и distinct
Когда вы делаете backoffice-поиск, вам почти всегда нужна пагинация и сортировка. И тут Specification прекрасно сочетается с Pageable: репозиторий сам построит основной запрос и отдельный count-запрос, чтобы посчитать количество элементов. Это удобно, но иногда неожиданно дорого, особенно когда вы начинаете join’ить коллекции и ставить distinct.
Классический вызов выглядит так:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.orders.entity.PurchaseOrder;
public class PageExample {
public Page<PurchaseOrder> page(PurchaseOrderRepository repo, Specification<PurchaseOrder> spec) {
// Pageable задаёт номер страницы и размер; Spring Data обычно сделает select + count
var pageable = PageRequest.of(0, 20);
return repo.findAll(spec, pageable);
}
}
Если спецификация содержит join на коллекцию и query.distinct(true), то вы должны понимать: distinct влияет не только на «основной select», но и может повлиять на count. Иногда это необходимо (иначе count будет завышен), иногда это делает count тяжелее. В учебном проекте это хороший момент, чтобы научиться не «бояться» count-запросов, а смотреть на SQL-лог и понимать, почему он появился.
Ещё один нюанс — сортировка. Если вы сортируете по полю root-entity (createdAt, orderNumber), всё обычно просто. Если вы пытаетесь сортировать по полю связанной сущности, вам понадобится join (или уже другой стиль запроса). Specification позволяет, но читаемость может резко упасть. Это не запрет, это просто инженерная цена: чем более «динамический и универсальный поиск», тем выше вероятность, что запрос станет неочевидным.
Чтобы не превращать лекцию в отдельный курс про «как построить идеальный search endpoint на все случаи жизни», зафиксируем практическое правило: Specification шикарна, когда динамика живёт в where. Когда динамика начинает жить в select, group by, агрегатах и отчётных штуках — спецификация перестаёт быть самым ясным инструментом. И это будет очень кстати уже в следующих лекциях дня.
8. Specification и projections: как не загрузить лишнее
После дня про проекции у вас, скорее всего, уже закрепилась мысль: список товаров для админки — это часто не «возвращаем Product целиком», а «возвращаем узкую строку». И тут легко сделать шаг назад: JpaSpecificationExecutor по умолчанию отдаёт List<Product>, и вы снова загрузили managed entity, хотя хотели read-model. Это не преступление, но это технический компромисс, который нужно видеть.
Для списка заказов это означает простую вещь: динамический where можно оставить в Specification, а наружу вернуть узкий OrderRow, не таща PurchaseOrder как read-model.
Хорошая новость: в Spring Data JPA есть fluent-метод findBy(spec, queryFunction), который позволяет применять projection прямо вместе со спецификацией. Это выглядит немного «магически» на первый взгляд, но по смыслу всё честно: спецификация формирует where, а queryFunction говорит «в каком виде вернуть результат».
Сделаем простую read-модель для списка товаров:
import com.example.commerce.catalog.entity.ProductStatus;
// Узкая read-модель: только то, что нужно для таблицы/списка
public record ProductRow(
long id,
String sku,
String name,
ProductStatus status
) {
}
Теперь пример вызова: хотим получить не List<Product>, а List<ProductRow>.
import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import com.example.commerce.catalog.entity.Product;
public class ProjectionWithSpecExample {
public List<ProductRow> rows(ProductRepository repo, Specification<Product> spec) {
// Specification задаёт WHERE, а дальше мы говорим, какой проекцией вернуть результат
return repo.findBy(spec, q -> q
.as(ProductRow.class)
.sortBy(Sort.by("name"))
.all());
}
}
Обратите внимание, насколько это «держит дисциплину» предыдущего дня. Мы по-прежнему не тащим entity как read-model, но при этом не склеиваем JPQL-строку и не пишем Criteria руками в каждом месте. Для backoffice-поиска это часто золотая середина: динамический where плюс узкая выдача.
Здесь есть инженерная граница. Projection + Specification отлично подходят, когда вы читаете «плоскую» строку: поля одной сущности или простые поля to-one связи (в зависимости от того, как Spring Data сможет это сматчить). Но если вы хотите сложную выдачу, агрегаты или отчётный формат, то вы уже уходите в зону, где JPQL (с constructor expression), CriteriaQuery «вручную» или native SQL (чуть позже сегодня) будут проще и честнее.
9. Типичные ошибки при работе со Specification
Ошибка №1: одна гигантская спецификация «на всё» вместо маленьких переиспользуемых фильтров.
Очень хочется написать buildSpec(filter) на 150 строк, потому что «так быстрее» и «зато всё в одном месте». Обычно это заканчивается тем, что вы боитесь туда заходить: любое изменение ломает что-то неожиданное, а код ревью превращается в археологию. Спецификации хорошо работают как маленькие кирпичики, которые комбинируются в сервисе или в отдельном сборщике.
Ошибка №2: смешивание бизнес-логики и логики фильтрации в одном месте.
Specification — это про запрос, а не про правила домена. Как только внутри спецификации появляются условия уровня «если заказ в статусе PAID, то ещё вот так», вы рискуете спрятать бизнес-решение в репозитории. Потом кто-то поменяет правило в сервисе и забудет, что оно ещё и «вшито» в спецификацию — и получится эффект двойного дна.
Ошибка №3: попытка лечить fetching и N+1 через Specification.
Иногда кажется: «ну раз я уже делаю join в спецификации, давайте и всё сразу загрузим». Технически можно, но это быстро превращает спецификацию в трудно читаемый комбайн, а поведение с пагинацией становится опасным. Fetch-план — отдельная история: для этого у нас есть JOIN FETCH, EntityGraph, batch fetching. Спецификация — в первую очередь про where.
Ошибка №4: забыть про дубли при join на коллекцию и не поставить distinct.
Фильтр вида «заказы, где есть item с таким SKU» почти всегда делает join на items. Без distinct вы можете получить один и тот же PurchaseOrder несколько раз, и это будет выглядеть как «репозиторий глючит». Репозиторий не глючит — это ваша SQL-форма результата. Если join порождает дубли root-строк, вы должны либо включить distinct, либо пересмотреть форму запроса.
Ошибка №5: не понимать, что пагинация почти всегда означает два запроса.
Когда вы делаете findAll(spec, pageable), Spring Data обычно делает select + count. Это нормально и ожидаемо. Если вы «удивляетесь» count-запросу и пытаетесь его «магически выключить», вы начинаете бороться с платформой. Правильный путь — видеть SQL, понимать его стоимость и держать запросы достаточно простыми, чтобы count не стал отдельной проблемой.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ