Spring Data JPA Specificationは、動的検索条件を型安全かつ再利用可能な形で構築するための強力なAPIです。従来の@Queryアノテーションや命名規則ベースのクエリメソッドでは対応が難しい複雑な検索フォームや、実行時に決定される検索条件に対して、Specificationパターンは非常に有効な解決策を提供します。本記事では、JpaSpecificationExecutorの導入から、CriteriaBuilder/CriteriaQuery/Rootを使った条件構築、複数Specificationの合成パターン、そして複雑な検索フォームへの実践的な適用例まで、動的クエリ構築に必要な知識を体系的に解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Boot 3.4.xのデフォルト)
データベース H2 Database(開発・テスト用)またはPostgreSQL
ビルドツール Maven または Gradle

事前に以下の知識があることを前提とします。

  • Spring Data JPAの基本(Entity定義、JpaRepository)
  • JPQLまたはネイティブクエリの基本的な理解
  • Javaのラムダ式とメソッド参照

なぜSpecificationが必要なのか

Spring Data JPAでは、クエリメソッドの命名規則や@Queryアノテーションを使って検索処理を実装できます。しかし、以下のようなケースでは限界があります。

課題 命名規則クエリの問題 Specificationの解決策
条件の組み合わせ爆発 findByNameAndStatusAndCategoryAndPriceGreaterThan...のように長大化 個別のSpecificationを合成して対応
実行時に条件が決まる null許容の引数が増え、分岐が複雑化 条件がnullなら適用しない動的構築
条件の再利用 同一条件を複数メソッドで重複定義 Specificationを共通部品化
型安全性 文字列ベースのJPQLはタイプミスに脆弱 CriteriaAPIによるコンパイル時検証

Specificationパターンは、Eric Evansの「Domain-Driven Design」で提唱された概念に基づいており、ビジネスルールを述語(Predicate)として表現し、それらを自由に組み合わせることを可能にします。

JpaSpecificationExecutorの導入

Specificationを使用するには、リポジトリインターフェースにJpaSpecificationExecutorを継承させます。

エンティティの定義

まず、検索対象となるエンティティを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.demo.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String category;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ProductStatus status;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    @Column(name = "release_date")
    private LocalDate releaseDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "supplier_id")
    private Supplier supplier;

    // コンストラクタ、getter、setterは省略
    public Product() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public ProductStatus getStatus() { return status; }
    public void setStatus(ProductStatus status) { this.status = status; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
    public LocalDate getReleaseDate() { return releaseDate; }
    public void setReleaseDate(LocalDate releaseDate) { this.releaseDate = releaseDate; }
    public Supplier getSupplier() { return supplier; }
    public void setSupplier(Supplier supplier) { this.supplier = supplier; }
}
1
2
3
4
5
6
7
8
package com.example.demo.entity;

public enum ProductStatus {
    DRAFT,      // 下書き
    ACTIVE,     // 販売中
    SUSPENDED,  // 一時停止
    DISCONTINUED // 販売終了
}

リポジトリの定義

JpaSpecificationExecutor<T>を追加で継承します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends 
        JpaRepository<Product, Long>, 
        JpaSpecificationExecutor<Product> {
    // JpaSpecificationExecutorを継承するだけで、以下のメソッドが使用可能になる
    // - findOne(Specification<T> spec)
    // - findAll(Specification<T> spec)
    // - findAll(Specification<T> spec, Pageable pageable)
    // - findAll(Specification<T> spec, Sort sort)
    // - count(Specification<T> spec)
    // - exists(Specification<T> spec)
    // - delete(Specification<T> spec)
}

Specificationインターフェースの実装

Specificationインターフェースは、toPredicateメソッドを実装する関数型インターフェースです。

基本構造

1
2
3
4
5
public interface Specification<T> extends Serializable {
    Predicate toPredicate(Root<T> root, 
                          CriteriaQuery<?> query, 
                          CriteriaBuilder criteriaBuilder);
}

各パラメータの役割は以下のとおりです。

