Spring BootのRepositoryでよくある戻り値の扱いを解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間でOptionalやListを学ぶイラスト。

実装・エラー解決

Spring Data JPAのRepository戻り値|検索0件・1件・複数件の違い

Spring Data JPAのfindByEmailをOptionalにすれば、見つからないときも安全だと思っていた。ところが同じメールアドレスが2行あった日だけ、利用者の詳細画面がエラーになる。Optionalは「0件でもよい」という表現で、複数件から1件を選ぶ指示でも、重複を防ぐ制約でもありません。検索条件とDBの約束まで揃える必要があります。

この記事でわかること

  • 検索結果が0件・1件・2件のとき、Optional・単一Entity・Listがそれぞれ何を返すか。
  • 「見つからない」が正常な検索と、必須の対象がない操作をServiceで分ける方法。
  • 複数件が問題なら、検索結果の扱いとDBのUNIQUEのどちらを直すか。

メールの条件は同じまま、0件・1件・2件を比べる

古い利用者データを調べる場面を想定し、検証用のaccounts表ではメールの重複を許します。検索条件は架空のdummy@example.invalidで固定し、DBへ入れる一致行の数だけを変えます。0件をnullにしてみたり、2件の結果を先に1件に絞ったりして、テスト側で結果を作ってはいません。実際のSpring Data JPAのRepositoryを呼びます。

package info.syachiku.repository;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findOptionalByEmail(String email);
    Account findSingleByEmail(String email);
    List<Account> findAllByEmailOrderByIdAsc(String email);
    boolean existsByEmail(String email);
    long countByEmail(String email);
}

3メソッドの条件はByEmailです。OptionalやSingleはfindとByの間の説明名として使っており、ここでは1件制限の指定ではありません。リストだけはテストで順番まで比較するため、OrderByIdAscを明示しました。

一致する行Optional単一EntityList
0件Optional.empty()(R01)null(R02)空リスト(R03)
1件その行を含む(R04)そのEntity(R04)要素1件(R04)
2件件数不一致の例外(R05)件数不一致の例外(R06)要素2件(R07)

2件で検出する具体的な例外は、org.springframework.dao.IncorrectResultSizeDataAccessExceptionです。単一EntityとOptionalは、どちらも一致行が最大1件であることを前提にしています。このサンプルの単一Entityは0件でnullですが、プロジェクトがパッケージ単位の@NonNullApiなどで戻り値を非nullにしていると扱いが異なります。自分のコードで同じ表を使う前に、package-info.javaなどの宣言を確認してください。

1件だけを試すと3メソッドとも同じ行を返すため、戻り値の選択を誤っても気づきにくくなります。R04では実際に保存されたIDを3つの結果と比較し、R05〜R07では同じ値の2行で差を確認します。2行は同じメールでも別のIDです。「同じ文字列だから同じ利用者」とみなしてどちらかを捨てるテストにはしていません。

重複を許すAccount.javaの全文を開く
package info.syachiku.repository;

import jakarta.persistence.*;

@Entity
@Table(name = "accounts")
public class Account {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    protected Account() {}
    public Account(String email) { this.email = email; }
    public Long getId() { return id; }
    public String getEmail() { return email; }
}

0件が正常な検索と、必須の対象がない操作を分ける

たとえば管理画面で「メールで検索する」は、まだ登録されていない利用者を探すこともあるので0件は正常です。一方、既に選択したIDの詳細を開く処理なら、対象が削除されていた場合の扱いを決める必要があります。Repositoryの戻り値が同じOptionalでも、空でよいか、処理を止めるかは呼び出し側の要件です。

修正前の扱いは、見つからないIDに対してaccounts.findById(-1L).get()を呼ぶものです。R08では同じIDに対して、戻り値が空で、直接get()するとNoSuchElementExceptionになることを確認します。これは「アカウントが削除されている」「利用者に知らせるべき」といった業務の意味を持たない例外です。

    @Test void R08_directGetFailsForAnAbsentId() {
        assertTrue(accounts.findById(-1L).isEmpty());
        assertThrowsExactly(NoSuchElementException.class, () -> accounts.findById(-1L).get());
    }

修正後のAccountDirectoryでは、検索はmapで見つかったメールだけを変換し、空なら空のまま返します。必須の詳細取得ではorElseThrowでAccountNotFoundExceptionへ変換します。R09は検索が空で終わること、R10は必須IDがない場合に、対象IDを含む例外が投げられることを確認します。

package info.syachiku.repository;

