Java

QueryDSLとは?Spring Bootでの導入手順と5.1.0・OpenFeign版7.7の選び方

QueryDSLとは?Spring Bootでの導入手順と5.1.0・OpenFeign版7.7の選び方

QueryDSLは、JPQLの文字列をJavaのメソッド呼び出しに置き換え、クエリの誤りをコンパイル時に検出するためのライブラリです。導入前に知っておきたいのは、2026年11月現在このライブラリが二系統に分かれていることです。

本家のcom.querydslはMaven Centralで2024年1月29日公開の5.1.0を最後にリリースが止まっています。更新が続いているのはOpenFeign配下のフォークio.github.openfeign.querydslで、最新は7.7(2026年9月23日)。それでもSpring Boot 4.1.2と4.2.0-RC1のBOMが管理するのは5.1.0のままなので、新しいほうを選べば済む話ではありません。この記事では選び方の基準と、Spring Boot 4での依存関係の書き方、DTO射影・動的クエリ・JOIN・一括更新の書き方を整理します。コード例はSpring Boot 4.1.2・Hibernate 7.4.13.Final・JDK 25・H2の組み合わせで実行し、出力を確かめたものです。

まとめ

  • 本家com.querydslの最新版は5.1.0(2024年1月29日)。JDK 21対応の修正はマージ済みですが、リリースされていません。
  • 更新が続いているのはOpenFeign版7.7(2026年9月23日)。Java 17以上・Jakarta EE専用で、Javaのパッケージ名はcom.querydsl.*のままです。
  • Spring Boot 4.1.2・4.2.0-RC1のBOMが管理するのは5.1.0。Spring Bootに追随するなら本家5.1.0が既定です。
  • Spring Boot 3以降はjakarta分類子付きの成果物を指定します。Mavenでは分類子を付けると<version>の明記が必須です。
  • JDK 23以降は、querydsl-aptを通常の依存に入れるだけではQクラスが生成されません。MavenはannotationProcessorPathsに書きます。
  • @Queryの綴り誤りはアプリ起動時、QueryDSLはコンパイル時に見つかります。条件が実行時に決まるクエリを抱えたときがQueryDSLの出番です。
  • update()・delete()による一括更新は永続化コンテキストを素通りします。読み込み済みのエンティティは、取り直しても古い値のままです。

QueryDSLの仕組み:Qクラスによる型安全なクエリ構築

QueryDSLはエンティティクラスをアノテーションプロセッサで走査し、Bookに対してQBookのような「Qクラス」をビルド時に生成します。クエリはこのQクラスのフィールドを起点に組み立てるため、存在しないフィールド名や型の合わない比較はコンパイルエラーになります。対象はJPAに限りません。SQLを直接組み立てるquerydsl-sql、MongoDB向けのquerydsl-mongodbなど、バックエンドごとにモジュールが分かれ、同じ書き味のAPIを提供します。どのO/Rマッパーを土台にするか自体を検討している段階なら、Java ORM(O/Rマッパー)の種類と選び方で候補を整理してから読み進めると判断が早くなります。

@Query・メソッド名クエリとの違いは誤りを検出するタイミング

Spring Data JPAでクエリを書く方法は複数あり、違いが出るのは「誤りがいつ見つかるか」と「条件を実行時に組み替えられるか」です。エンティティのフィールドtitleをtitelと誤記したクエリで、それぞれの挙動を確かめました。

書き方 誤記が見つかる時点 実行時の条件の組み替え
メソッド名クエリ アプリ起動時 不可
@Query(JPQL文字列) アプリ起動時 不可
EntityManager.createQuery その行の実行時 文字列連結で可
QueryDSL コンパイル時 可(nullの条件は無視)