パラメータ 役割
Root<T> クエリの対象となるエンティティのルート。フィールドへのパス取得に使用
CriteriaQuery<?> クエリ全体を表現。DISTINCT、ORDER BY、GROUP BYなどの設定に使用
CriteriaBuilder 条件(Predicate)を構築するためのファクトリ

単純な条件の実装

カテゴリで絞り込むSpecificationを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.demo.specification;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.domain.Specification;

public class ProductSpecifications {

    /**
     * 指定カテゴリに一致する商品を検索
     */
    public static Specification<Product> hasCategory(String category) {
        return (root, query, criteriaBuilder) -> {
            if (category == null || category.isBlank()) {
                return criteriaBuilder.conjunction(); // 常にtrue(条件なし)
            }
            return criteriaBuilder.equal(root.get("category"), category);
        };
    }

    /**
     * 商品名に指定文字列を含む商品を検索(部分一致)
     */
    public static Specification<Product> nameContains(String keyword) {
        return (root, query, criteriaBuilder) -> {
            if (keyword == null || keyword.isBlank()) {
                return criteriaBuilder.conjunction();
            }
            return criteriaBuilder.like(
                criteriaBuilder.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }

    /**
     * 指定価格以上の商品を検索
     */
    public static Specification<Product> priceGreaterThanOrEqual(BigDecimal minPrice) {
        return (root, query, criteriaBuilder) -> {
            if (minPrice == null) {
                return criteriaBuilder.conjunction();
            }
            return criteriaBuilder.greaterThanOrEqualTo(root.get("price"), minPrice);
        };
    }

    /**
     * 指定価格以下の商品を検索
     */
    public static Specification<Product> priceLessThanOrEqual(BigDecimal maxPrice) {
        return (root, query, criteriaBuilder) -> {
            if (maxPrice == null) {
                return criteriaBuilder.conjunction();
            }
            return criteriaBuilder.lessThanOrEqualTo(root.get("price"), maxPrice);
        };
    }
}

CriteriaBuilderの主要メソッド

CriteriaBuilderは、様々な条件を構築するための豊富なメソッドを提供します。

比較演算子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class ProductSpecifications {

    // 等価比較
    public static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) -> 
            status == null ? cb.conjunction() : cb.equal(root.get("status"), status);
    }

    // 不等価比較
    public static Specification<Product> statusNotEqual(ProductStatus status) {
        return (root, query, cb) -> 
            status == null ? cb.conjunction() : cb.notEqual(root.get("status"), status);
    }

    // 大なり
    public static Specification<Product> stockGreaterThan(Integer minStock) {
        return (root, query, cb) -> 
            minStock == null ? cb.conjunction() : cb.greaterThan(root.get("stockQuantity"), minStock);
    }

    // 小なり
    public static Specification<Product> stockLessThan(Integer maxStock) {
        return (root, query, cb) -> 
            maxStock == null ? cb.conjunction() : cb.lessThan(root.get("stockQuantity"), maxStock);
    }

    // 範囲(BETWEEN)
    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) {
                return cb.conjunction();
            }
            if (min != null && max != null) {
                return cb.between(root.get("price"), min, max);
            }
            if (min != null) {
                return cb.greaterThanOrEqualTo(root.get("price"), min);
            }
            return cb.lessThanOrEqualTo(root.get("price"), max);
        };
    }
}

文字列操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ProductSpecifications {

    // LIKE(前方一致)
    public static Specification<Product> nameStartsWith(String prefix) {
        return (root, query, cb) -> 
            prefix == null ? cb.conjunction() : 
                cb.like(root.get("name"), prefix + "%");
    }

    // LIKE(後方一致)
    public static Specification<Product> nameEndsWith(String suffix) {
        return (root, query, cb) -> 
            suffix == null ? cb.conjunction() : 
                cb.like(root.get("name"), "%" + suffix);
    }

    // 大文字小文字を無視した比較
    public static Specification<Product> categoryIgnoreCase(String category) {
        return (root, query, cb) -> 
            category == null ? cb.conjunction() : 
                cb.equal(cb.lower(root.get("category")), category.toLowerCase());
    }
}

