エラーメッセージ
それは通常、予期せぬタイミングで発生します。saveメソッドを呼び出すと、コンソールに次のようなスタックトレースが表示され、エラーが発生します。
org.hibernate.PersistentObjectException: detached entity passed to persist
at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:124)
at org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:775)
at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:754)
Hibernateがエラーを出す理由
このエラーは、Hibernateからの「新しいレコードを作成するように指示されましたが、このオブジェクトには既存のレコードに属するIDが既に設定されています」という通知です。Hibernateはステートマシンを使用してエンティティを追跡します。これを解決するには、オブジェクトがどの状態にあるかを知る必要があります。
- **Transient(一時的):** 新規オブジェクト。IDを持たず、Hibernateもその存在をまだ認識していません。
- **Persistent(永続的):** 現在のデータベースセッションに関連付けられたオブジェクト。Hibernateは、そのオブジェクトに対するすべての変更を追跡します。
- **Detached(分離):** ID(例:`id=101`)を持っているが、アクティブなセッションにはもうリンクされていないオブジェクト。
persist()メソッドは非常に厳格です。このメソッドはtransientなエンティティのみを受け入れます。既にプライマリキーを持っているdetachedなエンティティを渡すと、Hibernateは古いレコードを更新すべきか、あるいはIDを無視すべきか判断できず、パニックを起こして例外をスローします。
一般的なシナリオと解決策
1. 手動によるID設定の罠
新しいオブジェクトに対して手動でIDを設定していませんか?もし entity.setId(500L) を実行してから repository.save() を呼び出すと、HibernateはそのIDを見て、レコードが既に存在すると判断します。新規レコードとして persist 操作を実行しようとした際に、この矛盾が原因で例外が発生します。
解決策: IDの管理はデータベースに任せましょう。@GeneratedValue を使用し、新しいエントリに対してIDフィールドを操作しないようにします。どうしても手動でIDを使用する必要がある場合は、エンティティに Persistable を実装し、オブジェクトが実際に新規であるかどうかをSpring Data JPAに明示的に伝えられるようにしてください。
2. カスケードの落とし穴(最大の原因)
多くの開発者が、親子関係の操作中にこの問題に直面します。データベースから既存の Category(ID: 5)を取得し、それを新しい Product に関連付ける場面を想像してください。マッピングが以下のようになっているとします。
@ManyToOne(cascade = CascadeType.PERSIST)
private Category category;
Product を保存すると、Hibernateは Category もpersist(永続化)しようとします。しかし、Category には既にID 5が設定されているため、PERSIST 操作は失敗します。Hibernateは新規オブジェクトを期待している場所に、分離された(detached)エンティティを見つけてしまうからです。
解決策: カスケードのロジックを更新してください。MERGE または ALL を使用することで、Hibernateが既存のレコードを適切に処理できるようにします。
// PERSISTのみではなく、両方を使用します:
@ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Category category;
3. REST APIからのJSONデータの処理
モバイルアプリやフロントエンドからIDを含むJSONオブジェクトが送信されると、Hibernateはそれをdetached(分離)状態として扱います。サービス層で entityManager.persist() を使用してこのオブジェクトを直接保存しようとすると、必ず失敗します。
解決策: repository.save() を使用してください。Spring Data JPAのこのメソッドは賢く、IDが存在するかどうかをチェックします。存在する場合は merge() を呼び出し、IDがnullの場合は persist() を呼び出します。EntityManager を直接操作している場合は、外部システムから渡されたオブジェクトに対しては常に em.merge(entity) を優先して使用してください。
4. データベースシーケンスの同期ずれ
コードに問題がなくても、データが不整合を起こしている場合があります。もしPostgreSQLのシーケンスが1から始まっているのに、手動で100行挿入してしまった場合、HibernateはID 1を生成しようとするかもしれません。しかし、テーブルには既にID 1が存在するため、永続化コンテキストが混乱します。
解決策: シーケンスを同期させます。SELECT setval('your_sequence_name', (SELECT max(id) FROM your_table)); のようなSQLコマンドを実行して、Hibernateが既に使われているIDを生成しないように設定してください。
解決策のテスト
推測するのではなく、検証しましょう。既存の子要素を持つ新しい親要素を保存するシミュレーションを行う簡単な統合テストを作成できます。これにより、CascadeType の設定が実際に正しく機能していることを確認できます。
@SpringBootTest
class SaveOperationTest {
@Autowired private ProductRepository productRepo;
@Autowired private CategoryRepository categoryRepo;
@Test
void shouldSaveProductWithExistingCategory() {
Category existing = categoryRepo.save(new Category("Electronics"));
Product laptop = new Product("MacBook");
laptop.setCategory(existing);
// カスケードがPERSISTのみの場合、ここでPersistentObjectExceptionがスローされます
assertDoesNotThrow(() -> productRepo.save(laptop));
}
}
クリーンな永続化のためのベストプラクティス
- **リポジトリに徹する:** 同一サービス内で `EntityManager` と `JpaRepository` を混在させないようにしましょう。リポジトリは `persist` と `merge` のロジックを自動的に処理します。
- **カスケード設定を監査する:** `CascadeType.ALL` は便利ですが危険です。意図しない削除や状態の競合を避けるために、`{PERSIST, MERGE}` のように具体的なタイプを指定してください。
- **DTOを使用する:** 生のエンティティを直接フロントエンドに渡さないでください。Hibernateセッションをクリーンに保つために、DTOをデータベースから取得した最新のエンティティにマッピングするようにしましょう。