@Query("select b from Book b where b.titel = :title")を持つリポジトリは、起動中にリポジトリBeanの生成で失敗し、Query validation failedとCould not resolve attribute 'titel' of 'demo.Book'を出して止まりました。Spring Data JPAが起動時にJPQLを検証するためで、本番で初めて落ちるわけではありません。一方EntityManager.createQueryに同じ文字列を渡すと、その行を実行した時点でIllegalArgumentExceptionになります。QueryDSLではbook.titelと書いた時点でコンパイルが通りません。差が大きいのはリファクタリングです。フィールド名を変えると、QueryDSLはQクラスの再生成後に変更漏れの箇所をすべてビルドエラーとして示します。静的な条件しか無い検索なら、記述量の少ないメソッド名クエリや@Queryで足ります。

Elasticsearchの「Query DSL」との同名異義

検索結果には、Elasticsearchで検索条件をJSONで記述する「Query DSL」の解説が混ざりますが、無関係な技術です。参考記事は、groupIdがcom.querydsl系かio.github.openfeign.querydslかで見分けます。

本家5.1.0とOpenFeign版7.7の選択基準

検索上位の日本語記事は5.0.0や4.1.4の時代に書かれたものが中心で、プロジェクトの分裂に触れたものは見当たりません。まず双方の現状を数字で押さえます。

本家 com.querydsl の停滞状況

