はじめに
Spring Data JPAで受領書を取得し、後から明細をDTOへ変換しようとしたときにLazyInitializationExceptionが出ることがあります。DBには明細が保存されており、サービスの取得処理も例外なく終わるため、「保存処理が壊れたのではないか」「DTO変換が誤っているのではないか」と切り分けが広がりやすい問題です。
この記事では、再現可能な教材 spring-jpa-entitygraph-detached-lazy-lab を使って、次の契約を失敗テストとして固定し、原因を観測して最小修正を確認します。
詳細表示用に取得した受領書は、サービスの読み取りトランザクション完了後であっても、明細名を含むDTOへ変換できる。
対象はJava 21、Spring Boot 3.4.3、Spring Data JPA、Hibernate、H2、JUnit Jupiterで構成した統合テストです。ここで扱うのは、トランザクションをまたいで明細を読む表示契約に対して、どの取得メソッドを選ぶかという一例です。
先に結論
今回の例外は、Receipt.linesがLAZYであること自体ではなく、詳細表示用サービスが通常のfindByIdを使い、linesを未初期化のまま返したことで起きます。この教材ではサービスのトランザクションと永続化コンテキストが境界で終了するため、DTO変換時に遅延ロードできず例外になります。
修正は、詳細表示用に用意済みのfindDetailedByIdへ取得メソッドを置き換えることです。このメソッドには@EntityGraph(attributePaths = "lines")が付いており、この環境では必要な関連が取得時に初期化されます。EAGERへ一律変更せず、呼出し用途に合うフェッチ計画を選ぶことがポイントです。
| 項目 | バグ状態 | 修正後 |
|---|---|---|
| サービスの取得 | findById |
findDetailedById |
linesの初期化状態 |
未初期化 | 初期化済み |
| トランザクション終了後のDTO変換 | LazyInitializationException |
成功 |
| 確認対象 | 例外だけではない | DBの明細とDTOの内容の両方 |
何が起きているのか
受領書は明細との1対多関連を持ち、明細コレクションは明示的にFetchType.LAZYです。受領書本体を取得しても、getLines()を実際に読むまでは明細を読み込まない設定になっています。
@OneToMany(mappedBy = "receipt", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private List<ReceiptLine> lines = new ArrayList<>();
DTO変換では、受領書コードだけでなく明細名を作るためにlinesを走査します。したがって、例外が発生する直接の場所は保存処理でもリポジトリ呼出しそのものでもなく、次のstream()です。
public static ReceiptView from(Receipt receipt) {
return new ReceiptView(
receipt.getReceiptCode(),
receipt.getLines().stream().map(ReceiptLine::getItemName).toList()
);
}
Hibernateは、未取得データに対して開いている状態付きSessionの外からアクセスしようとした場合にLazyInitializationExceptionを示すと説明しています。HibernateのLazyInitializationException API
“Indicates an attempt to access unfetched data outside the context of an open stateful
Session.”
この教材では、ReceiptViewService#loadReceiptForViewに@Transactional(readOnly = true)を付けています。テストはサービスの戻り値をdetachedReceiptとして受け取った後にReceiptView.fromを呼ぶため、DTO変換はサービスの読み取りトランザクション完了後です。これにより、「サービス内部でたまたま遅延ロードできた」状態と、実際に必要な表示契約を区別できます。
失敗を再現する
教材のバグ状態はコミット8c70370です。未コミット変更のないディレクトリで、次のコマンドを実行します。
git clone https://github.com/tonbiattack/spring-jpa-entitygraph-detached-lazy-lab.git
cd spring-jpa-entitygraph-detached-lazy-lab
git switch --detach 8c70370
mvn --batch-mode test -Dtest=ReceiptViewServiceTest
このテストは失敗することが正しい結果です。Java 21で実行した実測では、1件のテストが失敗し、次のアサーション差分が出ました。
サービスのトランザクション完了後でも明細をDTOへ変換できる
==> Unexpected exception thrown:
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
jp.tonbiattack.debuglab.receipt.Receipt.lines:
could not initialize proxy - no Session
失敗の完全な出力は、教材の evidence/01-bug-service-test-output.txt に保存されています。
契約テストでは、DBに明細が保存されていることを別トランザクションで確認した後、サービスのトランザクション完了後にDTO変換を実行します。保存済みであることと、境界の外で読めることを分けて検証します。
| 観測点 | 期待 | バグ状態での実測 | 分かること |
|---|---|---|---|
| DB上の明細名 |
Green teaが存在する |
Green teaが存在する |
保存漏れではない |
| サービス呼出し | 受領書を返す | 受領書を返す | 取得メソッド自体は例外なく終わる |
| DTO変換 | 明細名を含むDTOを返す | LazyInitializationException |
トランザクション後の遅延初期化が失敗している |
例外だけで決めつけず、初期化状態を観測する
失敗テストだけでは保存漏れと取得計画の問題を区別できません。そこで教材では、同じデータに対して通常取得とEntityGraph付き詳細取得の初期化状態を比べています。
boolean regularFindInitialized = transactionTemplate.execute(status ->
Hibernate.isInitialized(repository.findById(receiptId).orElseThrow().getLines())
);
boolean entityGraphFindInitialized = transactionTemplate.execute(status ->
Hibernate.isInitialized(repository.findDetailedById(receiptId).orElseThrow().getLines())
);
assertAll(
() -> assertFalse(regularFindInitialized,
"通常のfindByIdはLAZYな明細コレクションを初期化しない"),
() -> assertTrue(entityGraphFindInitialized,
"EntityGraph付きの詳細取得は明細コレクションを初期化する")
);
この観測テストはバグコミットでも成功します。今回の独立したトランザクションでは、通常のfindByIdがfalse、findDetailedByIdがtrueになります。これにより、明細の保存漏れではなく、詳細表示サービスの取得計画が原因だと確認できます。実行出力は evidence/02-entitygraph-initialization-observation-output.txt で確認できます。
原因をコードと仕様に結び付ける
詳細取得用のリポジトリメソッドは、すでに用意されています。linesをattributePathsに指定した@EntityGraphを、受領書を主エンティティとするJPQLへ付与しています。
@EntityGraph(attributePaths = "lines")
@Query("select receipt from Receipt receipt where receipt.id = :id")
Optional<Receipt> findDetailedById(@Param("id") Long id);
Jakarta PersistenceのEntity graphは、同時に取得する永続フィールド群を表すフェッチ計画です。Jakarta EE Tutorial: Creating Fetch Plans with Entity Graphs Spring Data JPAの@EntityGraphはリポジトリメソッドに適用でき、attributePathsへプロパティを指定できます。Spring Data JPA EntityGraph API
それに対して、バグ状態のサービスは通常取得を選んでいました。
@Transactional(readOnly = true)
public Receipt loadReceiptForView(Long receiptId) {
return repository.findById(receiptId).orElseThrow();
}
この組合せでは、次の条件がそろいます。
-
Receipt.linesはLAZYであり、通常取得では未初期化のままです。 - サービスはエンティティを返し、テストはトランザクション終了後にDTO変換します。
- DTO変換が
linesを走査するため、初期化が必要になります。
したがって、保存済みの明細を画面へ渡せない原因は、詳細表示の取得用途とfindByIdのフェッチ計画が一致していないことです。SQLログだけを見て「明細用のSQLが出ていない」と判断するより、Hibernate.isInitializedとトランザクション後のDTO変換を組み合わせると、何が未初期化のまま境界を越えたのかを直接示せます。
最小修正
修正では、関連のfetch種別をグローバルに変えません。詳細表示用のサービスで、用途に対応する詳細取得メソッドを選択します。
@Transactional(readOnly = true)
public Receipt loadReceiptForView(Long receiptId) {
- return repository.findById(receiptId).orElseThrow();
+ return repository.findDetailedById(receiptId).orElseThrow();
}
この変更はコミット 62bec24 にあります。変更範囲はサービス内の1行です。Receipt.linesの宣言、DTO変換、テストの契約は変えていません。
@EntityGraphは今回の修正方法の一つです。サービス内でDTOへ変換する、JOIN FETCHやDTO projectionを使うなど、別の設計もあります。重要なのは、DTO変換の境界と、その時点で必要な関連の取得方法を契約としてそろえることです。関連を一律にEAGERへ変える方法は、詳細画面だけの要件をエンティティ全体へ広げるため採用しません。
修正後の回帰確認
修正済みのmainへ戻して、対象を限定せずにテスト全体を実行します。
git switch main
mvn --batch-mode clean test
Java 21での実測では、EntityGraphInitializationObservationTestとReceiptViewServiceTestの2件が成功し、失敗・エラー・スキップはいずれも0件でした。完全な実行出力は evidence/03-fixed-full-test-output.txt にあります。
回帰テストは、単に例外が出なくなったことだけでなく、DBから再読込した明細名と、トランザクション完了後に生成したDTOの両方がGreen teaを含むことを確認しています。
この記事は、単一の受領書と明細を対象にした一例です。一覧画面、大量取得、複数階層、ページング、JSONシリアライズ、N+1問題では、別のクエリやDTO projectionを検討してください。@EntityGraphが付いていることではなく、必要なDTOを境界の外で生成できることを成功条件にします。
まとめ
LazyInitializationExceptionがDTO変換時に発生しても、直ちに保存処理やDTOの実装が原因とは限りません。今回の実測では、DBに明細は存在し、通常のfindByIdだけがLAZYなlinesを未初期化のままサービス境界の外へ返していました。
詳細表示という契約に対し、@EntityGraph(attributePaths = "lines")を指定したfindDetailedByIdを選ぶと、トランザクション終了後でも明細名を含むDTOを生成できます。契約テスト、初期化状態の観測テスト、最小の1行修正をセットで残すことで、原因を説明可能にしながら同じ後退を防げます。