Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Оновлено:
Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Коли фільтрів в адмін-панелі стає більше трьох — 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:

// Тільки status

List<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 генерується тільки з тими умовами які реально передані.

Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

🎯 JpaSpecificationExecutor — підключення і синтаксис

Один extends і чотири нових методи в Repository

Щоб використовувати Specification, Repository має розширювати

JpaSpecificationExecutor<T>. Це додає методи

findAll(Specification), findOne(Specification),

count(Specification) і їх варіанти з Pageable.

Жодних додаткових конфігурацій не потрібно.

public interface StoryRepository

extends JpaRepository<Story, Long>,

JpaSpecificationExecutor<Story> {

// Звичайні derived methods залишаються — вони не конфліктують

}

Доступні методи після підключення

// Пошук з фільтром — повертає List

List<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 запит для stories

SELECT * 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

Останні статті

Читайте більше цікавих матеріалів

List vs Page vs Slice vs Specification у Spring Data JPA: повний гід Spring Boot 3

List vs Page vs Slice vs Specification у Spring Data JPA: повний гід Spring Boot 3

Spring Data JPA пропонує чотири принципово різних підходи до отримання даних.Кожен з них генерує різний SQL, по-різному поводиться з пам'яттюі вирішує різний клас задач. Неправильний вибір — це або зайвийCOUNT(*) при кожному scroll-івенті, або OOM при зростанні таблиці,або 64 derived methods...

List в Spring Data JPA: коли безпечно і коли це ризик — Spring Boot 3

List в Spring Data JPA: коли безпечно і коли це ризик — Spring Boot 3

List&lt;T&gt; — найпростіший тип повернення в Spring Data JPA. Саме тому він найчастіше використовується там де не повинен. Відсутність LIMIT у згенерованому SQL означає: розмір результату нічим не обмежений. При таблиці на 100K+ рядків це пряма дорога до OutOfMemory або...

Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Коли фільтрів в адмін-панелі стає більше трьох — derived methods перетворюютьсяна комбінаторний вибух: 4 опціональних фільтри дають 16 можливих комбінаційі стільки ж методів у Repository. Specification вирішує цю проблему:один метод, будь-яка комбінація фільтрів, чистий SQL під капотом.⚡ Коротко✅...

Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page&lt;T&gt; — найпопулярніший тип пагінації в Spring Data JPA, і водночас найчастіше використовуваний там де він не потрібен. Він виконує два SQL-запити при кожному виклику: основний SELECT і COUNT(*). Ключове питання не «як використовувати Page», а «коли він...

Slice в Spring Data JPA: легка пагінація для infinite scroll у Spring Boot 3

Slice в Spring Data JPA: легка пагінація для infinite scroll у Spring Boot 3

Більшість розробників за замовчуванням обирають Page&lt;T&gt; для пагінації — і платять за це зайвим COUNT(*) запитом при кожному scroll-івенті. Slice вирішує саме цю проблему: повертає наступну порцію даних без підрахунку загальної кількості рядків — і саме тому є...

OpenAI випустив GPT-5.4: що змінилось  в 2026

OpenAI випустив GPT-5.4: що змінилось в 2026

5 березня 2026 року OpenAI випустив GPT-5.4 — одночасно у ChatGPT, API і Codex.Це не черговий incremental update: модель вперше об'єднує coding pipeline GPT-5.3-Codexіз загальним reasoning, отримує native computer use і контекстне вікно до 1M токенів.Коротко: якщо ви будуєте агентні воркфлоу або...