NULL判定とコレクション

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ProductSpecifications {

    // NULLチェック
    public static Specification<Product> hasReleaseDate() {
        return (root, query, cb) -> cb.isNotNull(root.get("releaseDate"));
    }

    public static Specification<Product> noReleaseDate() {
        return (root, query, cb) -> cb.isNull(root.get("releaseDate"));
    }

    // IN句
    public static Specification<Product> categoryIn(List<String> categories) {
        return (root, query, cb) -> {
            if (categories == null || categories.isEmpty()) {
                return cb.conjunction();
            }
            return root.get("category").in(categories);
        };
    }

    // NOT IN句
    public static Specification<Product> categoryNotIn(List<String> categories) {
        return (root, query, cb) -> {
            if (categories == null || categories.isEmpty()) {
                return cb.conjunction();
            }
            return cb.not(root.get("category").in(categories));
        };
    }
}

複数Specificationの合成

Specificationの真価は、複数の条件を自由に組み合わせられる点にあります。

and / or による合成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

import static com.example.demo.specification.ProductSpecifications.*;

@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;

    public ProductSearchService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * AND条件での検索
     * 「電化製品」カテゴリかつ「価格が10000円以上」
     */
    public List<Product> findElectronicsExpensive() {
        Specification<Product> spec = hasCategory("電化製品")
            .and(priceGreaterThanOrEqual(new BigDecimal("10000")));
        
        return productRepository.findAll(spec);
    }

    /**
     * OR条件での検索
     * 「販売中」または「在庫が100以上」
     */
    public List<Product> findActiveOrWellStocked() {
        Specification<Product> spec = hasStatus(ProductStatus.ACTIVE)
            .or(stockGreaterThan(100));
        
        return productRepository.findAll(spec);
    }

    /**
     * 複合条件での検索
     * (カテゴリが「食品」AND 価格が500円以下) OR ステータスが「DRAFT」
     */
    public List<Product> findCheapFoodOrDraft() {
        Specification<Product> cheapFood = hasCategory("食品")
            .and(priceLessThanOrEqual(new BigDecimal("500")));
        
        Specification<Product> draft = hasStatus(ProductStatus.DRAFT);
        
        return productRepository.findAll(cheapFood.or(draft));
    }
}

Specification.where()による可読性向上

Specification.where()を起点にすることで、nullセーフかつ可読性の高いコードになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public List<Product> searchWithNullSafety(String category, 
                                          BigDecimal minPrice, 
                                          BigDecimal maxPrice) {
    Specification<Product> spec = Specification
        .where(hasCategory(category))
        .and(priceGreaterThanOrEqual(minPrice))
        .and(priceLessThanOrEqual(maxPrice));
    
    return productRepository.findAll(spec);
}

Specification.where(null)はnullを安全に扱い、後続のand()or()が適切に動作します。

not()による否定条件

1
2
3
4
5
6
public List<Product> findNonDiscontinued() {
    Specification<Product> spec = Specification
        .not(hasStatus(ProductStatus.DISCONTINUED));
    
    return productRepository.findAll(spec);
}

JOINを使った関連エンティティの検索

関連エンティティの属性で絞り込む場合は、Root.join()を使用します。

Supplierエンティティの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.entity;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "suppliers")
public class Supplier {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String country;

    @OneToMany(mappedBy = "supplier")
    private List<Product> products = new ArrayList<>();

    // コンストラクタ、getter、setterは省略
}

JOINを使ったSpecification

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.demo.specification;

import com.example.demo.entity.Product;
import com.example.demo.entity.Supplier;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import org.springframework.data.jpa.domain.Specification;

public class ProductSpecifications {

    /**
     * 指定国のサプライヤーの商品を検索
     */
    public static Specification<Product> supplierCountry(String country) {
        return (root, query, cb) -> {
            if (country == null || country.isBlank()) {
                return cb.conjunction();
            }
            // INNER JOINでsupplierテーブルと結合
            Join<Product, Supplier> supplierJoin = root.join("supplier", JoinType.INNER);
            return cb.equal(supplierJoin.get("country"), country);
        };
    }