import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class AccountDirectory {
    private final AccountRepository accounts;

    public AccountDirectory(AccountRepository accounts) {
        this.accounts = accounts;
    }

    public Optional<String> searchEmail(String email) {
        return accounts.findOptionalByEmail(email).map(Account::getEmail);
    }

    public Account require(long id) {
        return accounts.findById(id).orElseThrow(() -> new AccountNotFoundException(id));
    }
}

ここで扱っているのはServiceの契約です。HTTP 404にするなら、例外をレスポンスへ変換するController側の設定とテストを別に用意します。このサンプルはWebアプリを起動せず、HTTPステータスを検証していません。また、searchEmailは最大1件を意図する検索の例なので、同じメールの複数行を許す仕様ならfindAllByEmailOrderByIdAscを使う設計へ変えます。空を許すことと、複数を許すことを一つの判断にしないでください。

1件の保証が必要なら、検索と保存のどちらで止めるか決める

Optionalの検索で2件が見つかるとき、まず「同じメールは1アカウントだけ」という業務上の約束があるかを確認します。あるなら、複数件は検出したいデータ不整合です。ないなら、たとえば所属組織のIDを条件へ加える、複数件を表示して利用者に選ばせるといった修正が候補です。前者なら複合キーの一意性、後者なら複数表示が要件になります。

常に一件だけのはずなのに重複している場合、findFirst…やLIMIT 1に変えるだけでは、どちらの行を選んだかで更新先が変わり得ます。最も新しい一件を選ぶこと自体が要件なら、日時と同時刻の並び順まで決めて絞り込みます。重複を許していないのに件数を減らして通すのとは、仕様が異なります。

今回のもう一つの表unique_accountsは、メールを必須かつ一意にします。Hibernateが次のEntityから学習用DBの表を作ります。検証の目的は、検索で2件になってから例外にする仕組みと、二行目の保存自体を拒否する仕組みを分けて観察することです。

メールのUNIQUE制約を指定したUniqueAccount.javaの全文を開く
package info.syachiku.repository;

import jakarta.persistence.*;

@Entity
@Table(name = "unique_accounts", uniqueConstraints = @UniqueConstraint(name = "uk_account_email", columnNames = "email"))
public class UniqueAccount {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    protected UniqueAccount() {}
    public UniqueAccount(String email) { this.email = email; }
    public Long getId() { return id; }
    public String getEmail() { return email; }
}

R12では、一行目の保存を済ませてから、同じメールで二行目のsaveAndFlushを呼び、DataIntegrityViolationExceptionを確認します。発生箇所は検索ではなく保存です。今回はID生成方式のためINSERTが保存中に実行され得ます。すべてのJPA構成で明示的なflushの瞬間までエラーが遅れる、とは考えないでください。

    @Test void R12_uniqueConstraintRejectsTheSecondSave() {
        uniqueAccounts.saveAndFlush(new UniqueAccount(EMAIL));
        assertThrows(DataIntegrityViolationException.class, () -> uniqueAccounts.saveAndFlush(new UniqueAccount(EMAIL)));
    }

検証ではH2がSQLState: 23505を出し、Springが上の例外へ変換しました。ログにERRORが出るのは、拒否されるケースを実行した結果です。JUnitはその拒否を期待しているので全14件が成功します。テストでは制約違反を確認したトランザクションで別の検索を続けず、ロールバックでデータを片付けます。

本番に制約を追加するときは、Entityの注釈を足しただけで既存DBへ適用されたとは判断できません。まず既存データの重複を洗い出し、正しい行の決め方を合意し、マイグレーションで制約を入れます。例えばテナントごとの重複だけを禁止するなら、メール単体ではなくテナントとメールの組み合わせが対象です。メールの大文字小文字や空白を同じ値とみなすかも、実際のDB・業務仕様で決めます。

existsByは「今あるか」を返し、次の保存を予約しない

存在を知りたいだけならexistsByEmail、件数が必要ならcountByEmailを使うと意図が伝わります。R11では重複を許す表で、existsByEmailはtrue、countByEmailは2を返します。「存在する」ことは「最大一件」だという意味ではありません。

登録フォームで先に存在チェックを行い、既に使われているメールを早く案内することはできます。ただし二つの要求が同時に「まだない」と見てから保存する可能性は残ります。R14は並行処理のテストではなく、制約がない表で存在チェックをしても、その後に同じメールを二回保存できることを確かめる例です。重複を禁止する場合はDBの制約で守り、保存で拒否された結果を呼び出し側へどう返すか決めます。

