Spring Data JPAを使っていると、ほぼ誰もが一度は踏むのがこの例外ですよね。

org.hibernate.LazyInitializationException: could not initialize proxy [com.example.Order#1] - no Session

マッピングを直しても、SQLを見直しても消えない。それもそのはずで、これはマッピングやクエリの誤りではなく アクセスするタイミング の問題だからです。この記事では、なぜ起きるのかをHibernateのセッションとトランザクション境界から理解したうえで、4つの正攻法で恒久的に直す道筋を示します。安易な open-in-view=trueEAGER 化に逃げない判断軸も一緒に整理しましょう。

LazyInitializationExceptionとは何か - まず例外メッセージを読む

@OneToMany@ManyToOne(fetch = LAZY) な関連は、Hibernateが実体ではなく プロキシ(コレクションなら PersistentCollection)に差し替えています。実データは、そのフィールドに初めて触れた瞬間にDBへ取りに行く仕組みです。

この初回アクセスのとき、Hibernateセッション(永続コンテキスト)がすでに閉じていると、DBへ問い合わせる手段がないため例外になります。メッセージを分解すると素直に読めます。

  • could not initialize proxy … 未初期化のプロキシを解決しようとした
  • no Session … しかしセッションが無かった

つまり「データを取りに行きたいのに、取りに行く窓口がもう閉まっていた」状態です。エラーの原因はコードの正しさではなく、触るのが遅すぎた ことにあります。

なぜ起きるのか - セッションのライフサイクルとトランザクション境界

デフォルトでは、Hibernateセッションは @Transactional の範囲に紐づいて生きています。トランザクションが始まるとセッションが開き、メソッドを抜けてコミットされるとセッションは閉じます。

@Service
public class OrderService {

    @Transactional
    public Order findOrder(Long id) {
        Order order = orderRepository.findById(id).orElseThrow();
        // ここはトランザクション内 = セッションが開いている
        order.getItems().size(); // 初期化できる
        return order;
    }
}

問題は、このメソッドが返した order をControllerやThymeleafで触るときです。

@GetMapping("/orders/{id}")
public String show(@PathVariable Long id, Model model) {
    Order order = orderService.findOrder(id);
    model.addAttribute("order", order);
    return "order/show"; // テンプレート内で order.items にアクセス → 例外
}

Controllerに戻った時点でトランザクションは終わり、セッションは閉じています。テンプレートが order.items を描画しようとした瞬間、閉じたセッションでプロキシを初期化しようとして落ちるわけです。初期化される場所参照する場所 のズレ、これが例外の本質です。

原因を切り分ける - あなたのケースはどのパターンか

まずスタックトレースを読みます。could not initialize proxy [com.example.Order#1] のように、どのエンティティのどの関連 で落ちたかが書いてあります。次に、そのアクセスがトランザクションの外で起きていないかを確認します。

典型的に外になりやすいのは次の場面です。

  • Thymeleafなどテンプレート描画中の関連アクセス
  • @RestController でエンティティを返し、Jacksonがシリアライズする瞬間
  • Service外のユーティリティやController内での後処理

どれも「トランザクションを抜けたあとにLazyに触っている」点が共通しています。切り分けができたら、解決の方向は2つに分かれます。初期化する場所を前に寄せる か、そもそも必要な形で取っておく かです。ここから4つの正攻法を見ていきましょう。

解決策1: JOIN FETCHで必要な関連を一緒に取得する

クエリの時点で関連を結合して取ってしまえば、返ってきたエンティティはプロキシが解決済みです。トランザクション外で触っても安全になります。

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o join fetch o.items where o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);
}

そのユースケースでだけ関連が必要、というときに向いています。ひとつ注意したいのは、コレクションの join fetch とページング(Pageable)を併用すると、Hibernateが全件をメモリに読み込んでからページングする挙動になり警告が出る点です。一覧+ページングが絡む場合は次の @EntityGraph やDTO射影を検討しましょう。

解決策2: @EntityGraphで宣言的にフェッチする

JPQLを書かずに、リポジトリメソッド単位でフェッチ範囲を指定できます。メソッド名クエリにも後付けしやすく、可読性が高いのが利点です。

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"items", "customer"})
    Optional<Order> findWithGraphById(Long id);
}