    /**
     * サプライヤー名に指定文字列を含む商品を検索
     */
    public static Specification<Product> supplierNameContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) {
                return cb.conjunction();
            }
            Join<Product, Supplier> supplierJoin = root.join("supplier", JoinType.LEFT);
            return cb.like(
                cb.lower(supplierJoin.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }

    /**
     * DISTINCTを適用(JOINによる重複を排除)
     */
    public static Specification<Product> distinctProducts() {
        return (root, query, cb) -> {
            query.distinct(true);
            return cb.conjunction();
        };
    }
}

使用例

1
2
3
4
5
6
7
8
public List<Product> findProductsFromJapaneseSuppliers() {
    Specification<Product> spec = Specification
        .where(supplierCountry("日本"))
        .and(hasStatus(ProductStatus.ACTIVE))
        .and(distinctProducts());
    
    return productRepository.findAll(spec);
}

複雑な検索フォームへの適用例

実務では、複数の検索条件を持つ検索フォームに対応することが多くあります。以下は、商品検索画面を想定した実装例です。

検索条件DTOの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.demo.dto;

import com.example.demo.entity.ProductStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public record ProductSearchCriteria(
    String keyword,              // 商品名キーワード
    List<String> categories,     // カテゴリ(複数選択)
    ProductStatus status,        // ステータス
    BigDecimal minPrice,         // 最低価格
    BigDecimal maxPrice,         // 最高価格
    Integer minStock,            // 最低在庫数
    LocalDate releaseDateFrom,   // 発売日(開始)
    LocalDate releaseDateTo,     // 発売日(終了)
    String supplierCountry,      // サプライヤー国
    Boolean inStockOnly          // 在庫ありのみ
) {
    // 空の検索条件を返すファクトリメソッド
    public static ProductSearchCriteria empty() {
        return new ProductSearchCriteria(
            null, null, null, null, null, null, null, null, null, null
        );
    }
}

検索条件を組み立てるSpecificationBuilder

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
package com.example.demo.specification;

import com.example.demo.dto.ProductSearchCriteria;
import com.example.demo.entity.Product;
import com.example.demo.entity.ProductStatus;
import com.example.demo.entity.Supplier;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import org.springframework.data.jpa.domain.Specification;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public class ProductSpecificationBuilder {

    /**
     * 検索条件DTOからSpecificationを構築
     */
    public static Specification<Product> fromCriteria(ProductSearchCriteria criteria) {
        return Specification
            .where(nameContains(criteria.keyword()))
            .and(categoryIn(criteria.categories()))
            .and(hasStatus(criteria.status()))
            .and(priceBetween(criteria.minPrice(), criteria.maxPrice()))
            .and(stockGreaterThanOrEqual(criteria.minStock()))
            .and(releaseDateBetween(criteria.releaseDateFrom(), criteria.releaseDateTo()))
            .and(supplierCountry(criteria.supplierCountry()))
            .and(inStockOnly(criteria.inStockOnly()));
    }

    private static Specification<Product> nameContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) {
                return cb.conjunction();
            }
            return cb.like(
                cb.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }

    private static Specification<Product> categoryIn(List<String> categories) {
        return (root, query, cb) -> {
            if (categories == null || categories.isEmpty()) {
                return cb.conjunction();
            }
            return root.get("category").in(categories);
        };
    }

    private static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) -> 
            status == null ? cb.conjunction() : cb.equal(root.get("status"), status);
    }

    private static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) {
                return cb.conjunction();
            }
            if (min != null && max != null) {
                return cb.between(root.get("price"), min, max);
            }
            if (min != null) {
                return cb.greaterThanOrEqualTo(root.get("price"), min);
            }
            return cb.lessThanOrEqualTo(root.get("price"), max);
        };
    }

    private static Specification<Product> stockGreaterThanOrEqual(Integer minStock) {
        return (root, query, cb) -> 
            minStock == null ? cb.conjunction() : 
                cb.greaterThanOrEqualTo(root.get("stockQuantity"), minStock);
    }

    private static Specification<Product> releaseDateBetween(LocalDate from, LocalDate to) {
        return (root, query, cb) -> {
            if (from == null && to == null) {
                return cb.conjunction();
            }
            if (from != null && to != null) {
                return cb.between(root.get("releaseDate"), from, to);
            }
            if (from != null) {
                return cb.greaterThanOrEqualTo(root.get("releaseDate"), from);
            }
            return cb.lessThanOrEqualTo(root.get("releaseDate"), to);
        };
    }

    private static Specification<Product> supplierCountry(String country) {
        return (root, query, cb) -> {
            if (country == null || country.isBlank()) {
                return cb.conjunction();
            }
            Join<Product, Supplier> supplierJoin = root.join("supplier", JoinType.LEFT);
            return cb.equal(supplierJoin.get("country"), country);
        };
    }

    private static Specification<Product> inStockOnly(Boolean inStockOnly) {
        return (root, query, cb) -> {
            if (inStockOnly == null || !inStockOnly) {
                return cb.conjunction();
            }
            return cb.greaterThan(root.get("stockQuantity"), 0);
        };
    }
}