Maven Centralのcom.querydsl:querydsl-jpaのメタデータでは、最新版は5.1.0で更新時刻は2024年1月29日です。GitHubのquerydsl/querydslリポジトリはアーカイブされていないものの、最後にマージされたプルリクエストは2025年2月1日で、2026年11月時点でもそれ以降はありません。JDK 21での互換性を確保するプルリクエスト(#3705)は2024年5月11日にマージされましたが、それを含むリリースは出ていません。5.1.0はこの修正より前のビルドです。公式サイトのquerydsl.comはGitHub Pagesの証明書がドメインに対応しておらず、開くと証明書エラーになります。

OpenFeign版で変わる点と据え置かれる点

項目 本家 5.1.0 OpenFeign 7.7
groupId com.querydsl io.github.openfeign.querydsl
Javaパッケージ名 com.querydsl.* com.querydsl.*(据え置き)
artifactId querydsl-jpa ほか 同一(据え置き)
必要なJDK Java 8以上 Java 17以上
名前空間 javax / jakarta を分類子で選択 6.0以降 jakarta のみ
削除モジュール なし jdo / lucene3・4・5 / hibernate-search
追加モジュール なし querydsl-r2dbc / querydsl-ksp-codegen ほか

パッケージ名とartifactIdが同じなので、乗り換えてもimport文とクエリのコードは変わりません。リリースは7.1(2025年10月21日)から7.2(2026年5月25日)まで7か月空いた後、7.3.0・7.4.0・7.5・7.6(8月19日)・7.7(9月23日)と、7.2からの4か月で6本が出ています。7.7の変更はアノテーションプロセッサの循環参照検出の修正やfetchFirstへの@Nullable付与など小さなもので、APIの互換性を崩す変更は含まれていません。

OpenFeign版へ乗り換えるときのビルド定義

// OpenFeign 版へ移す場合は groupId・バージョンに加え分類子も変わる(querydsl-jpa は分類子なし、apt は :jpa)
implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.7'
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.7:jpa'

Mavenの場合は、移行ガイドがcom.mysema.maven:apt-maven-pluginを推奨しなくなったので、後述のmaven-compiler-plugin方式に合わせます。

Spring Boot前提での既定は本家5.1.0

判断の決め手はSpring BootのBOMです。spring-boot-dependenciesは3.5系も4.1.2も、2026年10月公開の4.2.0-RC1もcom.querydsl:querydsl-bomの5.1.0を管理しています。Spring Bootに追随する運用なら既定は5.1.0です。ただしquerydsl-jpa 5.1.0はHibernate 5.4.8.Finalに対してビルドされた成果物で、Spring Boot 4.1.2ではHibernate 7.4.13.Final・Jakarta Persistence 3.2.0と同居します。この組み合わせで本記事のクエリはすべて動きましたが、Hibernate固有のAPIを直接呼ぶ箇所があるなら自前の検証を挟んでください。

フォークへ移る理由があるのは、R2DBCによるノンブロッキングアクセスやKotlin KSPによるQクラス生成が必要な場合(Kotlin向けの演算子拡張querydsl-kotlinは本家5.1.0にもあります)、あるいはバグ修正を自分で送って取り込んでもらいたい場合です。既存のJPAアプリが5.1.0で動いていてSpring Bootの更新への追随を優先するなら、移行するべきではありません。削除されたquerydsl-jdoやquerydsl-lucene5を使っているプロジェクトも同様です。フォークの作者はREADMEで「本家が保守を再開すればこのフォークはアーカイブされる可能性が高い」と書いており、長期の保守先として確定しているわけでもありません。Spring Boot側の期限と合わせた計画はSpring Bootバージョン一覧とサポート期限で確認できます。

Spring Boot 4への導入設定

以下は本家5.1.0を前提にした設定です。Spring Boot 3.x系でも同じ書き方で動きます(Gradleのプラグインの版は3.5.xに替えます)。Spring Boot 4での変更点全般はSpring Boot 4とは?最新バージョン4.1の変更点にまとめています。

Gradleでの依存関係とjakarta分類子

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.2'
    id 'io.spring.dependency-management' version '1.1.7'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // jakarta 分類子付きの成果物を指定する(Spring Boot 3 以降は必須)
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

末尾の:jakartaが分類子です。Maven Centralの5.1.0にはquerydsl-jpa-5.1.0.jarとquerydsl-jpa-5.1.0-jakarta.jarの両方があり、分類子を省くとjavax.persistenceを参照する前者が入ります。querydsl-aptにも同じ分類子が必要で、取り違えるとQクラスが生成されないか、実行時にNoClassDefFoundErrorで落ちます。Gradleで長く使われてきたcom.ewerk.gradle.plugins.querydslプラグインは不要です。提供元リポジトリは2021年4月28日を最後に更新が止まってアーカイブされ、READMEにも「Gradle 5以降とは互換性がない」と書かれています。

Mavenでversionの明記が必須になる理由

<!-- 前提: spring-boot-starter-parent 4.1.2 を親に持つ pom.xml -->
<properties>
  <java.version>21</java.version>
  <querydsl.version>5.1.0</querydsl.version>
</properties>

<dependencies>
  <dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
    <!-- classifier 付きは BOM の管理キーと一致しないため version が要る -->
    <version>${querydsl.version}</version>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <annotationProcessorPaths>
          <path>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>${querydsl.version}</version>
            <classifier>jakarta</classifier>
          </path>
          <path>
            <groupId>jakarta.persistence</groupId>
            <artifactId>jakarta.persistence-api</artifactId>
            <version>${jakarta-persistence.version}</version>
          </path>
          <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
          </path>
        </annotationProcessorPaths>
      </configuration>
    </plugin>
  </plugins>
</build>

Mavenの依存関係管理はgroupId:artifactId:type:classifierをキーに照合します。querydsl-bom 5.1.0の管理エントリには分類子が付いていないため、jakarta分類子付きの依存はBOMの管理対象と別物になります。querydsl-jpaの<version>を消して検証すると、ビルドは'dependencies.dependency.version' for com.querydsl:querydsl-jpa:jar:jakarta is missing.で止まりました。Spring Bootを使っていれば省略できるはず、と考えて詰まる典型です。${jakarta-persistence.version}と${lombok.version}は親POMが定義するプロパティで、4.1.2ではそれぞれ3.2.0と1.18.48になります。

Lombokを並べているのは、maven-compiler-pluginの公式ドキュメントが「annotationProcessorPathsを指定した場合、アノテーションプロセッサはそこに挙げた要素からのみ検出する」と定めているためです。Lombokを通常の依存として入れているプロジェクトは、ここに足さないとQueryDSL導入を境にLombokが効かなくなります。Lombok側の設定の詳細はLombokとは?読み方・使い方とアノテーション一覧、Maven・Gradleの設定を参照してください。

JDK 23以降でQクラスが生成されない原因

JDK 23で、javacはクラスパス上のアノテーションプロセッサを自動では実行しなくなりました(JDK 23リリースノート JDK-8321314)。古い記事にある「querydsl-aptをprovidedスコープの依存に入れる」書き方は、これで動かなくなります。同じプロジェクトをJDKだけ替えてコンパイルした結果が次のとおりです。

apt の指定方法 JDK 21 JDK 25
provided スコープの依存 Qクラス4個生成 生成されずコンパイル失敗
provided+-Dmaven.compiler.proc=full - Qクラス4個生成
annotationProcessorPaths Qクラス4個生成 Qクラス4個生成

JDK 25ではQBookなどが生成されないまま、それを参照する行が「シンボルを見つけられません」で失敗しました。ビルドが通っていたプロジェクトでも、JDKを21から25へ上げた途端に落ちるということです。JDK 21のビルドログに「将来のリリースのjavacでは注釈処理が無効化される可能性があります」という警告が出ていれば、同じ構成に当たります。前掲のpom.xmlのようにannotationProcessorPathsへ書けば、maven-compiler-plugin 3.15.0(Spring Boot 4.1.2が管理する版)がプロセッサパスを明示して渡すので、JDK 25でも追加設定なしで生成されます。OpenFeign版も7.6のドキュメント修正(PR #1896)で同じ書き方へ改めました。GradleのannotationProcessor設定はもともとプロセッサパスとして渡されるため、この変更の影響を受けません。

Qクラスが生成されないときの確認箇所

  • 分類子の付け忘れ:querydsl-apt側にjakartaが付いているか。Spring Boot 3以降で最も多い原因です。
  • JDK 23以降でのプロセッサ指定:Mavenでprovided依存に頼っていないか(前節)。
  • エンティティのアノテーション:jakarta.persistence.Entityか。javax.persistence.Entityのままだと走査対象になりません。
  • 生成先がソースセットに入っていない:Mavenはtarget/generated-sources/annotations、Gradleはbuild/generated/sources/annotationProcessor/java/mainに出力します。IDEが認識しないときはプロジェクトを再インポートします。
  • 古い生成物の残存:エンティティ名を変えた後はcleanを挟まないと、削除済みのQクラスが参照され続けます。

JPAQueryFactoryの登録とSpring Data JPAとの連携

QueryDSLでJPAのクエリを発行する入口はJPAQueryFactoryです。EntityManagerを渡して生成し、Beanとして登録しておくとリポジトリから注入できます。

JPAQueryFactoryのBean定義

@Configuration
public class QuerydslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

引数で受け取るEntityManagerはプロキシで、トランザクションごとに実際のインスタンスへ委譲されます。そのためJPAQueryFactoryはシングルトンで登録して問題ありません。DIやJPA関連のアノテーションの役割はSpring Bootの主要なアノテーション一覧で確認できます。

フラグメント方式でのカスタムリポジトリ実装

// フラグメントインターフェース
public interface BookRepositoryCustom {
    List<Book> search(String titleKeyword, Integer minPrice);
}

// 実装クラス名は「フラグメント名 + Impl」
@RequiredArgsConstructor
public class BookRepositoryCustomImpl implements BookRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Book> search(String titleKeyword, Integer minPrice) {
        QBook book = QBook.book;
        return queryFactory
                .selectFrom(book)
                .where(
                        titleContains(book, titleKeyword),
                        priceGoe(book, minPrice)
                )
                .orderBy(book.publishedOn.desc())
                .limit(50)
                .fetch();
    }

    private BooleanExpression titleContains(QBook book, String keyword) {
        return StringUtils.hasText(keyword) ? book.title.contains(keyword) : null;
    }

    private BooleanExpression priceGoe(QBook book, Integer minPrice) {
        return minPrice != null ? book.price.goe(minPrice) : null;
    }
}

// リポジトリ本体にフラグメントを継承させる
public interface BookRepository extends JpaRepository<Book, Long>, BookRepositoryCustom {
}

実装クラス名は、リポジトリ名ではなくフラグメントインターフェース名+Implにします。リポジトリ名から導くBookRepositoryImplも動きますが、Spring Dataのリファレンスはこの命名を非推奨としています。where()にカンマ区切りで渡した条件はANDで連結され、nullを返した条件は無視されます。値があるときだけ条件を足す処理をif文なしで書けるのはこのためです。

QuerydslPredicateExecutorとの使い分け

Spring Data JPAのQuerydslPredicateExecutorをリポジトリに継承させると、実装クラスなしでfindAll(Predicate)などが使えます。ただし集計(GROUP BY)やサブクエリ、関連の無いエンティティとの結合は書けません。射影は、Spring Data 2.6以降のfindBy(predicate, q -> q.as(TitleOnly.class).all())でインタフェースやDTOに絞って取得できます(QuerydslPredicateExecutorのjavadoc)。単純な絞り込みと列の射影までならQuerydslPredicateExecutor、集計・サブクエリ・結合条件の制御が要るならJPAQueryFactoryのカスタムリポジトリ、と分けます。両方を1つのリポジトリに継承させても競合しません。

BooleanBuilderによる動的クエリの組み立て

条件をループの中で順に積み上げたい場合はBooleanBuilderが向いています。

QBook book = QBook.book;
BooleanBuilder condition = new BooleanBuilder();

if (StringUtils.hasText(titleKeyword)) {
    condition.and(book.title.contains(titleKeyword));
}
if (categoryIds != null && !categoryIds.isEmpty()) {
    condition.and(book.category.id.in(categoryIds));
}
if (from != null && to != null) {
    condition.and(book.publishedOn.between(from, to));
}

List<Book> result = queryFactory.selectFrom(book).where(condition).fetch();

注意したいのは、条件が1つも積まれなかった場合です。空のBooleanBuilderを渡すと、発行されたSQLにはWHERE句が付かず、全件を返しました。一覧画面でそのまま実行すると想定外の件数になります。condition.hasValue()は空のときfalseを返すので、未指定時はlimit()を併用するか、既定の条件を足しておきます。

DTO射影・集計・サブクエリの実装

必要な列だけを取り出す射影、GROUP BYを伴う集計、サブクエリは、JPAQueryFactory経由でないと書けない領域です。以下のコードはQBook book = QBook.book;を宣言済みの前提です。

Projectionsと@QueryProjectionによるDTO射影

// DTO。@QueryProjection を付けると QBookSummary がビルド時に生成される
public class BookSummary {
    private final String title;
    private final int price;

    @QueryProjection
    public BookSummary(String title, int price) {
        this.title = title;
        this.price = price;
    }
}

// 1) Projections.constructor:引数の順序と型で解決する(DTO 側に手を入れない)
List<BookSummary> a = queryFactory
        .select(Projections.constructor(BookSummary.class, book.title, book.price))
        .from(book)
        .fetch();

// 2) 生成された QBookSummary を使う
List<BookSummary> b = queryFactory
        .select(new QBookSummary(book.title, book.price))
        .from(book)
        .fetch();

Projections.constructorは引数とコンストラクタの対応を実行時に解決するため、列の順番を入れ替えてもその場では気づけません。@QueryProjectionはDTO用のQクラスを生成するので、不一致がコンパイルエラーになります。代わりにDTOがQueryDSLへ依存します。共有ライブラリのDTOには前者、アプリ内部のDTOには後者を使えば、共有ライブラリにQueryDSLへの依存を持ち込まずに済みます。

groupByとhavingを使った集計

List<Tuple> stats = queryFactory
        .select(book.category.name, book.count(), book.price.avg())
        .from(book)
        .groupBy(book.category.name)
        .having(book.count().gt(3L))
        .fetch();

for (Tuple row : stats) {
    String category = row.get(book.category.name);
    Long count = row.get(book.count());
}

複数の列を選択した結果はTupleで返ります。値はrow.get(...)に選択時と同じ式を渡して取り出します。式の等価性で引くので、上のようにbook.count()を書き直しても取得できます。集計結果を直接DTOへ詰めたいなら、Projections.constructorの引数に集計式を渡します。

JPAExpressionsによるサブクエリ

QBook sub = new QBook("sub");

List<Book> aboveAverage = queryFactory
        .selectFrom(book)
        .where(book.price.gt(
                JPAExpressions.select(sub.price.avg()).from(sub)
        ))
        .fetch();

サブクエリはJPAExpressionsで組み立てます。外側と同じエンティティを使うときは、subのように別名を付けたQクラスを作ります。同じbookを使ってもJPQLの仕様上は内側で再定義した別名として扱われ、Hibernate 7.4.13でも結果は同じでした。ただし内側のbookが外側を隠すため、「同じカテゴリの平均より高い本」のように外側の行を参照する相関サブクエリが書けなくなります。JPQLの仕様ではFROM句にサブクエリを書けず、Jakarta Persistence 3.2でも派生テーブルは入っていません。Hibernate 6.1以降のHQLは拡張として扱えますが、移植性を失います。JOIN対象をサブクエリにしたい要件は、querydsl-sqlで素のSQLを組むか、ビューを用意します。どちらもJPQLの拡張に頼らないので、JPAプロバイダを替えても動きます。

fetch系メソッドの使い分けとページング

List<Book> content = queryFactory
        .selectFrom(book)
        .where(condition)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

JPAQuery<Long> countQuery = queryFactory
        .select(book.count())
        .from(book)
        .where(condition);

return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);

