Коли фільтрів в адмін-панелі стає більше трьох — derived methods перетворюються
на комбінаторний вибух: 4 опціональних фільтри дають 16 можливих комбінацій
і стільки ж методів у Repository. Specification вирішує цю проблему:
один метод, будь-яка комбінація фільтрів, чистий SQL під капотом.
⚡ Коротко
- ✅ Combinatorial explosion: N опціональних фільтрів = 2ⁿ derived methods — Specification замінює все одним
- ✅ JpaSpecificationExecutor: додається одним extends до Repository
- ✅ Composition: фільтри комбінуються через
.and() / .or() динамічно в рантаймі - ⚠️ N+1: Specification не вирішує проблему ледачої ініціалізації — потрібен явний JOIN
- 👇 Нижче — механіка, реальний приклад і production-нюанси
📚 Зміст
Інші статті серії:
📌 Slice в Spring Data JPA — infinite scroll без COUNT(*)
📌 Page в Spring Data JPA — пагінація з COUNT і countQuery
📌 List в Spring Data JPA — ризики без LIMIT і коли це прийнятно (незабаром)
📌 List vs Page vs Slice vs Specification — порівняльна стаття (незабаром)
🎯 Проблема: combinatorial explosion у derived methods
N опціональних фільтрів дають 2ⁿ методів у Repository
Derived methods — зручний інструмент для фіксованих запитів.
Але як тільки з'являються опціональні фільтри, кількість необхідних методів
зростає експоненційно. При 4 фільтрах це 16 комбінацій.
При 6 — 64. Підтримувати це неможливо.
Derived methods описують фіксований запит у назві методу.
Динамічні фільтри — це запити що формуються в рантаймі.
Це фундаментально різні задачі.
Як виглядає combinatorial explosion на практиці
Уявімо адмін-панель для керування публікаціями (stories).
Адміністратор може фільтрувати по чотирьох полях — всі опціональні:
status, authorId, categoryId, createdAfter.
Кожна комбінація присутніх/відсутніх фільтрів вимагає окремого derived method:
// Тільки statusList<Story> findByStatus(String status);
// status + authorId
List<Story> findByStatusAndAuthorId(String status, Long authorId);
// status + categoryId
List<Story> findByStatusAndCategoryId(String status, Long categoryId);
// status + authorId + categoryId
List<Story> findByStatusAndAuthorIdAndCategoryId(...);
// ... і ще 12 комбінацій
// Разом: 2⁴ = 16 методів для 4 фільтрів
Проблеми цього підходу виходять за межі кількості методів:
Логіка вибору методу переноситься в сервіс.
Хтось має перевірити які фільтри передані і викликати відповідний метод —
це if/else ліс у сервісному шарі.
Додавання нового фільтру подвоює кількість методів.
П'ятий фільтр — вже 32 комбінації. Шостий — 64.
Пагінація множить проблему.
Кожен метод потрібно продублювати з Pageable — кількість методів
подвоюється ще раз.
Альтернатива через JPQL — теж не масштабується
Часта спроба вирішення — один @Query з JPQL і умовами через IS NULL:
@Query("""SELECT s FROM Story s WHERE
(:status IS NULL OR s.status = :status) AND
(:authorId IS NULL OR s.author.id = :authorId) AND
(:categoryId IS NULL OR s.category.id = :categoryId)
""")
Page<Story> findWithFilters(..., Pageable pageable);
Цей підхід працює, але має серйозний недолік: PostgreSQL не може оптимально
використовувати індекси коли умова містить IS NULL OR ... —
план запиту фіксується при першому виконанні і не змінюється при різних
комбінаціях фільтрів. При великих таблицях це призводить до suboptimal
execution plans.
Specification генерує різний SQL для кожної комбінації — тільки ті
WHERE-умови які реально потрібні.
🎯 Criteria API — основа Specification
Specification — типобезпечна обгортка над Criteria API
Criteria API — це JPA-стандарт для програмної побудови запитів через Java-об'єкти
замість рядків. Specification<T> в Spring Data — функціональний
інтерфейс з одним методом що повертає Predicate. Це тонка обгортка
яка дає зручний API для composition і повністю делегує виконання Criteria API.
Criteria API дає type-safety і програмну побудову запитів.
Specification додає до цього composability — можливість комбінувати
предикати через .and() / .or().
Три ключових компоненти
Specification працює через три об'єкти які передаються в метод toPredicate:
Root<T> — представляє entity в запиті.
Через нього звертаємось до полів: root.get("status"),
root.join("author"). Відповідає FROM-частині SQL.
CriteriaQuery<?> — представляє сам запит.
Рідко використовується напряму в Specification — Spring Data керує ним автоматично.
CriteriaBuilder — фабрика для створення предикатів:
cb.equal(), cb.greaterThan(), cb.like(),
cb.and(), cb.or(). Відповідає WHERE-умовам в SQL.
Як Specification перетворюється на SQL
Кожен Predicate що повертає toPredicate транслюється
Hibernate в SQL-умову. Кілька Specification об'єднаних через .and()
генерують SQL з відповідними AND-умовами:
// Specification для фільтру по статусуSpecification<Story> hasStatus(String status) {
return (root, query, cb) ->
cb.equal(root.get("status"), status);
}
// SQL: WHERE status = 'PUBLISHED'
// Specification для фільтру по даті
Specification<Story> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
cb.greaterThan(root.get("createdAt"), date);
}
// SQL: WHERE created_at > '2024-01-01'
// Комбінація — генерує обидві умови
Specification<Story> combined = hasStatus("PUBLISHED")
.and(createdAfter(LocalDateTime.of(2024, 1, 1, 0, 0)));
// SQL: WHERE status = 'PUBLISHED' AND created_at > '2024-01-01'
Якщо фільтр не потрібен — він просто не додається до ланцюжка.
SQL генерується тільки з тими умовами які реально передані.
🎯 JpaSpecificationExecutor — підключення і синтаксис
Один extends і чотири нових методи в Repository
Щоб використовувати Specification, Repository має розширювати
JpaSpecificationExecutor<T>. Це додає методи
findAll(Specification), findOne(Specification),
count(Specification) і їх варіанти з Pageable.
Жодних додаткових конфігурацій не потрібно.
public interface StoryRepositoryextends JpaRepository<Story, Long>,
JpaSpecificationExecutor<Story> {
// Звичайні derived methods залишаються — вони не конфліктують
}
Доступні методи після підключення
// Пошук з фільтром — повертає ListList<Story> stories = repository.findAll(spec);
// Пошук з фільтром і пагінацією — повертає Page
Page<Story> page = repository.findAll(spec, pageable);
// Підрахунок — без зайвого SELECT
long count = repository.count(spec);
// Перевірка існування
boolean exists = repository.exists(spec);
// Один результат (кидає exception якщо більше одного)
Optional<Story> story = repository.findOne(spec);
Composition — динамічне поєднання фільтрів
Specification підтримує три оператори composition:
// AND — обидві умови мають виконуватисьSpecification<Story> spec = hasStatus("PUBLISHED")
.and(createdAfter(date));
// OR — хоча б одна умова
Specification<Story> spec = hasStatus("PUBLISHED")
.or(hasStatus("DRAFT"));
// NOT — заперечення
Specification<Story> spec = Specification.not(hasStatus("DELETED"));
// Статичний where() — зручний стартовий метод
Specification<Story> spec = Specification
.where(hasStatus("PUBLISHED"))
.and(createdAfter(date))
.and(hasAuthor(authorId));
Ключовий момент: якщо фільтр не потрібен — він не додається.
Логіка умовного додавання знаходиться в сервісі, а не в Repository:
Specification<Story> spec = Specification.where(null); // порожній стартif (filter.getStatus() != null) {
spec = spec.and(hasStatus(filter.getStatus()));
}
if (filter.getAuthorId() != null) {
spec = spec.and(hasAuthor(filter.getAuthorId()));
}
// ...
🎯 Приклад: адмін-панель з динамічними фільтрами
один Specification-клас замінює 16 derived methods
Повний приклад: адмін-панель для керування публікаціями з чотирма опціональними
фільтрами. Весь код — DTO для фільтру, клас зі Specification-методами,
сервіс що їх комбінує і SQL який генерується для різних комбінацій.
DTO для фільтру
// Всі поля опціональні — null означає "фільтр не застосовується"public record AdminStoryFilter(
String status, // PUBLISHED / DRAFT / DELETED
Long authorId,
Long categoryId,
LocalDateTime createdAfter
) {}
StorySpecification — окремий клас для предикатів
public class StorySpecification {public static Specification<Story> hasStatus(String status) {
return (root, query, cb) ->
cb.equal(root.get("status"), status);
}
public static Specification<Story> hasAuthor(Long authorId) {
return (root, query, cb) ->
cb.equal(root.get("author").get("id"), authorId);
}
public static Specification<Story> hasCategory(Long categoryId) {
return (root, query, cb) ->
cb.equal(root.get("category").get("id"), categoryId);
}
public static Specification<Story> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("createdAt"), date);
}
}
Service — динамічна композиція
@Service@RequiredArgsConstructor
public class AdminStoryService {
private final StoryRepository storyRepository;
public Page<Story> findStories(AdminStoryFilter filter, Pageable pageable) {
Specification<Story> spec = Specification.where(null);
if (filter.status() != null) {
spec = spec.and(StorySpecification.hasStatus(filter.status()));
}
if (filter.authorId() != null) {
spec = spec.and(StorySpecification.hasAuthor(filter.authorId()));
}
if (filter.categoryId() != null) {
spec = spec.and(StorySpecification.hasCategory(filter.categoryId()));
}
if (filter.createdAfter() != null) {
spec = spec.and(StorySpecification.createdAfter(filter.createdAfter()));
}
return storyRepository.findAll(spec, pageable);
}
}
SQL для різних комбінацій фільтрів
Один і той самий метод генерує різний SQL залежно від переданих фільтрів:
-- Тільки status:SELECT * FROM stories WHERE status = 'PUBLISHED'
ORDER BY created_at DESC LIMIT 20;
-- status + authorId:
SELECT * FROM stories
WHERE status = 'PUBLISHED' AND author_id = 42
ORDER BY created_at DESC LIMIT 20;
-- Всі чотири фільтри:
SELECT * FROM stories
WHERE status = 'PUBLISHED'
AND author_id = 42
AND category_id = 5
AND created_at >= '2024-01-01'
ORDER BY created_at DESC LIMIT 20;
-- Без фільтрів (spec = where(null)):
SELECT * FROM stories
ORDER BY created_at DESC LIMIT 20;
Кожна комбінація отримує оптимальний план виконання — без зайвих
IS NULL OR умов що заважають оптимізатору PostgreSQL.
🎯 Production-нюанси і обмеження
N+1 і JOIN — головні пастки при роботі зі Specification
Specification будує WHERE-умови, але не керує завантаженням зв'язаних entities.
LAZY-зв'язки залишаються LAZY — і якщо їх не обробити явно, отримаєте N+1.
JOIN в Specification через root.join() вирішує фільтрацію,
але не завантаження даних.
N+1 проблема зі Specification
Якщо Story має LAZY-зв'язок author і контролер
звертається до story.getAuthor().getName() при серіалізації —
Hibernate виконає окремий SELECT для кожного автора:
-- 1 запит для storiesSELECT * FROM stories WHERE ... LIMIT 20;
-- 20 запитів для авторів (N+1)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
-- ...
Рішення — явний JOIN FETCH в Specification через query.distinct(true)
і перевірку типу запиту (COUNT-запит не потребує fetch):
public static Specification<Story> withAuthorFetch() {return (root, query, cb) -> {
// Fetch тільки для основного запиту, не для COUNT
if (query.getResultType() != Long.class) {
root.fetch("author", JoinType.LEFT);
query.distinct(true);
}
return cb.conjunction(); // без додаткових WHERE-умов
};
}
Додайте цей Specification до ланцюжка разом з фільтрами — і N+1 зникне.
Коли Specification надлишковий
Specification додає складність — клас предикатів, логіку композиції,
розуміння Criteria API. Це виправдано коли фільтрів 3+ і вони опціональні.
Для простіших сценаріїв є кращі рішення:
1–2 фіксованих фільтри — derived method або @Query.
Specification тут надлишковий.
Складна аналітика з агрегацією — нативний SQL або JOOQ.
Criteria API погано читається для складних GROUP BY і оконних функцій.
Full-text search — Elasticsearch або PostgreSQL tsvector.
Specification не призначений для пошуку по тексту.
Specification + Page — production-комбінація
findAll(Specification, Pageable) генерує і основний SELECT і COUNT —
обидва з однаковими WHERE-умовами. Для складних Specification з JOIN
COUNT-запит може бути неоптимальним. Перевіряйте EXPLAIN ANALYZE
і за потреби використовуйте countQuery через нативний підхід
або кастомний Repository метод.
❓ FAQ
Чи можна використовувати Specification з @EntityGraph?
Так. @EntityGraph на Repository-методі і Specification —
сумісні. Оголосіть @EntityGraph на кастомному методі
що приймає Specification, або використовуйте
EntityGraphQueryHint через @QueryHints.
Це чистіший підхід ніж fetch через root.fetch()
для випадків коли граф завантаження фіксований.
Specification і Querydsl — що обрати?
Обидва вирішують ту саму проблему динамічних запитів.
Querydsl генерує типобезпечні Q-класи під час компіляції —
QStory.story.status.eq("PUBLISHED") замість
root.get("status").
Querydsl зручніший для великих проектів з багатьма entity,
але вимагає додаткової залежності і кодогенерації.
Specification — стандартний Spring Data підхід без зовнішніх залежностей.
Для більшості проектів Specification достатній.
Як тестувати Specification?
Кожен Specification-метод можна тестувати ізольовано через
@DataJpaTest — це lightweight тест що піднімає тільки
JPA-шар без повного Spring контексту. Передайте Specification в
repository.findAll(spec) і перевірте результат.
Це надійніше ніж мокати Criteria API — він складний для мокання
і тест з реальною БД дає більше впевненості.
Specification кешується Hibernate?
Частково. Hibernate кешує query plan для JPQL і нативних запитів,
але кожна нова комбінація Specification генерує новий SQL —
і новий query plan. При дуже великій кількості унікальних комбінацій
це може впливати на query plan cache. На практиці для адмін-панелей
з 4–6 фільтрами це несуттєво, але для публічних API з високим RPS
варто моніторити розмір Hibernate query plan cache.
Чи підтримує Specification сортування?
Сортування передається через Pageable або Sort
окремо від Specification — вони не змішуються. Якщо потрібне динамічне
сортування залежно від фільтрів — передавайте Sort
через PageRequest.of(page, size, sort) поряд зі Specification.
Нестандартне сортування (наприклад, NULLS LAST) можна задати через
query.orderBy() всередині Specification.
✅ Висновки
Specification<T> вирішує одну конкретну проблему:
динамічні запити з опціональними фільтрами де derived methods
призводять до combinatorial explosion. Один метод Repository,
будь-яка комбінація фільтрів, оптимальний SQL для кожної комбінації.
- N опціональних фільтрів = 2ⁿ derived methods → один
findAll(Specification, Pageable) - Specification генерує різний SQL для кожної комбінації — без
IS NULL OR антипатерну - JOIN FETCH у Specification — перевіряйте тип запиту щоб не зламати COUNT
- N+1 Specification не вирішує — потрібен явний fetch або
@EntityGraph - Для 1–2 фіксованих фільтрів Specification надлишковий —
@Query простіший
Джерела:
Spring Data JPA — Specifications documentation
Jakarta Persistence — Criteria API specification
Vlad Mihalcea — Spring Data JPA Specifications
Ключові слова:
SpecificationSpring DataJPA