画面からは問題なく動いているサービスのメソッドを、夜間バッチ(とテスト)から呼んだところ、次の例外が出ました。

Spring Boot 3.x(Hibernate 6.6)のメッセージです。

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.demo.PurchaseOrder.lines: could not initialize proxy - no Session

Spring Boot 4.x(Hibernate 7.4)では、同じ例外でもメッセージが変わっています。

org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.PurchaseOrder.lines' with key '1' (no session)

結論: @OneToMany の関連は、既定では使うときに初めて読み込まれます(遅延読み込み)。読み込むには、データベースとの接続(Hibernate のセッション)が開いている必要があります。

Spring Boot は Web リクエストの間だけセッションを開いたままにする「Open EntityManager in View」を既定で有効にしています。そのため、画面からは動き、バッチやテストでは失敗します。

直し方は、必要な関連をトランザクションの中で先に読み込んでおくことです。

躓いたポイント

環境

項目 バージョン
OS Windows 11
JDK Eclipse Temurin 21.0.12
Spring Boot 3.5.16(Spring Data JPA 3.5.13、Hibernate ORM 6.6.53)
4.1.1(Spring Data JPA 4.1.1、Hibernate ORM 7.4.5)
データベース H2
Maven 3.9.16

起きたこと

注文(PurchaseOrder)が明細(OrderLine)を複数持つ、よくある形です。

@Entity
public class PurchaseOrder {
    @Id
    private Long id;

    private String customer;

    // @OneToMany の既定は LAZY
    @OneToMany(mappedBy = "purchaseOrder")
    private List<OrderLine> lines = new ArrayList<>();

    // getter
}
@Service
public class OrderService {

    private final PurchaseOrderRepository repository;

    public OrderService(PurchaseOrderRepository repository) {
        this.repository = repository;
    }

    public PurchaseOrder find(Long id) {
        return repository.findById(id).orElseThrow();
    }
}

コントローラーから orderService.find(1L).getLines().size() を呼ぶと、明細の件数が返ります。

@GetMapping("/orders/{id}/line-count")
public int lineCount(@PathVariable Long id) {
    return orderService.find(id).getLines().size();
}

まったく同じ呼び方をバッチやテスト(@SpringBootTest)から行うと、getLines().size() の行で LazyInitializationException になります。Spring Boot 3.5.16 と 4.1.1 の両方で同じでした。

起動時のログには、次の警告が出ていました。

WARN ... JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

画面から動く理由を確かめる

spring.jpa.open-in-view=false にすると、画面(コントローラー)からの呼び出しでも同じ例外になりました。

jakarta.servlet.ServletException: Request processing failed: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.demo.PurchaseOrder.lines: could not initialize proxy - no Session

画面で動いていたのは、Open EntityManager in View がリクエストの間ずっとセッションを開いていたためで、コードが正しかったからではありません。

どうやって解決したか

必要な関連を、セッションが開いているうちに読み込んでおきます。3つの方法を試し、どれもバッチ・テストから明細2件を取得できました。

方法1:JOIN FETCH で一緒に取得する

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    @Query("select o from PurchaseOrder o left join fetch o.lines where o.id = :id")
    Optional<PurchaseOrder> findWithLinesById(Long id);
}

方法2:@EntityGraph で一緒に取得する

JPQL を書かずに、メソッドに付けるアノテーションで指定できます。

@EntityGraph(attributePaths = "lines")
Optional<PurchaseOrder> findGraphById(Long id);

方法3:トランザクションの中で必要な値を取り出してから返す

@Transactional(readOnly = true)
public int countLines(Long id) {
    return repository.findById(id).orElseThrow().getLines().size();
}

エンティティをそのまま返さず、画面やバッチで使う値(件数や DTO)に詰め替えてから返す形です。

再発を防ぐ

spring.jpa.open-in-view=false を明示しておくと、画面でも同じ例外が出るので、開発中に気付けます。ただし、既存の画面が遅延読み込みに頼っている場合は、その画面も上の方法で直す必要があります。既存のシステムで切り替えるときは、画面のテストを流してから変えてください。

補足:@Query の名前付き引数で別のエラーが出た場合

検証中、方法1で次のエラーが出ました。

For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters

spring-boot-starter-parent を親にしていないプロジェクトでは、コンパイラの -parameters オプションが付かないためです。pom.xml に次の設定を足すと解消しました(エラーメッセージにあるとおり、引数に @Param("id") を付ける方法もあります)。

<properties>
  <maven.compiler.parameters>true</maven.compiler.parameters>
</properties>

解決できた根拠

@OneToMany の既定は LAZY

Jakarta Persistence の API ドキュメントで、OneToMany の fetch の既定値は FetchType.LAZY とされています。

セッションの外で未取得のデータにアクセスすると LazyInitializationException

Hibernate の API ドキュメントの説明です。

Indicates an attempt to access unfetched data outside the context of an open stateful Session.

(開いているセッションの外で、まだ取得していないデータにアクセスしようとしたことを示します)

— Hibernate ORM 6.6 Javadoc: LazyInitializationException

Spring Boot は Web アプリでだけ Open EntityManager in View を既定で有効にする

If you are running a web application, Spring Boot by default registers OpenEntityManagerInViewInterceptor to apply the “Open EntityManager in View” pattern, to allow for lazy loading in web views. If you do not want this behavior, you should set spring.jpa.open-in-view to false in your application.properties.

(Web アプリケーションの場合、Spring Boot は既定で OpenEntityManagerInViewInterceptor を登録し、画面で遅延読み込みができるようにします。この動作が不要なら spring.jpa.open-in-view を false にします)

— Spring Boot: SQL Databases(Open EntityManager in View)

「Web アプリケーションの場合」なので、バッチやテストからの呼び出しには効きません。

@EntityGraph の attributePaths で関連を一緒に取得できる

Spring Data JPA のリファレンスの「Configuring Fetch- and LoadGraphs」に、@EntityGraph の attributePaths で、@NamedEntityGraph を定義せずにエンティティグラフを指定できると書かれています。

参考