リストはfetch()、1件に定まるはずならfetchOne()、先頭1件でよいならfetchFirst()を使います。fetchOne()は該当が無ければnull、2件以上ならcom.querydsl.core.NonUniqueResultExceptionを投げました。SpringのIncorrectResultSizeDataAccessExceptionではないので、例外処理を書くときは型を取り違えないようにします。fetchFirst()はH2ではSQLにfetch first ? rows only(MySQLなどではlimit ?)を付けて1件だけ取得します。

件数取得のfetchCount()とfetchResults()はAbstractJPAQueryで非推奨です。GROUP BYの列が2つあるクエリでfetchCount()を呼ぶと、Fetchable#fetchCount() was computed in memory!という警告を出し、件数用のSQLではなく本体のSELECTを発行して結果を数えました。結果が大きいほど全件ロードが重くなるので、上のように件数用のクエリを別に組みます。PageableExecutionUtils.getPageは、1ページに収まる場合など件数が不要なときは件数クエリの発行自体を省きます。

一括UPDATE・DELETEと永続化コンテキストのずれ

QueryDSLはupdate()とdelete()で、条件に合う行をSQL1本でまとめて更新・削除できます。

// entityManager は注入済みの EntityManager
QSale sale = QSale.sale;

long updated = queryFactory
        .update(book)
        .set(book.price, book.price.add(100))
        .where(book.price.lt(650))
        .execute();

