Specification Pattern: Композиція бізнес-правил для складних запитів
Specification Pattern: Композиція бізнес-правил для складних запитів
Вступ: Коли методів пошуку стає забагато
У попередніх статтях ми побудували архітектуру Repository + Data Mapper, що успішно відокремила доменну модель від SQL-логіки. Репозиторій JdbcAuthorRepository містить стандартні CRUD-операції (findById, findAll, save, update, deleteById) та кілька специфічних методів пошуку:
public interface AuthorRepository extends Repository<Author, UUID> {
List<Author> findByLastName(String lastNamePart);
Optional<Author> findByFullName(String lastName, String firstName);
}
Це працює для простих сценаріїв. Але що станеться, коли бізнес-вимоги ускладнюються? Розглянемо реальні запити, що виникають у системі управління аудіоплатформою:
Запит 1: Фільтрація авторів
Запит 2: Пошук аудіокниг
Запит 3: Динамічний фільтр
Наївний підхід — створити окремий метод для кожної комбінації:
List<Audiobook> findByGenre(String genre);
List<Audiobook> findByGenreAndYear(String genre, Integer year);
List<Audiobook> findByGenreAndYearRange(String genre, Integer minYear, Integer maxYear);
List<Audiobook> findByGenreAndMinDuration(String genre, Integer minDuration);
// ... ще десятки методів для всіх комбінацій
Це призводить до комбінаторного вибуху: кожна нова вимога множить кількість методів. Якщо у системі 5 критеріїв пошуку, кількість можливих комбінацій — 2⁵ = 32. Для 10 критеріїв — 1024 методи. Репозиторій перетворюється на нечитабельний монстр із сотнями рядків однотипного коду.
Друга проблема — дублювання SQL-логіки. Умова WHERE price <= ? повторюється у findByMaxPrice(), findByGenreAndMaxPrice(), findByYearAndMaxPrice() тощо. Зміна логіки (наприклад, додавання перевірки price > 0) вимагає правки у десятках місць.
Третя проблема — відсутність повторного використання бізнес-правил. Умова «аудіокнига доступна для покупки» може означати: ціна встановлена, мова підтримується платформою, жанр не заборонений у регіоні користувача. Це бізнес-правило, що має бути виражене один раз і використане скрізь — у пошуку, валідації, звітах.
Саме для вирішення цих проблем Ерік Еванс у книзі «Domain-Driven Design» (2003) описав патерн Specification — спосіб інкапсуляції бізнес-правил у композиційні об'єкти.
Концепція: Specification Pattern за Еріком Евансом
Визначення
У книзі «Domain-Driven Design: Tackling Complexity in the Heart of Software» (2003) Ерік Еванс визначає Specification так:
«A Specification is a predicate that determines if an object satisfies certain criteria. It can be used to select objects from a collection, validate objects, or specify what kind of object should be created.»
«Специфікація — це предикат, що визначає, чи задовольняє об'єкт певним критеріям. Вона може використовуватися для вибору об'єктів з колекції, валідації об'єктів або специфікації того, який об'єкт має бути створений.»
Ключова ідея: бізнес-правило інкапсульоване в окремий об'єкт, що має метод isSatisfiedBy(T candidate). Цей об'єкт можна комбінувати з іншими через логічні оператори (AND, OR, NOT), створюючи складні умови з простих будівельних блоків.
Зверніть увагу на композиційну структуру: AndSpecification, OrSpecification та NotSpecification самі реалізують Specification<T> і містять посилання на інші специфікації. Це класичний Composite Pattern (GoF) — дерево об'єктів, де листки (прості специфікації) і вузли (логічні оператори) мають однаковий інтерфейс.
Приклад використання
Розглянемо, як виглядає клієнтський код із Specification:
// Прості специфікації — будівельні блоки
Specification<Audiobook> poetry = new GenreSpec("Поезія");
Specification<Audiobook> prose = new GenreSpec("Проза");
Specification<Audiobook> after2020 = new YearAfterSpec(2020);
Specification<Audiobook> affordable = new PriceRangeSpec(
BigDecimal.valueOf(100),
BigDecimal.valueOf(500)
);
Specification<Audiobook> ukrainian = new LanguageSpec("українська");
// Композиція через логічні оператори
Specification<Audiobook> poetryOrProse = poetry.or(prose);
Specification<Audiobook> complexQuery = poetryOrProse
.and(after2020)
.and(affordable)
.and(ukrainian);
// Використання у репозиторії
List<Audiobook> results = audiobookRepository.findAll(complexQuery);
Порівняймо з наївним підходом:
// Жорстко закодований метод у репозиторії
List<Audiobook> findByGenresAndYearAndPriceAndLanguage(
List<String> genres,
Integer minYear,
BigDecimal minPrice,
BigDecimal maxPrice,
String language
) {
// SQL з багатьма умовами WHERE
String sql = """
SELECT ... FROM audiobooks ab
JOIN genres g ON ab.genre_id = g.id
WHERE (g.name = ? OR g.name = ?)
AND ab.year > ?
AND ab.price BETWEEN ? AND ?
AND ab.language = ?
""";
// ... JDBC-код
}
// Виклик
List<Audiobook> results = repo.findByGenresAndYearAndPriceAndLanguage(
List.of("Поезія", "Проза"), 2020,
BigDecimal.valueOf(100), BigDecimal.valueOf(500),
"українська"
);
// Гнучка композиція — жодних нових методів у репозиторії
Specification<Audiobook> spec = new GenreSpec("Поезія")
.or(new GenreSpec("Проза"))
.and(new YearAfterSpec(2020))
.and(new PriceRangeSpec(BigDecimal.valueOf(100), BigDecimal.valueOf(500)))
.and(new LanguageSpec("українська"));
List<Audiobook> results = repo.findAll(spec);
Ключова перевага: репозиторій має лише один метод findAll(Specification<T> spec) замість десятків спеціалізованих методів. Нові комбінації критеріїв не вимагають змін у репозиторії — лише створення нових специфікацій або композиції існуючих.
Два підходи до реалізації: In-Memory vs SQL
Specification Pattern може бути реалізований двома принципово різними способами, залежно від того, де виконується фільтрація:
In-Memory Specification
Фільтрація у Java-пам'яті:
isSatisfiedBy(T candidate)перевіряє об'єкт у пам'яті- Репозиторій завантажує всі записи з БД
- Фільтрація виконується через
stream().filter(spec::isSatisfiedBy)
Переваги:
- ✅ Простота реалізації
- ✅ Не залежить від SQL-діалекту
- ✅ Працює з будь-яким джерелом даних
Недоліки:
- ❌ Завантажує всі записи з БД (неефективно для великих таблиць)
- ❌ Фільтрація на стороні Java (повільно)
- ❌ Неможливо використати індекси БД
SQL Specification
Фільтрація на рівні БД:
- Специфікація генерує SQL-фрагмент (
WHEREумову) - Репозиторій будує повний SQL-запит із цим фрагментом
- БД виконує фільтрацію через індекси
Переваги:
- ✅ Ефективно для великих таблиць
- ✅ Використовує індекси БД
- ✅ Мінімальне навантаження на мережу
Недоліки:
- ❌ Складніша реалізація
- ❌ Залежність від SQL-діалекту
- ❌ Не працює з in-memory колекціями
Для enterprise-систем із великими обсягами даних SQL Specification є єдиним прийнятним варіантом. Завантаження 100 000 записів у пам'ять для фільтрації 10 з них — неприпустима марнотратність.
У цій статті ми реалізуємо SQL Specification — підхід, що генерує WHERE-умови для JDBC-запитів.
Реалізація: Інтерфейс Specification<T>
Почнемо з базового інтерфейсу. На відміну від класичного визначення Еванса (що містить лише isSatisfiedBy), наш інтерфейс буде dual-mode: підтримувати і in-memory перевірку, і SQL-генерацію.
package com.example.audiobook.specification;
/**
* Базовий інтерфейс Specification Pattern за Еріком Евансом (DDD, 2003).
* <p>
* Specification інкапсулює бізнес-правило у вигляді предиката, що може бути:
* <ul>
* <li>Застосований до об'єкта у пам'яті ({@link #isSatisfiedBy});</li>
* <li>Перетворений на SQL WHERE-умову ({@link #toSql});</li>
* <li>Скомбінований з іншими специфікаціями ({@link #and}, {@link #or}, {@link #not}).</li>
* </ul>
* <p>
* <b>Приклад використання:</b>
* <pre>{@code
* Specification<Audiobook> affordable = new PriceRangeSpec(100, 500);
* Specification<Audiobook> ukrainian = new LanguageSpec("українська");
* Specification<Audiobook> query = affordable.and(ukrainian);
*
* // In-memory фільтрація
* boolean matches = query.isSatisfiedBy(someBook);
*
* // SQL-генерація
* String whereClause = query.toSql(); // "price BETWEEN ? AND ? AND language = ?"
* List<Object> params = query.getParameters(); // [100, 500, "українська"]
* }</pre>
*
* @param <T> тип доменної сутності (Author, Genre, Audiobook)
*/
public interface Specification<T> {
/**
* Перевіряє, чи задовольняє об'єкт умовам специфікації (in-memory).
* <p>
* Використовується для валідації об'єктів у пам'яті або фільтрації
* невеликих колекцій через {@code stream().filter(spec::isSatisfiedBy)}.
*
* @param candidate об'єкт для перевірки
* @return {@code true} якщо об'єкт задовольняє умовам
*/
boolean isSatisfiedBy(T candidate);
/**
* Генерує SQL WHERE-умову для цієї специфікації.
* <p>
* Повертає фрагмент SQL без ключового слова {@code WHERE}.
* Параметри представлені як {@code ?} (JDBC placeholders).
* <p>
* <b>Приклад:</b> {@code "price <= ? AND language = ?"}
*
* @return SQL-фрагмент для WHERE-умови
*/
String toSql();
/**
* Повертає список параметрів для {@link #toSql()} у порядку появи {@code ?}.
* <p>
* Ці значення передаються у {@link java.sql.PreparedStatement#setObject}.
*
* @return список параметрів (може бути порожнім)
*/
java.util.List<Object> getParameters();
/**
* Логічне AND: обидві специфікації мають бути задоволені.
*
* @param other друга специфікація
* @return нова специфікація {@code this AND other}
*/
default Specification<T> and(Specification<T> other) {
return new AndSpecification<>(this, other);
}
/**
* Логічне OR: хоча б одна специфікація має бути задоволена.
*
* @param other друга специфікація
* @return нова специфікація {@code this OR other}
*/
default Specification<T> or(Specification<T> other) {
return new OrSpecification<>(this, other);
}
/**
* Логічне NOT: інверсія умови.
*
* @return нова специфікація {@code NOT this}
*/
default Specification<T> not() {
return new NotSpecification<>(this);
}
}
Ключові архітектурні рішення:
- Рядки 15–16 (
isSatisfiedBy+toSql): dual-mode інтерфейс дозволяє використовувати ту саму специфікацію і для in-memory фільтрації, і для SQL-запитів. Це корисно для тестування: можна перевірити логіку специфікації без бази даних. - Рядок 24 (
getParameters): параметри відокремлені від SQL-рядка — це забезпечує безпеку від SQL-ін'єкцій. Репозиторій передає їх уPreparedStatement.setObject(). - Рядки 32–47 (default-методи
and,or,not): композиційні оператори реалізовані як default-методи Java 8+. Це дозволяє писатиspec1.and(spec2).or(spec3)без явного створенняAndSpecificationвручну.
Композиційні специфікації: AND, OR, NOT
Тепер реалізуємо три логічні оператори. Вони є декораторами (Decorator Pattern, GoF) — обгортають інші специфікації і змінюють їх поведінку.
AndSpecification
package com.example.audiobook.specification;
import java.util.ArrayList;
import java.util.List;
/**
* Логічне AND: обидві специфікації мають бути задоволені.
* <p>
* SQL-генерація: {@code (left) AND (right)}.
* Дужки обов'язкові для правильного пріоритету операторів.
*/
public class AndSpecification<T> implements Specification<T> {
private final Specification<T> left;
private final Specification<T> right;
public AndSpecification(Specification<T> left, Specification<T> right) {
this.left = left;
this.right = right;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Обидві умови мають бути true
return left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
// Дужки критично важливі: (a OR b) AND c ≠ a OR (b AND c)
return "(" + left.toSql() + ") AND (" + right.toSql() + ")";
}
@Override
public List<Object> getParameters() {
// Об'єднуємо параметри обох специфікацій у порядку появи
List<Object> params = new ArrayList<>();
params.addAll(left.getParameters());
params.addAll(right.getParameters());
return params;
}
}
OrSpecification
package com.example.audiobook.specification;
import java.util.ArrayList;
import java.util.List;
/**
* Логічне OR: хоча б одна специфікація має бути задоволена.
* <p>
* SQL-генерація: {@code (left) OR (right)}.
*/
public class OrSpecification<T> implements Specification<T> {
private final Specification<T> left;
private final Specification<T> right;
public OrSpecification(Specification<T> left, Specification<T> right) {
this.left = left;
this.right = right;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Достатньо однієї true
return left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
return "(" + left.toSql() + ") OR (" + right.toSql() + ")";
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
params.addAll(left.getParameters());
params.addAll(right.getParameters());
return params;
}
}
NotSpecification
package com.example.audiobook.specification;
import java.util.List;
/**
* Логічне NOT: інверсія умови.
* <p>
* SQL-генерація: {@code NOT (spec)}.
*/
public class NotSpecification<T> implements Specification<T> {
private final Specification<T> spec;
public NotSpecification(Specification<T> spec) {
this.spec = spec;
}
@Override
public boolean isSatisfiedBy(T candidate) {
// Інверсія результату
return !spec.isSatisfiedBy(candidate);
}
@Override
public String toSql() {
return "NOT (" + spec.toSql() + ")";
}
@Override
public List<Object> getParameters() {
// Параметри залишаються незмінними
return spec.getParameters();
}
}
Чому дужки у toSql()? Розглянемо вираз без дужок:
-- Без дужок (НЕПРАВИЛЬНО):
SELECT * FROM audiobooks WHERE price <= 500 AND language = 'uk' OR year > 2020
-- SQL інтерпретує як:
(price <= 500 AND language = 'uk') OR (year > 2020)
-- Але ми хотіли:
price <= 500 AND (language = 'uk' OR year > 2020)
Дужки у AndSpecification та OrSpecification гарантують правильний пріоритет операторів незалежно від порядку композиції.
Інтеграція з Repository
Тепер найважливіша частина: як репозиторій використовує специфікації для побудови SQL-запитів? Додамо новий метод до інтерфейсу Repository<T, ID>:
package com.example.audiobook.repository;
import com.example.audiobook.specification.Specification;
import java.util.List;
import java.util.Optional;
/**
* Базовий контракт репозиторію з підтримкою Specification Pattern.
*/
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
void save(T entity);
void update(T entity);
boolean deleteById(ID id);
long count();
boolean existsById(ID id);
/**
* Знаходить всі сутності, що задовольняють заданій специфікації.
* <p>
* Специфікація перетворюється на SQL WHERE-умову, що виконується на рівні БД.
* Це забезпечує ефективність для великих таблиць (використання індексів).
*
* @param spec специфікація для фільтрації
* @return список сутностей, що задовольняють умовам (може бути порожнім)
*/
List<T> findAll(Specification<T> spec);
}
Тепер реалізуємо цей метод у AbstractJdbcRepository. Ключова ідея: базовий SELECT-запит доповнюється WHERE-умовою зі специфікації.
package com.example.audiobook.repository.jdbc;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.db.DatabaseException;
import com.example.audiobook.repository.Repository;
import com.example.audiobook.specification.Specification;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Абстрактний JDBC-репозиторій з підтримкою Specification Pattern.
*/
public abstract class AbstractJdbcRepository<T, ID> implements Repository<T, ID> {
protected final ConnectionManager connectionManager;
protected AbstractJdbcRepository(ConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}
// ... існуючі методи findById, findAll, save, update, deleteById ...
/**
* Знаходить сутності за специфікацією.
* <p>
* Алгоритм:
* <ol>
* <li>Отримати базовий SELECT-запит від підкласу ({@link #getSelectAllSql()});</li>
* <li>Додати WHERE-умову зі специфікації ({@code spec.toSql()});</li>
* <li>Встановити параметри ({@code spec.getParameters()});</li>
* <li>Виконати запит і змаппити результати.</li>
* </ol>
*/
@Override
public List<T> findAll(Specification<T> spec) {
// Базовий SELECT без WHERE (наприклад, "SELECT ... FROM audiobooks ab JOIN ...")
String baseSql = getSelectAllSql();
// Додаємо WHERE-умову зі специфікації
String sql = baseSql + " WHERE " + spec.toSql();
List<T> results = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// Встановлюємо параметри зі специфікації
List<Object> params = spec.getParameters();
for (int i = 0; i < params.size(); i++) {
stmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
results.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException(
"Помилка findAll(Specification) для таблиці " + getTableName(), e);
}
return results;
}
// Абстрактні методи, що підкласи зобов'язані реалізувати
protected abstract T mapRow(ResultSet rs) throws SQLException;
protected abstract String getTableName();
protected abstract String getSelectAllSql();
// ... інші абстрактні методи
}
Ключові моменти реалізації:
- Рядок 42 (
baseSql + " WHERE " + spec.toSql()): базовий SELECT доповнюється WHERE-умовою. Якщо базовий запит вже містить WHERE (наприклад, для soft-delete:WHERE deleted_at IS NULL), потрібно використовуватиANDзамістьWHERE. - Рядки 49–52 (встановлення параметрів): параметри зі специфікації передаються у
PreparedStatementу порядку появи. Це забезпечує безпеку від SQL-ін'єкцій. - Рядок 55 (
mapRow(rs)): маппінг залишається незмінним — специфікація не впливає на структуру ResultSet, лише на кількість рядків.
Конкретні специфікації для Audiobook (на основі реальної схеми)
Тепер реалізуємо прості (листкові) специфікації для доменної моделі Audiobook, використовуючи реальні поля зі схеми БД. Нагадаємо структуру таблиці audiobooks:
CREATE TABLE audiobooks (
id UUID PRIMARY KEY,
author_id UUID NOT NULL REFERENCES authors(id),
genre_id UUID NOT NULL REFERENCES genres(id),
title VARCHAR(255) NOT NULL,
duration INTEGER NOT NULL CHECK (duration > 0),
release_year INTEGER NOT NULL CHECK (release_year >= 1900),
description TEXT,
cover_image_path VARCHAR(2048)
);
Ключові поля для специфікацій: duration, release_year, genre_id, title.
DurationRangeSpecification
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.ArrayList;
import java.util.List;
/**
* Специфікація: аудіокнига з тривалістю у заданому діапазоні (у секундах).
* <p>
* SQL: {@code duration BETWEEN ? AND ?} або {@code duration >= ?} / {@code duration <= ?}.
*/
public class DurationRangeSpecification implements Specification<Audiobook> {
private final Integer minDuration; // у секундах
private final Integer maxDuration; // у секундах
/**
* @param minDuration мінімальна тривалість (включно), може бути {@code null} — без нижньої межі
* @param maxDuration максимальна тривалість (включно), може бути {@code null} — без верхньої межі
*/
public DurationRangeSpecification(Integer minDuration, Integer maxDuration) {
this.minDuration = minDuration;
this.maxDuration = maxDuration;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
Integer duration = candidate.getDuration();
if (duration == null) return false;
boolean satisfiesMin = (minDuration == null) || duration >= minDuration;
boolean satisfiesMax = (maxDuration == null) || duration <= maxDuration;
return satisfiesMin && satisfiesMax;
}
@Override
public String toSql() {
if (minDuration != null && maxDuration != null) {
return "ab.duration BETWEEN ? AND ?";
} else if (minDuration != null) {
return "ab.duration >= ?";
} else if (maxDuration != null) {
return "ab.duration <= ?";
} else {
return "1=1"; // завжди true — немає обмежень
}
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
if (minDuration != null) params.add(minDuration);
if (maxDuration != null) params.add(maxDuration);
return params;
}
}
YearRangeSpecification
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.ArrayList;
import java.util.List;
/**
* Специфікація: аудіокнига опублікована у заданому діапазоні років.
* <p>
* SQL: {@code release_year BETWEEN ? AND ?}.
*/
public class YearRangeSpecification implements Specification<Audiobook> {
private final Integer minYear;
private final Integer maxYear;
/**
* @param minYear мінімальний рік (включно), може бути {@code null}
* @param maxYear максимальний рік (включно), може бути {@code null}
*/
public YearRangeSpecification(Integer minYear, Integer maxYear) {
this.minYear = minYear;
this.maxYear = maxYear;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
Integer year = candidate.getReleaseYear();
if (year == null) return false;
boolean satisfiesMin = (minYear == null) || year >= minYear;
boolean satisfiesMax = (maxYear == null) || year <= maxYear;
return satisfiesMin && satisfiesMax;
}
@Override
public String toSql() {
if (minYear != null && maxYear != null) {
return "ab.release_year BETWEEN ? AND ?";
} else if (minYear != null) {
return "ab.release_year >= ?";
} else if (maxYear != null) {
return "ab.release_year <= ?";
} else {
return "1=1";
}
}
@Override
public List<Object> getParameters() {
List<Object> params = new ArrayList<>();
if (minYear != null) params.add(minYear);
if (maxYear != null) params.add(maxYear);
return params;
}
}
GenreSpecification
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: аудіокнига належить до заданого жанру.
* <p>
* SQL: {@code g.name = ?} (припускаємо, що у SELECT є JOIN до genres).
*/
public class GenreSpecification implements Specification<Audiobook> {
private final String genreName;
public GenreSpecification(String genreName) {
this.genreName = genreName;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
return candidate.getGenre() != null
&& genreName.equals(candidate.getGenre().getName());
}
@Override
public String toSql() {
// Припускаємо, що у SELECT є JOIN genres g
return "g.name = ?";
}
@Override
public List<Object> getParameters() {
return List.of(genreName);
}
}
TitleContainsSpecification
package com.example.audiobook.specification.audiobook;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.specification.Specification;
import java.util.List;
/**
* Специфікація: назва аудіокниги містить заданий підрядок (регістр-незалежний пошук).
* <p>
* SQL: {@code LOWER(ab.title) LIKE LOWER(?)}.
*/
public class TitleContainsSpecification implements Specification<Audiobook> {
private final String titlePart;
public TitleContainsSpecification(String titlePart) {
this.titlePart = titlePart;
}
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
String title = candidate.getTitle();
return title != null && title.toLowerCase().contains(titlePart.toLowerCase());
}
@Override
public String toSql() {
return "LOWER(ab.title) LIKE LOWER(?)";
}
@Override
public List<Object> getParameters() {
return List.of("%" + titlePart + "%");
}
}
Реалізація JdbcAudiobookRepository з Specification
Тепер адаптуємо JdbcAudiobookRepository для роботи зі специфікаціями. Ключовий принцип: специфікація є додатковим методом пошуку, а не заміною існуючих специфічних методів.
package com.example.audiobook.repository.jdbc;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.db.DatabaseException;
import com.example.audiobook.domain.Author;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.domain.Genre;
import com.example.audiobook.repository.AudiobookRepository;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* JDBC-реалізація {@link AudiobookRepository} з підтримкою Specification Pattern.
* <p>
* Клас зберігає всі специфічні методи пошуку (findByAuthorId, findByGenreName)
* і додає універсальний метод findAll(Specification) для складних запитів.
*/
public class JdbcAudiobookRepository
extends AbstractJdbcRepository<Audiobook, UUID>
implements AudiobookRepository {
/**
* Базовий SELECT з усіма JOIN для специфікацій.
* <p>
* Важливо: псевдоніми таблиць (ab, a, g) мають відповідати тим,
* що використовуються у специфікаціях (ab.duration, g.name тощо).
*/
private static final String SQL_SELECT_BASE = """
SELECT ab.id,
ab.title, ab.duration, ab.release_year,
ab.description, ab.cover_image_path,
a.id AS author_id,
a.first_name, a.last_name, a.bio, a.image_path,
g.id AS genre_id,
g.name AS genre_name,
g.description AS genre_description
FROM audiobooks ab
JOIN authors a ON ab.author_id = a.id
JOIN genres g ON ab.genre_id = g.id
""";
private static final String SQL_SELECT_BY_ID =
SQL_SELECT_BASE + "WHERE ab.id = ?";
private static final String SQL_INSERT = """
INSERT INTO audiobooks
(id, title, author_id, genre_id, duration, release_year, description, cover_image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
private static final String SQL_UPDATE = """
UPDATE audiobooks
SET title = ?,
author_id = ?,
genre_id = ?,
duration = ?,
release_year = ?,
description = ?,
cover_image_path = ?
WHERE id = ?
""";
// Специфічні SQL для методів AudiobookRepository
private static final String SQL_FIND_BY_AUTHOR_ID =
SQL_SELECT_BASE + "WHERE ab.author_id = ? ORDER BY ab.release_year DESC";
private static final String SQL_FIND_BY_GENRE_NAME =
SQL_SELECT_BASE + "WHERE g.name = ? ORDER BY ab.release_year DESC";
public JdbcAudiobookRepository(ConnectionManager connectionManager) {
super(connectionManager);
}
// === Реалізація абстрактних методів AbstractJdbcRepository ===
@Override
protected String getTableName() {
return "audiobooks";
}
@Override
protected String getSelectByIdSql() {
return SQL_SELECT_BY_ID;
}
@Override
protected String getSelectAllSql() {
// Повертаємо базовий SELECT без WHERE — WHERE додається у findAll(Specification)
return SQL_SELECT_BASE;
}
@Override
protected String getInsertSql() {
return SQL_INSERT;
}
@Override
protected String getUpdateSql() {
return SQL_UPDATE;
}
/**
* Data Mapper: ResultSet → Audiobook з вкладеними Author та Genre.
*/
@Override
protected Audiobook mapRow(ResultSet rs) throws SQLException {
// Відновлюємо вкладені об'єкти з JOIN-рядка
Author author = new Author(
rs.getObject("author_id", UUID.class),
rs.getString("first_name"),
rs.getString("last_name"),
rs.getString("bio"),
rs.getString("image_path")
);
Genre genre = new Genre(
rs.getObject("genre_id", UUID.class),
rs.getString("genre_name"),
rs.getString("genre_description")
);
return new Audiobook(
rs.getObject("id", UUID.class),
rs.getString("title"),
author,
genre,
rs.getInt("duration"),
rs.getInt("release_year"),
rs.getString("description"),
rs.getString("cover_image_path")
);
}
@Override
protected void setInsertParams(PreparedStatement stmt, Audiobook audiobook) throws SQLException {
stmt.setObject(1, audiobook.getId());
stmt.setString(2, audiobook.getTitle());
stmt.setObject(3, audiobook.getAuthor().getId());
stmt.setObject(4, audiobook.getGenre().getId());
stmt.setInt(5, audiobook.getDuration());
stmt.setInt(6, audiobook.getReleaseYear());
stmt.setString(7, audiobook.getDescription());
stmt.setString(8, audiobook.getCoverImagePath());
}
@Override
protected void setUpdateParams(PreparedStatement stmt, Audiobook audiobook) throws SQLException {
stmt.setString(1, audiobook.getTitle());
stmt.setObject(2, audiobook.getAuthor().getId());
stmt.setObject(3, audiobook.getGenre().getId());
stmt.setInt(4, audiobook.getDuration());
stmt.setInt(5, audiobook.getReleaseYear());
stmt.setString(6, audiobook.getDescription());
stmt.setString(7, audiobook.getCoverImagePath());
stmt.setObject(8, audiobook.getId()); // WHERE id = ?
}
@Override
protected UUID getId(Audiobook audiobook) {
return audiobook.getId();
}
// === Специфічні методи AudiobookRepository — ЗБЕРІГАЄМО ЇХ ===
/**
* Знаходить всі аудіокниги заданого автора.
* Цей метод залишається у репозиторії — Specification не замінює його.
*/
@Override
public List<Audiobook> findByAuthorId(UUID authorId) {
List<Audiobook> books = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL_FIND_BY_AUTHOR_ID)) {
stmt.setObject(1, authorId);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException("Помилка findByAuthorId для: " + authorId, e);
}
return books;
}
/**
* Знаходить всі аудіокниги заданого жанру за назвою.
* Цей метод також залишається — він є зручним для простих запитів.
*/
@Override
public List<Audiobook> findByGenreName(String genreName) {
List<Audiobook> books = new ArrayList<>();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(SQL_FIND_BY_GENRE_NAME)) {
stmt.setString(1, genreName);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(mapRow(rs));
}
}
} catch (SQLException e) {
throw new DatabaseException("Помилка findByGenreName для: " + genreName, e);
}
return books;
}
// Метод findAll(Specification) успадкований від AbstractJdbcRepository
// і використовує getSelectAllSql() + spec.toSql()
}
Ключові архітектурні рішення:
- Рядки 31–42 (
SQL_SELECT_BASE): базовий SELECT містить усі поля зі схеми БД —duration,release_year,cover_image_path. Псевдоніми таблиць (ab,a,g) відповідають тим, що використовуються у специфікаціях. - Рядки 175–197 (
findByAuthorId): специфічний метод залишається у репозиторії. Specification Pattern не замінює його — він додає гнучкість для складних запитів, але прості запити залишаються простими. - Рядки 202–220 (
findByGenreName): аналогічно — зручний метод для частого сценарію. Якщо потрібен складний запит (жанр + рік + тривалість), використовуєтьсяfindAll(Specification). - Рядок 223 (коментар): метод
findAll(Specification<Audiobook> spec)успадкований відAbstractJdbcRepositoryі автоматично працює завдякиgetSelectAllSql().
Критична деталь: рядок 31 (SQL_SELECT_BASE) містить псевдонім g.name AS genre_name. Саме цей псевдонім використовується у GenreSpecification.toSql() ("g.name = ?"). Якби псевдоніми не збігалися — SQL-запит був би некоректним.
Демонстрація: Складні запити через композицію
Тепер продемонструємо, як Specification Pattern вирішує проблему комбінаторного вибуху методів пошуку, використовуючи реальні поля зі схеми БД.
package com.example.audiobook;
import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.domain.Audiobook;
import com.example.audiobook.repository.AudiobookRepository;
import com.example.audiobook.repository.jdbc.JdbcAudiobookRepository;
import com.example.audiobook.specification.Specification;
import com.example.audiobook.specification.audiobook.*;
import java.util.List;
public class Main {
public static void main(String[] args) {
ConnectionManager cm = ConnectionManager.forH2("./data/audiobook_db");
AudiobookRepository repo = new JdbcAudiobookRepository(cm);
// === Сценарій 1: Простий запит ===
System.out.println("=== Аудіокниги жанру 'Поезія' ===");
Specification<Audiobook> poetry = new GenreSpecification("Поезія");
List<Audiobook> result1 = repo.findAll(poetry);
System.out.println("Знайдено: " + result1.size());
// === Сценарій 2: Композиція через AND ===
System.out.println("\n=== Поезія, опублікована після 2020 року ===");
Specification<Audiobook> after2020 = new YearRangeSpecification(2020, null);
Specification<Audiobook> query2 = poetry.and(after2020);
List<Audiobook> result2 = repo.findAll(query2);
System.out.println("Знайдено: " + result2.size());
// === Сценарій 3: Композиція через OR ===
System.out.println("\n=== Поезія АБО проза ===");
Specification<Audiobook> prose = new GenreSpecification("Проза");
Specification<Audiobook> poetryOrProse = poetry.or(prose);
List<Audiobook> result3 = repo.findAll(poetryOrProse);
System.out.println("Знайдено: " + result3.size());
// === Сценарій 4: Складна композиція ===
System.out.println("\n=== (Поезія АБО проза) І (2020-2023) І (тривалість 3600-7200 сек) ===");
Specification<Audiobook> yearRange = new YearRangeSpecification(2020, 2023);
Specification<Audiobook> durationRange = new DurationRangeSpecification(3600, 7200); // 1-2 години
Specification<Audiobook> complexQuery = poetryOrProse
.and(yearRange)
.and(durationRange);
List<Audiobook> result4 = repo.findAll(complexQuery);
System.out.println("Знайдено: " + result4.size());
// Виведемо згенерований SQL для діагностики
System.out.println("\nЗгенерований SQL WHERE:");
System.out.println(complexQuery.toSql());
System.out.println("\nПараметри:");
System.out.println(complexQuery.getParameters());
// === Сценарій 5: Динамічна побудова запиту ===
System.out.println("\n=== Динамічний фільтр (користувацький ввід) ===");
// Імітація користувацького вводу
String userGenre = "Фантастика"; // може бути null
Integer userMinYear = 2015; // може бути null
Integer userMaxDuration = 10800; // може бути null (3 години у секундах)
Specification<Audiobook> dynamicQuery = buildDynamicQuery(
userGenre, userMinYear, userMaxDuration
);
List<Audiobook> result5 = repo.findAll(dynamicQuery);
System.out.println("Знайдено: " + result5.size());
// === Сценарій 6: Пошук за назвою + жанр ===
System.out.println("\n=== Назва містить 'Кобзар' І жанр 'Поезія' ===");
Specification<Audiobook> titleContains = new TitleContainsSpecification("Кобзар");
Specification<Audiobook> query6 = titleContains.and(poetry);
List<Audiobook> result6 = repo.findAll(query6);
System.out.println("Знайдено: " + result6.size());
cm.close();
}
/**
* Будує специфікацію динамічно на основі користувацького вводу.
* Якщо параметр null — він не включається у запит.
*/
private static Specification<Audiobook> buildDynamicQuery(
String genre, Integer minYear, Integer maxDuration) {
// Початкова специфікація — завжди true (1=1)
Specification<Audiobook> spec = new AlwaysTrueSpecification<>();
if (genre != null) {
spec = spec.and(new GenreSpecification(genre));
}
if (minYear != null) {
spec = spec.and(new YearRangeSpecification(minYear, null));
}
if (maxDuration != null) {
spec = spec.and(new DurationRangeSpecification(null, maxDuration));
}
return spec;
}
}
Результат виконання:
Ключові спостереження:
- Рядок 29 (
poetry.and(after2020)): дві специфікації комбінуються через fluent API. Жодного нового методу у репозиторії не потрібно. - Рядок 36 (
poetry.or(prose)): логічне OR створює нову специфікацію, що може бути далі скомбінована через AND. - Рядок 57 (згенерований SQL): дужки автоматично розставлені правильно завдяки
AndSpecificationтаOrSpecification. Використовуються реальні поля:ab.release_year,ab.duration,g.name. - Рядки 90–105 (
buildDynamicQuery): динамічна побудова запиту на основі користувацького вводу. Якщо параметрnull— він не включається у WHERE-умову. - Рядок 78 (пошук за назвою):
TitleContainsSpecificationдемонструє роботу з текстовими полями черезLIKE.
AlwaysTrueSpecification: Нейтральний елемент композиції
Для динамічних запитів корисна спеціальна специфікація, що завжди повертає true:
package com.example.audiobook.specification;
import java.util.Collections;
import java.util.List;
/**
* Специфікація, що завжди задоволена (нейтральний елемент для AND).
* <p>
* SQL: {@code 1=1} (завжди true).
* <p>
* Використовується як початкова точка для динамічної побудови запитів:
* <pre>{@code
* Specification<T> spec = new AlwaysTrueSpecification<>();
* if (condition1) spec = spec.and(new SomeSpec());
* if (condition2) spec = spec.and(new OtherSpec());
* }</pre>
*/
public class AlwaysTrueSpecification<T> implements Specification<T> {
@Override
public boolean isSatisfiedBy(T candidate) {
return true; // завжди задоволена
}
@Override
public String toSql() {
return "1=1"; // SQL-константа, що завжди true
}
@Override
public List<Object> getParameters() {
return Collections.emptyList(); // немає параметрів
}
}
Ця специфікація є нейтральним елементом для операції AND: AlwaysTrue AND X = X. Це дозволяє писати динамічні запити без спеціальної обробки першого елемента.
Порівняння: До і після Specification Pattern
Підсумуємо, що змінилося у архітектурі після впровадження Specification Pattern:
// Репозиторій містить десятки спеціалізованих методів
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
List<Audiobook> findByAuthorId(UUID authorId);
List<Audiobook> findByGenreName(String genreName);
// Для кожної комбінації критеріїв — окремий метод
List<Audiobook> findByGenreAndYear(String genre, Integer year);
List<Audiobook> findByGenreAndYearRange(String genre, Integer minYear, Integer maxYear);
List<Audiobook> findByGenreAndMinDuration(String genre, Integer minDuration);
List<Audiobook> findByYearAndDuration(Integer year, Integer minDuration);
List<Audiobook> findByGenreAndYearAndDuration(String genre, Integer year, Integer duration);
// ... ще десятки методів для всіх комбінацій
}
// Клієнтський код жорстко прив'язаний до методів
List<Audiobook> books = repo.findByGenreAndYearAndDuration(
"Поезія", 2020, 3600
);
// Нова комбінація критеріїв = новий метод у репозиторії
// Репозиторій зберігає специфічні методи + додає універсальний
public interface AudiobookRepository extends Repository<Audiobook, UUID> {
// Специфічні методи — ЗАЛИШАЮТЬСЯ для простих запитів
List<Audiobook> findByAuthorId(UUID authorId);
List<Audiobook> findByGenreName(String genreName);
// Універсальний метод для складних запитів — ДОДАЄТЬСЯ
List<Audiobook> findAll(Specification<Audiobook> spec);
}
// Клієнтський код будує запит через композицію
Specification<Audiobook> spec = new GenreSpecification("Поезія")
.and(new YearRangeSpecification(2020, null))
.and(new DurationRangeSpecification(3600, null));
List<Audiobook> books = repo.findAll(spec);
// Нова комбінація = нова композиція існуючих специфікацій
// Репозиторій НЕ змінюється
| Характеристика | Без Specification | З Specification |
|---|---|---|
| Кількість методів у репозиторії | O(2ⁿ) для n критеріїв | O(1) — один метод + специфічні |
| Повторне використання логіки | ❌ Дублювання SQL | ✅ Специфікації багаторазові |
| Динамічні запити | ❌ Складно | ✅ Природно |
| Тестування бізнес-правил | ❌ Потребує БД | ✅ In-memory isSatisfiedBy |
| Читабельність клієнтського коду | ❌ Довгі імена методів | ✅ Fluent API |
| Прості запити | ✅ Зручні методи | ✅ Зберігаються + Specification |
Проблеми та обмеження Specification Pattern
Незважаючи на переваги, Specification Pattern має низку обмежень, що стають помітними у реальних проєктах:
Проблема 1: Жорстка прив'язка до структури SELECT
Специфікації генерують фрагменти SQL, що залежать від псевдонімів стовпців у базовому SELECT-запиті. Розглянемо GenreSpecification:
@Override
public String toSql() {
return "genre_name = ?"; // Припускає, що є псевдонім genre_name
}
Якщо у JdbcAudiobookRepository змінити псевдонім з g.name AS genre_name на g.name AS genre, всі специфікації, що використовують genre_name, стануть некоректними. Це порушує інкапсуляцію: специфікація (доменний шар) знає про деталі SQL-запиту (інфраструктурний шар).
Проблема 2: Складність із JOIN
Якщо специфікація потребує JOIN до таблиці, що не включена у базовий SELECT, виникає конфлікт. Наприклад, специфікація «автор має більше 10 опублікованих книг» потребує підзапиту або додаткового JOIN:
SELECT ... FROM audiobooks ab
JOIN authors a ON ab.author_id = a.id
WHERE a.id IN (
SELECT author_id FROM audiobooks GROUP BY author_id HAVING COUNT(*) > 10
)
Специфікація не може «додати» JOIN до базового SELECT — вона генерує лише WHERE-умову. Це обмежує виразність патерну.
Проблема 3: Відсутність типобезпеки
Специфікації генерують SQL як рядки. Помилка у назві стовпця виявляється лише під час виконання:
@Override
public String toSql() {
return "genreName = ?"; // Помилка: має бути genre_name
}
// Компілятор не виявить помилку — SQLException під час виконання
Сучасні ORM (Hibernate Criteria API, JPA Specification, jOOQ) вирішують це через type-safe query builders, але це вимагає відмови від чистого JDBC.
Проблема 4: Складність із Window Functions та UNION
Specification Pattern добре працює для WHERE-умов (включно з підзапитами та агрегаціями у WHERE), але має обмеження для:
- Window functions:
ROW_NUMBER() OVER (PARTITION BY genre ORDER BY release_year)— не є частиною WHERE - UNION/INTERSECT: комбінування результатів кількох SELECT-запитів
- GROUP BY з агрегацією у SELECT:
SELECT genre, COUNT(*) GROUP BY genre— змінює структуру ResultSet
Для таких сценаріїв потрібні спеціалізовані методи у репозиторії або Query Object Pattern.
Проблема 5: Дублювання логіки між isSatisfiedBy та toSql
Кожна специфікація реалізує одну і ту ж логіку двічі — для Java і для SQL:
// Java-версія
@Override
public boolean isSatisfiedBy(Audiobook candidate) {
return candidate.getPrice().compareTo(maxPrice) <= 0;
}
// SQL-версія
@Override
public String toSql() {
return "price <= ?";
}
Якщо логіка змінюється (наприклад, додається перевірка price > 0), треба оновити обидва методи. Це джерело помилок.
isSatisfiedBy. In-memory фільтрація виконується через окремі предикати (Predicate<T> у Java 8+), що не дублюють SQL-логіку.Альтернативи та еволюція патерну
Specification Pattern є лише одним із підходів до вирішення проблеми динамічних запитів. Розглянемо альтернативи:
Criteria API (JPA)
Hibernate/JPA Criteria API:
- Type-safe query builder
- Генерує SQL автоматично
- Підтримує JOIN, підзапити, агрегації
Недоліки:
- Складний API
- Прив'язка до JPA
- Повільніший за нативний SQL
QueryDSL / jOOQ
Type-safe SQL builders:
- Генерують Java-класи зі схеми БД
- Повна виразність SQL
- Compile-time перевірка
Недоліки:
- Додаткова залежність
- Кодогенерація
- Крива навчання
Specification (Spring Data)
Spring Data JPA Specification:
- Інтеграція з JPA Criteria
- Стандартний підхід у Spring-екосистемі
- Підтримка пагінації
Недоліки:
- Прив'язка до Spring
- Складність для нетривіальних запитів
Для чистого JDBC-проєкту без ORM наш підхід (SQL Specification) є оптимальним компромісом між гнучкістю та складністю. Він не вимагає додаткових залежностей і дає повний контроль над SQL.
Підсумок
Specification Pattern вирішує фундаментальну проблему enterprise-розробки: як виразити складні бізнес-правила у вигляді композиційних об'єктів, що можуть бути повторно використані у різних контекстах — пошуку, валідації, звітах.
Ключові досягнення патерну:
1. Усунення комбінаторного вибуху методів. Замість 2ⁿ методів для n критеріїв пошуку — один метод findAll(Specification<T>) і композиція специфікацій через and(), or(), not().
2. Повторне використання бізнес-правил. Специфікація «аудіокнига доступна для покупки» виражена один раз і використовується скрізь — у пошуку, валідації, звітах. Зміна правила вимагає правки в одному місці.
3. Читабельність клієнтського коду. Fluent API (affordable.and(ukrainian).and(after2020)) читається як природна мова і виражає намір, а не механізм.
4. Тестованість без бази даних. Метод isSatisfiedBy() дозволяє тестувати логіку специфікацій через прості unit-тести з mock-об'єктами.
5. Гнучкість для динамічних запитів. Побудова запиту на основі користувацького вводу стає тривіальною — просто комбінуємо специфікації через and().
Але патерн не є срібною кулею. Він має обмеження:
- Жорстка прив'язка до структури SELECT-запиту (псевдоніми стовпців)
- Складність із JOIN та підзапитами
- Відсутність типобезпеки (помилки виявляються під час виконання)
- Обмежена виразність для агрегацій, window functions, UNION
- Дублювання логіки між
isSatisfiedByтаtoSql
Для складних запитів, що виходять за межі простих WHERE-умов, доводиться повертатися до спеціалізованих методів у репозиторії або використовувати більш потужні інструменти (Criteria API, QueryDSL, jOOQ).
У наступних статтях ми розглянемо патерни, що доповнюють Specification: Query Object (для складних запитів із агрегаціями), Criteria Builder (type-safe альтернатива) та інтеграцію специфікацій із Unit of Work для транзакційної узгодженості.
Завдання
Реалізуйте три специфікації для сутності Author:
AuthorWithBioSpecification— автор має біографію (полеbioнеnullі не порожнє)AuthorLastNameStartsWithSpecification— прізвище автора починається з заданого підрядкаAuthorWithPublishedBooksSpecification— автор має хоча б одну опубліковану аудіокнигу
Підказка для третьої специфікації: потрібен підзапит або EXISTS-умова.
Використовуючи існуючі специфікації, побудуйте запит:
«Знайти аудіокниги жанру 'Фантастика' або 'Детектив', опубліковані у 2018-2023 роках, з тривалістю від 5400 до 10800 секунд (1.5-3 години)».
Виведіть згенерований SQL та параметри.
Реалізуйте метод buildFilterFromRequest(FilterRequest request), що будує специфікацію на основі HTTP-запиту:
class FilterRequest {
String genre; // опціонально
Integer minYear; // опціонально
Integer maxYear; // опціонально
Integer minDuration; // опціонально (у секундах)
Integer maxDuration; // опціонально (у секундах)
String titlePart; // опціонально (пошук у назві)
}
Якщо параметр null або порожній — він не включається у запит.
Створіть запит: «Знайти аудіокниги, що НЕ є поезією, з тривалістю НЕ більше 7200 секунд (2 години), і опубліковані після 2015 року».
Використовуйте метод not() для інверсії умов.
Реалізуйте PopularAuthorSpecification — автор, у якого більше 5 опублікованих аудіокниг.
SQL має містити підзапит:
WHERE author_id IN (
SELECT author_id
FROM audiobooks
GROUP BY author_id
HAVING COUNT(*) > 5
)
Підказка: toSql() може повертати складні конструкції, не лише прості умови.
Створіть специфікацію RecentAudiobooksSpecification, що знаходить аудіокниги, опубліковані за останні N років від поточної дати.
Підказка: використовуйте SQL-функцію EXTRACT(YEAR FROM CURRENT_DATE) або YEAR(CURRENT_DATE) залежно від діалекту БД.
Додаткові матеріали
📖 Domain-Driven Design
📖 Patterns of Enterprise Application Architecture
Generic Repository через Java Reflection: анотації та динамічний SQL
Усунення дублювання між JdbcAuthorRepository, JdbcGenreRepository та JdbcAudiobookRepository через власні анотації @Table, @Column, @Id та Java Reflection API. Динамічна генерація INSERT/UPDATE/SELECT/DELETE без жодного SQL у коді сутностей. Порівняння з JPA і аналіз обмежень рефлексійного підходу.
Розширені можливості Specification Pattern: підзапити, агрегації та гібридний підхід
Від простих WHERE-умов до складних підзапитів: реалізація специфікацій з EXISTS, IN (SELECT), HAVING, агрегатними функціями. Порівняння підходів: чисті специфікації vs спеціалізовані методи репозиторію. Коли використовувати що.