attributePaths に一緒に取りたい関連を並べるだけです。JOIN FETCHは複雑な条件を柔軟に書けるのが強み、@EntityGraph は宣言的で再利用しやすいのが強み、と覚えておくと使い分けやすいです。フェッチ戦略そのものの詳しい話は Spring BootのJPAエンティティ関連マッピング の記事に譲ります。

解決策3: DTO射影で必要なデータだけを取得する

そもそもエンティティやプロキシを層をまたいで持ち回らない、という発想です。クエリの時点で必要なフィールドだけを平坦なDTOへ変換してしまえば、遅延プロキシは最初から存在しません。

public interface OrderSummary {
    Long getId();
    String getCustomerName();
    int getItemCount();
}

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o.id as id, o.customer.name as customerName, size(o.items) as itemCount from Order o")
    List<OrderSummary> findSummaries();
}

DTOにプロキシが含まれないので、トランザクション外で参照しても例外は起きません。表示やAPI返却の用途では過剰なエンティティ取得も避けられ、責務分離としても健全です。射影の書き分けは Spring Data JPAのDTO射影・インターフェース射影 にまとめてあります。例外を根本から断ちたいなら第一候補です。

解決策4: @Transactionalを適切に付け、サービス層で初期化する

既存の構造を大きく変えたくないなら、トランザクション境界の内側でLazyアクセスを完結させます。Service層で必要な関連に触って初期化し、Controllerには初期化済みのDTOを返すのが安全です。

@Service
public class OrderService {

    @Transactional(readOnly = true)
    public OrderDto getOrder(Long id) {
        Order order = orderRepository.findById(id).orElseThrow();
        return new OrderDto(order.getId(),
                order.getItems().stream().map(ItemDto::from).toList());
    }
}

ポイントは、エンティティのままControllerへ渡さないことです。「閉じる前に触る」設計にしておけば、境界の外で不意にプロキシを踏むことがなくなります。伝播や readOnly の詳細は Spring Bootのトランザクション管理 を参照してください。

open-in-view=true で塞ぐべきではない理由

spring.jpa.open-in-view=true(実はデフォルトでtrue)にすると、ビュー描画までセッションを開いたままにするので、確かに例外は消えます。

# 明示的にoffにして、境界を意識する運用が推奨
spring.jpa.open-in-view=false

ただしSpring Bootは起動時にこの設定へ警告を出します。ビュー層で暗黙のDBアクセスやN+1が発生しても気づきにくくなり、DBコネクションをレスポンス完了まで握り続けるためリソース面の副作用もあるからです。「エラーが消えたから解決」ではなく、どこでDBに触れているかを見えなくしているだけ、と捉えるべきです。原則は false にして境界を意識し、上の4つで対処しましょう。

EAGER化で逃げるべきではない理由

関連を FetchType.EAGER にすれば例外は出なくなります。しかしEAGERは 常に その関連を取りに行くため、その関連が不要な一覧やバッチでも余計な結合やN+1を招きます。一箇所の例外回避のために、アプリ全体のクエリコストを恒久的に上げてしまうわけです。

必要な場所でだけ取りに行く(JOIN FETCH / @EntityGraph / DTO射影)ほうが、はるかに制御しやすいです。N+1と性能の観点は Spring Data JPAのパフォーマンス最適化 で詳しく扱っています。

4つの正攻法の使い分け - 状況別の判断軸

迷ったら、用途で選ぶのがシンプルです。

  • 画面表示で関連がまとまって必要 … JOIN FETCH または @EntityGraph
  • API返却や一覧で必要フィールドが限定的 … DTO射影が最有力
  • 既存ロジックを大きく変えず安全に閉じたい … @Transactional内で初期化してDTOを返す
  • open-in-view=true と EAGER化 … 原則避け、選ぶ理由を説明できるときだけ

表示とAPIで別のリポジトリメソッドを用意するのはまったく普通のことです。ひとつのエンティティ取得を無理に使い回そうとしないのがコツです。

まとめ

LazyInitializationExceptionの本質は「セッションが閉じたあとにプロキシを初期化しようとした」ことです。切り分けは「どこでLazyアクセスしていて、そのときトランザクションが開いているか」を見るだけ。恒久対処は JOIN FETCH / @EntityGraph / DTO射影 / @Transactionalの適切な配置から、用途に応じて選びます。

open-in-view=true と EAGER化はエラーを隠すだけで、性能や見通しの面で代償を払います。安易に採用せず、必要な場所で必要な形だけ取りに行く。この習慣が身につくと、この例外とはきれいに縁が切れますよ。