long deleted = queryFactory
        .delete(sale)
        .where(sale.amount.lt(50))
        .execute();

// 同じトランザクションで続けてエンティティを読むなら、先に永続化コンテキストを捨てる
entityManager.clear();

落とし穴は、この一括更新がデータベースだけを書き換え、すでに読み込んだエンティティには反映されない点です。価格700・500・600の3冊のうち価格500のBookを読み込んでから上のupdate()を実行すると、更新件数は2件と返るのに、手元のエンティティは500のままでした。同じ条件で取り直してもSELECTは発行されますが、返ってきたのは同じインスタンスで値は500。EntityManager.clear()の後に取り直して、ようやく600が見えました。永続化コンテキストに残ったエンティティが優先されるためです。一括更新の後で同じトランザクションの中でエンティティを読む処理があるなら、clear()を挟むか、更新処理を別のトランザクションに分けます。clear()は未反映の変更も捨てるので、必要なら先にflush()します。

JOINとN+1問題への対処

QueryDSLのJOINはjoin()・leftJoin()・innerJoin()に、結合条件を足すon()と、関連を同時に読み込むfetchJoin()を組み合わせて書きます。

joinとfetchJoinの書き分け

QBook book = QBook.book;
QAuthor author = QAuthor.author;
QReview review = QReview.review;