サービス層での使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.demo.service;

import com.example.demo.dto.ProductSearchCriteria;
import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import com.example.demo.specification.ProductSpecificationBuilder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;

    public ProductSearchService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * 検索条件に基づいて商品をページング検索
     */
    public Page<Product> search(ProductSearchCriteria criteria, Pageable pageable) {
        Specification<Product> spec = ProductSpecificationBuilder.fromCriteria(criteria);
        return productRepository.findAll(spec, pageable);
    }

    /**
     * 検索条件に一致する商品数を取得
     */
    public long count(ProductSearchCriteria criteria) {
        Specification<Product> spec = ProductSpecificationBuilder.fromCriteria(criteria);
        return productRepository.count(spec);
    }

    /**
     * 検索条件に一致する商品が存在するか確認
     */
    public boolean exists(ProductSearchCriteria criteria) {
        Specification<Product> spec = ProductSpecificationBuilder.fromCriteria(criteria);
        return productRepository.exists(spec);
    }
}

コントローラーでの使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.demo.controller;

import com.example.demo.dto.ProductSearchCriteria;
import com.example.demo.entity.Product;
import com.example.demo.service.ProductSearchService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductSearchService productSearchService;

    public ProductController(ProductSearchService productSearchService) {
        this.productSearchService = productSearchService;
    }

    @GetMapping("/search")
    public ResponseEntity<Page<Product>> search(
            ProductSearchCriteria criteria,
            @PageableDefault(size = 20, sort = "name") Pageable pageable) {
        
        Page<Product> result = productSearchService.search(criteria, pageable);
        return ResponseEntity.ok(result);
    }
}

Spring Data JPA 4.0のPredicateSpecification

Spring Data JPA 4.0(Spring Boot 4.x)では、より軽量なPredicateSpecificationインターフェースが導入されました。これはCriteriaQueryへの依存を排除し、より柔軟な合成を可能にします。

1
2
3
4
// PredicateSpecificationの定義
public interface PredicateSpecification<T> {
    Predicate toPredicate(From<?, T> from, CriteriaBuilder builder);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// PredicateSpecificationを使った実装例
class ProductPredicateSpecs {

    static PredicateSpecification<Product> hasCategory(String category) {
        return (from, builder) -> 
            builder.equal(from.get("category"), category);
    }

    static PredicateSpecification<Product> priceGreaterThan(BigDecimal price) {
        return (from, builder) -> 
            builder.greaterThan(from.get("price"), price);
    }
}

// 使用例
List<Product> products = productRepository.findAll(
    hasCategory("電化製品").and(priceGreaterThan(new BigDecimal("5000")))
);

PredicateSpecificationは、SELECT、UPDATE、DELETEのいずれのクエリタイプでも使用できる汎用的なインターフェースです。

よくある誤解とアンチパターン

アンチパターン1: Specificationの乱用

Specificationは強力ですが、単純な検索には過剰です。

1
2
3
4
5
6
7
8
9
// 悪い例:単純な検索にSpecificationを使用
public List<Product> findByCategory(String category) {
    return productRepository.findAll(hasCategory(category));
}

// 良い例:クエリメソッドで十分
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(String category);
}

