Проектування баз даних

Specification Pattern: Композиція бізнес-правил для складних запитів

Від жорстко закодованих методів пошуку до гнучких композицій: реалізація Specification Pattern за Еріком Евансом, інтеграція з Repository через SqlSpecification, логічні оператори AND/OR/NOT та побудова динамічних WHERE-умов.

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: Пошук аудіокниг

Знайти аудіокниги жанру «Поезія» або «Проза», опубліковані після 2020 року, з ціною від 100 до 500 грн, мовою «українська».

Запит 3: Динамічний фільтр

Користувач обирає фільтри у веб-інтерфейсі: жанр (опціонально), діапазон років (опціонально), мова (опціонально), максимальна ціна (опціонально). Кількість комбінацій — 2⁴ = 16 варіантів.

Наївний підхід — створити окремий метод для кожної комбінації:

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. На відміну від Repository (що є інфраструктурним патерном), 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), створюючи складні умови з простих будівельних блоків.

Loading diagram...
classDiagram
    direction TB

    class Specification~T~ {
        <<interface>>
        +isSatisfiedBy(T candidate) boolean
        +and(Specification~T~ other) Specification~T~
        +or(Specification~T~ other) Specification~T~
        +not() Specification~T~
    }

    class PriceRangeSpec {
        -BigDecimal minPrice
        -BigDecimal maxPrice
        +isSatisfiedBy(Audiobook book) boolean
    }

    class GenreSpec {
        -String genreName
        +isSatisfiedBy(Audiobook book) boolean
    }

    class LanguageSpec {
        -String language
        +isSatisfiedBy(Audiobook book) boolean
    }

    class AndSpecification~T~ {
        -Specification~T~ left
        -Specification~T~ right
        +isSatisfiedBy(T candidate) boolean
    }

    class OrSpecification~T~ {
        -Specification~T~ left
        -Specification~T~ right
        +isSatisfiedBy(T candidate) boolean
    }

    class NotSpecification~T~ {
        -Specification~T~ spec
        +isSatisfiedBy(T candidate) boolean
    }

    Specification~T~ <|.. PriceRangeSpec
    Specification~T~ <|.. GenreSpec
    Specification~T~ <|.. LanguageSpec
    Specification~T~ <|.. AndSpecification~T~
    Specification~T~ <|.. OrSpecification~T~
    Specification~T~ <|.. NotSpecification~T~

    AndSpecification~T~ o-- Specification~T~ : left
    AndSpecification~T~ o-- Specification~T~ : right
    OrSpecification~T~ o-- Specification~T~ : left
    OrSpecification~T~ o-- Specification~T~ : right
    NotSpecification~T~ o-- Specification~T~

Зверніть увагу на композиційну структуру: 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), 
    "українська"
);

Ключова перевага: репозиторій має лише один метод 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;
    }
}

Результат виконання:

java Main
$ java -cp . com.example.audiobook.Main
=== Аудіокниги жанру 'Поезія' ===
Знайдено: 23
=== Поезія, опублікована після 2020 року ===
Знайдено: 8
=== Поезія АБО проза ===
Знайдено: 67
=== (Поезія АБО проза) І (2020-2023) І (тривалість 3600-7200 сек) ===
Знайдено: 12
Згенерований SQL WHERE:
((g.name = ?) OR (g.name = ?)) AND (ab.release_year BETWEEN ? AND ?) AND (ab.duration BETWEEN ? AND ?)
Параметри:
[Поезія, Проза, 2020, 2023, 3600, 7200]
=== Динамічний фільтр (користувацький ввід) ===
Знайдено: 15
=== Назва містить 'Кобзар' І жанр 'Поезія' ===
Знайдено: 3

Ключові спостереження:

  • Рядок 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
);

// Нова комбінація критеріїв = новий метод у репозиторії
ХарактеристикаБез 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), треба оновити обидва методи. Це джерело помилок.

У production-системах Specification Pattern найчастіше використовується лише для SQL-генерації, без реалізації 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 Pattern є тактичним патерном Domain-Driven Design. Він належить до доменного шару і виражає бізнес-логіку мовою предметної області. Це принципово відрізняє його від Repository (інфраструктурний патерн) — специфікації описують «що шукати», а не «як шукати».

У наступних статтях ми розглянемо патерни, що доповнюють Specification: Query Object (для складних запитів із агрегаціями), Criteria Builder (type-safe альтернатива) та інтеграцію специфікацій із Unit of Work для транзакційної узгодженості.


Завдання


Додаткові матеріали

📖 Domain-Driven Design

Книга Еріка Еванса (2003), де вперше описано Specification Pattern у контексті DDD.

📖 Patterns of Enterprise Application Architecture

Мартін Фаулер про Repository, Unit of Work та інші патерни персистентності.

🔗 Spring Data JPA Specification

Офіційна документація Spring Data про інтеграцію Specification з JPA.

🔗 QueryDSL

Type-safe query builder для Java — альтернатива Specification Pattern.