// 1) 関連を検索条件にするだけなら join(フェッチはしない)
List<Book> byAuthor = queryFactory
        .selectFrom(book)
        .join(book.author, author)
        .where(author.name.eq("夏目漱石"))
        .fetch();

// 2) 関連も一緒に読み込んで N+1 を防ぐなら fetchJoin
List<Book> withAuthor = queryFactory
        .selectFrom(book)
        .join(book.author, author).fetchJoin()
        .fetch();

// 3) 結合する行に条件を足すなら leftJoin + on(結合先の列も選ぶ)
List<Tuple> highReviews = queryFactory
        .select(book.title, review.score)
        .from(book)
        .leftJoin(book.reviews, review).on(review.score.goe(4))
        .fetch();

fetchJoin()を付けないjoinのSELECT句にはBookの列しか入らず、関連は遅延読み込みのまま残ります。取得したBookをループしてgetAuthor()を呼ぶと、関連先の数だけ(最大で取得件数分の)SELECTが飛びます。これがN+1問題で、発生の仕組みと検出方法はN+1問題とは?発行クエリ数の実測と検出方法で詳しく扱っています。関連を表示に使うならfetchJoin()を付けます。ビュー描画中にクエリが飛ぶ構成そのものを見直すなら、spring.jpa.open-in-viewの既定値と切り替え手順も確認してください。