「Serviceが0件を業務例外へ変える」ことはモックでも確かめられます。一方で「2行を返したときRepositoryが実際に何を投げるか」や「制約で二行目が拒否されるか」は、RepositoryやDBを置き換えたテストでは確認できません。テスト対象に残す境界を、守りたい結果から選びます。

関連記事Spring BootのServiceをJUnit・Mockitoでテストする方法|Repositoryをモックする基本 RepositoryをモックするServiceテストで、戻り値と保存呼び出しを確かめます。実DBで何が起きるかとは切り分けます。 記事を読む

H2で14件を実行し、戻り値と例外を照合する

ZIPを展開し、pom.xmlがある場所で次を実行します。@DataJpaTestでH2とJPAを起動し、実際のRepositoryを呼びます。テストごとにデータはロールバックされるので、別ケースの行を前提にしません。JUnitが一行を検索したつもりでも、テストデータが別ケースから残っていれば件数が変わります。期待する件数に必要な行は各テストで用意します。

mvn -B test

Java 17.0.8、Maven 3.9.5、Spring Boot 3.5.16、Spring Data JPA 3.5.13、H2 2.3.232、JUnit 5.12.2で14件通過しました。Maven Centralから不足依存を取得した後、キャッシュを使った再実行も確認しています。Mockitoを使わない検証なので、テスト用スターターからMockitoを除外しています。完全に空の依存キャッシュ、別のJDK、PostgreSQL/MySQLなど別DBの結果は未確認です。

0件・1件・2件と制約違反を確認するJUnit全文を開く
package info.syachiku.repository;

import java.util.NoSuchElementException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest(showSql = false)
@Import(AccountDirectory.class)
class RepositoryReturnValuesTest {
    private static final String EMAIL = "dummy@example.invalid";
    @Autowired AccountRepository accounts;
    @Autowired UniqueAccountRepository uniqueAccounts;
    @Autowired AccountDirectory directory;

    private Account one() { return accounts.saveAndFlush(new Account(EMAIL)); }
    private void two() { one(); one(); }

    @Test void R01_optionalReturnsEmptyWhenNoRowMatches() {
        assertTrue(accounts.findOptionalByEmail(EMAIL).isEmpty());
    }

    @Test void R02_singleEntityReturnsNullWhenNoRowMatches() {
        assertNull(accounts.findSingleByEmail(EMAIL));
    }

    @Test void R03_listReturnsEmptyWhenNoRowMatches() {
        assertEquals(0, accounts.findAllByEmailOrderByIdAsc(EMAIL).size());
    }

    @Test void R04_oneRowIsTheSameAccountForAllThreeReturnTypes() {
        Account saved = one();
        assertEquals(saved.getId(), accounts.findOptionalByEmail(EMAIL).orElseThrow().getId());
        assertEquals(saved.getId(), accounts.findSingleByEmail(EMAIL).getId());
        var found = accounts.findAllByEmailOrderByIdAsc(EMAIL);
        assertEquals(1, found.size());
        assertEquals(saved.getId(), found.get(0).getId());
    }

    @Test void R05_optionalDoesNotSelectOneOfTwoRows() {
        two();
        assertThrows(IncorrectResultSizeDataAccessException.class, () -> accounts.findOptionalByEmail(EMAIL));
    }

    @Test void R06_singleEntityDoesNotSelectOneOfTwoRows() {
        two();
        assertThrows(IncorrectResultSizeDataAccessException.class, () -> accounts.findSingleByEmail(EMAIL));
    }

    @Test void R07_listReturnsBothRowsInExplicitIdOrder() {
        Account first = one();
        Account second = one();
        var found = accounts.findAllByEmailOrderByIdAsc(EMAIL);
        assertEquals(2, found.size());
        assertEquals(first.getId(), found.get(0).getId());
        assertEquals(second.getId(), found.get(1).getId());
    }

    @Test void R08_directGetFailsForAnAbsentId() {
        assertTrue(accounts.findById(-1L).isEmpty());
        assertThrowsExactly(NoSuchElementException.class, () -> accounts.findById(-1L).get());
    }

    @Test void R09_searchTreatsMissingAccountAsANormalEmptyResult() {
        assertTrue(directory.searchEmail(EMAIL).isEmpty());
    }

    @Test void R10_requiredAccountTurnsMissingIntoTheBusinessException() {
        var error = assertThrows(AccountNotFoundException.class, () -> directory.require(-1L));
        assertEquals("Account not found: -1", error.getMessage());
    }