使い分けの指針:条件が2つ以下かつ固定の場合はクエリメソッド、3つ以上または動的に変化する場合はSpecificationを検討します。

アンチパターン2: N+1問題の見落とし

Specificationで関連エンティティを検索条件に含めても、結果取得時にN+1問題が発生することがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 問題のあるコード
public List<Product> findProductsFromJapan() {
    Specification<Product> spec = supplierCountry("日本");
    List<Product> products = productRepository.findAll(spec);
    
    // N+1問題: 各productのsupplierを個別に取得
    for (Product p : products) {
        System.out.println(p.getSupplier().getName());
    }
    return products;
}

解決策:Entity GraphまたはJPQLのFetch Joinを併用します。

1
2
3
4
5
6
7
8
@Repository
public interface ProductRepository extends 
        JpaRepository<Product, Long>, 
        JpaSpecificationExecutor<Product> {

    @EntityGraph(attributePaths = {"supplier"})
    List<Product> findAll(Specification<Product> spec);
}

アンチパターン3: 複雑すぎるSpecification

1つのSpecificationに複数の責務を持たせると、再利用性が低下します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 悪い例:1つのSpecificationに複数の条件を詰め込む
public static Specification<Product> complexSearch(
        String keyword, String category, BigDecimal minPrice, 
        BigDecimal maxPrice, ProductStatus status) {
    return (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();
        if (keyword != null) {
            predicates.add(cb.like(root.get("name"), "%" + keyword + "%"));
        }
        if (category != null) {
            predicates.add(cb.equal(root.get("category"), category));
        }
        // ... 以下続く
        return cb.and(predicates.toArray(new Predicate[0]));
    };
}

// 良い例:個別のSpecificationを合成
Specification<Product> spec = Specification
    .where(nameContains(keyword))
    .and(hasCategory(category))
    .and(priceBetween(minPrice, maxPrice))
    .and(hasStatus(status));

アンチパターン4: 文字列リテラルの多用

フィールド名を文字列で指定すると、リファクタリング時に追従できません。

1
2
3
4
5
// 悪い例:文字列リテラル
return cb.equal(root.get("category"), category);

// 良い例:JPA Metamodelを使用
return cb.equal(root.get(Product_.category), category);

JPA Metamodelを使用するには、hibernate-jpamodelgenを依存関係に追加します。

1
2
3
4
5
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
    <scope>provided</scope>
</dependency>

Fluent APIによる高度なクエリ実行

Spring Data JPA 3.x以降では、findBy(Specification, Function)メソッドを使用して、より柔軟なクエリ実行が可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// プロジェクションを使用した検索
Page<ProductSummary> page = productRepository.findBy(
    hasCategory("電化製品"),
    q -> q.as(ProductSummary.class)
          .page(PageRequest.of(0, 20, Sort.by("name")))
);

// 最初の1件を取得(ソート付き)
Optional<Product> firstProduct = productRepository.findBy(
    hasStatus(ProductStatus.ACTIVE),
    q -> q.sortBy(Sort.by("price").ascending())
          .first()
);

// 件数の取得
long count = productRepository.findBy(
    priceBetween(new BigDecimal("1000"), new BigDecimal("5000")),
    q -> q.count()
);

まとめ

Spring Data JPA Specificationを使用することで、以下のメリットが得られます。

観点 メリット
保守性 検索条件を個別のメソッドとして分離し、変更の影響範囲を限定
再利用性 同じ条件を複数のユースケースで共有可能
型安全性 CriteriaAPIにより、コンパイル時にエラーを検出
可読性 and/orによる合成で、ビジネス要件を自然に表現
テスト容易性 個別のSpecificationを単体テスト可能

実践Tipsとして以下を意識してください。

  1. 単純な検索にはクエリメソッドを使う:Specificationは動的検索のためのツール
  2. Specificationは小さく保つ:1つの条件に対して1つのSpecification
  3. null安全を徹底する:条件がnullの場合はcb.conjunction()を返す
  4. JPA Metamodelの活用:文字列リテラルを排除し、リファクタリング耐性を向上
  5. N+1問題に注意:Entity GraphやFetch Joinと組み合わせる

参考リンク