3つ目のonの条件は結合する行だけを絞り、左側のBookは条件に関係なく全件残ります。selectFrom(book)でエンティティだけを選ぶと効果が見えず、getReviews()もonの条件を無視して全レビューを読み込むので、結合先の列を一緒に選択します。高評価が付いた本だけが欲しいならjoin(book.reviews, review).where(review.score.goe(4))と書きます。

関連の無いエンティティとのJOIN

マッピング上の関連を持たないエンティティ同士も、join(対象)にon()で条件を渡せば結合できます。2017年頃の記事には「ON句で条件付き結合はできない」とするものがありますが、Hibernate 7.4.13では次のクエリがそのままSQLになりました。

// Sale は Book への関連を持たず、書名の文字列 bookTitle だけを持つ
QSale sale = QSale.sale;

List<Tuple> salesByTitle = queryFactory
        .select(book.title, sale.amount.sum())
        .from(book)
        .join(sale).on(sale.bookTitle.eq(book.title))
        .groupBy(book.title)
        .fetch();
// 発行SQL: select b1_0.title,sum(s1_0.amount) from book b1_0
//   join sale s1_0 on s1_0.book_title=b1_0.title group by b1_0.title

leftJoin(sale).on(...)にすれば、売上の無い書籍も行として残ります。外部キーが張れない旧システムのテーブルや、コード値で緩く結び付いたマスタを結合する場面で使えます。結合のたびに文字列比較が走るので、結合列にはインデックスを用意しておきます。

Hibernate 6以降でのdistinctの扱い