    @Test void R11_existsAndCountDoNotHaveTheSameMeaningAsSingleResult() {
        two();
        assertTrue(accounts.existsByEmail(EMAIL));
        assertEquals(2L, accounts.countByEmail(EMAIL));
    }

    @Test void R12_uniqueConstraintRejectsTheSecondSave() {
        uniqueAccounts.saveAndFlush(new UniqueAccount(EMAIL));
        assertThrows(DataIntegrityViolationException.class, () -> uniqueAccounts.saveAndFlush(new UniqueAccount(EMAIL)));
    }

    @Test void R13_uniqueTableAcceptsOneAccount() {
        UniqueAccount saved = uniqueAccounts.saveAndFlush(new UniqueAccount(EMAIL));
        assertNotNull(saved.getId());
        assertTrue(uniqueAccounts.existsByEmail(EMAIL));
    }

    @Test void R14_priorExistenceCheckDoesNotCreateAUniquenessConstraint() {
        assertFalse(accounts.existsByEmail(EMAIL));
        one();
        one();
        assertEquals(2L, accounts.countByEmail(EMAIL));
    }
}
pom.xmlの全文を開く
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
  <groupId>info.syachiku.samples</groupId><artifactId>repository-return-values</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope>
      <exclusions>
        <exclusion><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId></exclusion>
        <exclusion><groupId>org.mockito</groupId><artifactId>mockito-junit-jupiter</artifactId></exclusion>
      </exclusions>
    </dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.6</version></plugin></plugins></build>
</project>

一意制約が効いているかを確かめるため、別コピーでUniqueAccountの一意制約だけを外しました。二行目が保存されてR12が失敗します。配布版は制約を残し、拒否されることを期待するテストが通る版です。

全文のうち、R01〜R07は検索結果、R08〜R10は未取得時の扱い、R11は存在と件数、R12〜R14は保存制約の確認です。起動設定・専用Repository・例外クラスは冒頭のGitHubとZIPに含めています。データベースはメモリ内で作成・破棄する学習環境です。実環境のデータベースへこのサンプルのcreate-drop設定を向けないでください。

レビューで「最大1件」の根拠を説明する

例:この詳細操作ではIDがなければ処理を止めるので、findById(...).get()を業務例外へ置き換えます。一方、メール検索をOptionalにするには、同じ条件で複数行が返らない根拠が必要です。既存のデータ制約と検索条件を先に確認してください。

レビューを受けたら、型を変更したことだけで返さず、「何件を許し」「0件をどう扱い」「その約束を検索条件とDBのどこで守るか」を伝えます。今回ならR05のような2件のテストと、R12の保存時拒否は別の確認です。どちらも実行して、検索で異常が見えることと、重複の発生を防ぐことを混同しないようにします。

  • 0件は正常か、業務上の例外か。
  • 最大1件を選んだ根拠は主キー、検索条件、DB制約のどれか。
  • 複数件を返す場合、順番と画面での扱いを決めたか。
  • 存在チェックと保存時の制約違反を分けて確認したか。

同じメールアドレスの登録をどこで防ぐか迷うなら。Javaの戻り値と分けて、テーブル設計と制約の判断を学ぶための本です。

書籍「SQLアンチパターン 第2版」の表紙
商品画像:Amazon.co.jp
PR SQL基礎を終えた若手向け

SQLアンチパターン 第2版

Bill Karwin (著)

動くSQLの先にある、設計上の失敗パターンを知る。

テーブル設計やクエリで繰り返される問題を、原因と改善策から学べます。基礎SQLを終えた後の一冊です。

  • SQLレビューの観点を増やしたい
  • DB設計の失敗例から学びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事Spring Bootの@Transactionalはどこに付ける? 保存と他の処理をどの範囲で戻すか。トランザクションを置く位置と、自己呼び出しの注意を整理します。 記事を読む 関連記事SpringのServiceが肥大化したらどこで分ける?計算・保存・通知の分割例 保存・計算・通知が一つのServiceに混ざったとき、変更の理由ごとにクラスを分ける例を試せます。 記事を読む

0件・1件・複数件の約束から戻り値を選ぶ

最大1件ならOptionalで未取得を表せます。複数件が仕様ならList、存在だけならexistsです。最大1件を守る必要があるときは、検索結果だけでなくデータを保存する場所まで確認します。まず実際の検索条件を一つ選び、0行・1行・2行それぞれの期待結果を書いてから、戻り値と制約を見直してください。

参考:Spring Data JPA 3.5の戻り値仕様、nullの扱い。

-実装・エラー解決
-,