Hibernate 5までは、一対多のfetchJoin()で重複した親エンティティをdistinct()で除くのが定石でした。Hibernate 6.0の移行ガイドは、この用途のdistinctは不要になり重複は常にHibernateが除去すると明記し、HINT_PASS_DISTINCT_THROUGHも削除しました。書籍3冊・レビュー5件(2冊に3件と2件)のデータでbook.reviewsをfetchJoin()すると、distinct()なしでも結果は2件でした。distinct()を付けた場合はSQLにselect distinctが出力され、件数は同じ2件です。

つまりSpring Boot 3以降(Hibernate 6以上)でdistinct()を残しても、データベースに重複判定の負荷を足すだけです。Hibernate 5時代のサンプルを写しているなら外してください。もう1点、一対多のfetchJoin()とoffset()・limit()の併用は、Hibernateの版で挙動が変わります。Hibernate 6.xと7.3まで(Spring Boot 3.x・4.0)は全件を読んでからメモリ上でページングするため、IDだけを先にページ単位で取得し、そのIDで本体と関連を引く2段階のクエリに分けます。Hibernate 7.4の新機能一覧はこの問題の解消を明記しており、Spring Boot 4.1.2で実行すると、offset ? rows fetch first ? rows onlyを含むサブクエリに関連を結合するSQLが発行されました。

ここまでの設定や実装を自社システムに組み込む段階で、設計から任せたい場合はフルスクラッチ開発の支援もご相談いただけます。

よくある質問

QueryDSLの最新バージョンはどれですか?

本家com.querydslは5.1.0(2024年1月29日)が最新です。OpenFeign配下のフォークio.github.openfeign.querydslは7.7(2026年9月23日)が最新で、更新が続いています。Spring Bootを使っているなら、BOMが管理する5.1.0が既定値です。

Spring Boot 4でQueryDSLは使えますか?

使えます。Spring Boot 4.1.2のBOMはquerydsl-bom 5.1.0を管理しており、jakarta分類子付きのquerydsl-jpaでHibernate 7.4.13.Final・JDK 25と組み合わせても、この記事のクエリはすべて動きました。JDK 23以降で動かす場合は、MavenのアノテーションプロセッサをannotationProcessorPathsで指定してください。

ElasticsearchのQuery DSLとQueryDSLは同じものですか?

別物です。ElasticsearchのQuery DSLは検索条件をJSONで記述する言語仕様で、この記事のQueryDSLはJavaのクエリ構築ライブラリです。記事を参照するときはgroupIdやimport文で見分けてください。

Spring Boot 3に上げたらQueryDSLが動かなくなったのはなぜですか?

まず依存関係にjakarta分類子が付いているかを確認します。分類子なしのquerydsl-jpaやquerydsl-aptはjavax.persistenceを参照するため、Jakarta EEへ移ったSpring Boot 3ではエンティティを認識できません。両方に分類子を付け、cleanしてから再ビルドします。

Mavenでバージョンを省略するとエラーになるのはなぜですか?

Mavenの依存関係管理は分類子まで含めたキーで照合するのに、querydsl-bomには分類子付きのエントリが無いためです。jakarta分類子を使う場合は<version>を明記します。<properties>にバージョンを1か所定義しておくと、依存関係とアノテーションプロセッサの指定がずれません。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.09 テックブログ エディオン不正アクセス第2報:カード番号の対象確認とログ・決済画面の改ざん対策
  2. 2026.11.09 テックブログ LANSCOPE不正アクセス第2報:製品への影響と偽連絡の確認、停止中のライセンス認証の窓口
  3. 2026.11.10 テックブログ Claude Fable 5.2とは:料金・破壊的変更4点とFable 5.1からの移行手順【2026年11月】
  4. 2026.11.09 テックブログ ステップゴルフ不正アクセス:口座情報の対象確認と偽連絡・引き落としの注意点
  5. 2026.11.10 テックブログ ニトリの情報漏洩:最大3,762万件の対象確認となりすまし連絡の見分け方

RELATED POSTS 関連記事

目次