Spring JPAを使用したアプリケーション開発において、エンティティライフサイクルの理解とコールバックの活用は、データの整合性維持や共通処理の自動化に不可欠です。本記事では、JPAのエンティティライフサイクルイベント(persist、update、remove、load)と、@PrePersist、@PostPersist、@PreUpdate、@PostUpdate、@PreRemove、@PostRemove、@PostLoadの各コールバックアノテーションについて、その発火タイミングと実践的な活用パターンを解説します。作成日時の自動設定、監査ログ、バリデーション処理などの実装例を通じて、エンティティライフサイクルコールバックの効果的な使い方を習得しましょう。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Data JPA |
3.4.x(Spring Boot Starterに含まれる) |
| Hibernate |
6.6.x(Spring Data JPAに含まれる) |
| Jakarta Persistence |
3.2 |
| データベース |
H2 Database(開発・テスト用インメモリDB) |
| ビルドツール |
Maven または Gradle |
| IDE |
VS Code または IntelliJ IDEA |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Spring Boot + Spring Data JPAプロジェクトの基本構成
- 永続化コンテキストとエンティティ状態の基本知識(永続化コンテキスト入門を参照)
エンティティライフサイクルの全体像#
JPAにおけるエンティティは、永続化コンテキスト内で4つの状態(New、Managed、Detached、Removed)を遷移します。ライフサイクルコールバックは、これらの状態遷移に伴うデータベース操作の前後で自動的に呼び出されるメソッドです。
flowchart TB
subgraph States["エンティティの状態"]
New["New(新規)"]
Managed["Managed(管理)"]
Detached["Detached(分離)"]
Removed["Removed(削除)"]
end
subgraph Callbacks["コールバック発火ポイント"]
PrePersist["@PrePersist"]
PostPersist["@PostPersist"]
PreUpdate["@PreUpdate"]
PostUpdate["@PostUpdate"]
PreRemove["@PreRemove"]
PostRemove["@PostRemove"]
PostLoad["@PostLoad"]
end
New -->|"persist()"| PrePersist
PrePersist -->|"DBへINSERT"| PostPersist
PostPersist --> Managed
Managed -->|"フィールド変更"| PreUpdate
PreUpdate -->|"DBへUPDATE"| PostUpdate
PostUpdate --> Managed
Managed -->|"remove()"| PreRemove
PreRemove -->|"DBからDELETE"| PostRemove
PostRemove --> Removed
Managed -->|"detach()/clear()"| Detached
Detached -->|"merge()"| Managed
Managed -->|"find()/query"| PostLoad
PostLoad --> Managedライフサイクルコールバックの種類#
JPAでは7種類のライフサイクルコールバックアノテーションが定義されています。
| アノテーション |
発火タイミング |
主な用途 |
@PrePersist |
persist()実行時、INSERT前 |
作成日時設定、バリデーション |
@PostPersist |
INSERT完了後 |
監査ログ、通知処理 |
@PreUpdate |
UPDATE前 |
更新日時設定、バリデーション |
@PostUpdate |
UPDATE完了後 |
監査ログ、キャッシュ更新 |
@PreRemove |
DELETE前 |
バリデーション、関連データ処理 |
@PostRemove |
DELETE完了後 |
監査ログ、リソース解放 |
@PostLoad |
SELECT完了後 |
一時データの初期化、計算フィールド |
コールバックメソッドの定義方法#
ライフサイクルコールバックは、エンティティクラス内に直接定義する方法と、専用のリスナークラスに分離する方法の2通りがあります。
エンティティクラス内での定義#
最もシンプルな方法は、エンティティクラス内にコールバックメソッドを直接定義することです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// コールバックメソッドはpublic、protected、package-private、privateのいずれでも可
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
@PostLoad
protected void onLoad() {
// 読み込み後の初期化処理
System.out.println("Article loaded: " + this.id);
}
// ゲッター・セッター省略
}
|
コールバックメソッドには以下の制約があります。
- 戻り値は
void
- 引数なし(エンティティクラス内で定義する場合)
staticやfinalは不可
- 任意のアクセス修飾子を使用可能
エンティティリスナークラスによる定義#
複数のエンティティで共通のコールバック処理を実装する場合、エンティティリスナークラスを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import jakarta.persistence.*;
import java.time.LocalDateTime;
public class AuditListener {
@PrePersist
public void setCreatedAt(Object entity) {
if (entity instanceof Auditable auditable) {
LocalDateTime now = LocalDateTime.now();
auditable.setCreatedAt(now);
auditable.setUpdatedAt(now);
}
}
@PreUpdate
public void setUpdatedAt(Object entity) {
if (entity instanceof Auditable auditable) {
auditable.setUpdatedAt(LocalDateTime.now());
}
}
}
|
リスナークラスのコールバックメソッドは、対象のエンティティインスタンスを引数として受け取ります。共通インターフェースを定義することで、型安全にアクセスできます。
1
2
3
4
5
6
7
8
|
import java.time.LocalDateTime;
public interface Auditable {
LocalDateTime getCreatedAt();
void setCreatedAt(LocalDateTime createdAt);
LocalDateTime getUpdatedAt();
void setUpdatedAt(LocalDateTime updatedAt);
}
|
エンティティにリスナーを適用するには、@EntityListenersアノテーションを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
@EntityListeners(AuditListener.class)
public class Product implements Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer price;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// Auditableインターフェースの実装
@Override
public LocalDateTime getCreatedAt() {
return createdAt;
}
@Override
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
@Override
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
@Override
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
// その他のゲッター・セッター省略
}
|
各コールバックの詳細と発火タイミング#
@PrePersist - 新規エンティティ保存前#
@PrePersistは、EntityManager.persist()が呼び出された時点で、データベースへのINSERT文が実行される前に発火します。merge()によって新規エンティティが作成される場合も、マネージドインスタンスにコピーされた後に発火します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_sequence", allocationSize = 1)
private Long id;
@Column(name = "order_number", nullable = false, unique = true)
private String orderNumber;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OrderStatus status;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onPrePersist() {
// 作成日時の自動設定
this.createdAt = LocalDateTime.now();
// 初期ステータスの設定
if (this.status == null) {
this.status = OrderStatus.PENDING;
}
// 注文番号の自動生成
if (this.orderNumber == null) {
this.orderNumber = generateOrderNumber();
}
}
private String generateOrderNumber() {
return "ORD-" + System.currentTimeMillis();
}
}
|
重要な注意点として、@GeneratedValueの戦略によって@PrePersist時点でIDが利用可能かどうかが異なります。
| 戦略 |
@PrePersistでID利用可能 |
| SEQUENCE |
可能 |
| TABLE |
可能 |
| UUID |
可能 |
| IDENTITY |
不可(INSERT後に生成) |
@PostPersist - 新規エンティティ保存後#
@PostPersistは、データベースへのINSERT操作が完了した直後に発火します。生成された主キー値は常に利用可能です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
@Entity
@Table(name = "users")
@EntityListeners(UserLifecycleListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
// ゲッター・セッター省略
}
public class UserLifecycleListener {
@PostPersist
public void afterCreate(User user) {
// ID生成後なので確実に利用可能
System.out.println("New user created with ID: " + user.getId());
// 監査ログの記録(外部システム連携など)
// 注意: このコールバック内でEntityManagerを使用する場合は慎重に
}
}
|
@PreUpdate - エンティティ更新前#
@PreUpdateは、管理状態のエンティティに対する変更がデータベースに同期される前に発火します。発火タイミングは、エンティティの状態が変更された時点ではなく、flush()またはトランザクションコミット時です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
@Entity
@Table(name = "documents")
public class Document {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(nullable = false)
private Integer version;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "content_hash")
private String contentHash;
@PreUpdate
protected void onPreUpdate() {
this.updatedAt = LocalDateTime.now();
// コンテンツのハッシュ値を更新
if (this.content != null) {
this.contentHash = calculateHash(this.content);
}
}
private String calculateHash(String content) {
// 実際にはMessageDigestなどを使用
return Integer.toHexString(content.hashCode());
}
}
|
注意点として、@PreUpdateはダーティチェックによって実際に変更が検出された場合にのみ発火します。同じ値を再設定しただけでは発火しません。
@PostUpdate - エンティティ更新後#
@PostUpdateは、データベースへのUPDATE操作が完了した直後に発火します。
1
2
3
4
5
6
7
8
9
10
|
public class DocumentLifecycleListener {
@PostUpdate
public void afterUpdate(Document document) {
// キャッシュの無効化通知
System.out.println("Document updated: " + document.getId());
// 外部検索インデックスの更新トリガーなど
}
}
|
@PreRemove - エンティティ削除前#
@PreRemoveは、EntityManager.remove()が呼び出された時点で、データベースからのDELETE文が実行される前に発火します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "category")
private List<Product> products = new ArrayList<>();
@PreRemove
protected void onPreRemove() {
// 関連する商品が存在する場合は削除を禁止
if (!products.isEmpty()) {
throw new IllegalStateException(
"Cannot delete category with associated products. " +
"Please reassign or delete products first."
);
}
}
}
|
@PostRemove - エンティティ削除後#
@PostRemoveは、データベースからのDELETE操作が完了した直後に発火します。
1
2
3
4
5
6
7
8
9
10
|
public class CategoryLifecycleListener {
@PostRemove
public void afterRemove(Category category) {
// 関連リソースのクリーンアップ
System.out.println("Category removed: " + category.getId());
// ファイルシステム上の関連画像削除など
}
}
|
@PostLoad - エンティティ読み込み後#
@PostLoadは、エンティティがデータベースから読み込まれた後、またはrefresh()操作の後に発火します。クエリ結果が返される前、アソシエーションがトラバースされる前に呼び出されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Integer balance;
@Column(name = "preferred_threshold")
private Integer preferredThreshold;
// 永続化されない一時的なフィールド
@Transient
private boolean preferred;
@Transient
private String displayName;
@PostLoad
protected void onPostLoad() {
// 一時フィールドの計算
this.preferred = (this.balance >= getPreferredThreshold());
// 表示用データの初期化
this.displayName = formatDisplayName();
}
private int getPreferredThreshold() {
return preferredThreshold != null ? preferredThreshold : 10000;
}
private String formatDisplayName() {
return "Account #" + String.format("%08d", this.id);
}
public boolean isPreferred() {
return preferred;
}
}
|
実践的な活用パターン#
パターン1: 監査情報の自動設定#
Spring Data JPAの@CreatedDate、@LastModifiedDateと同等の機能をJPAコールバックで実装する方法です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
import jakarta.persistence.*;
import java.time.LocalDateTime;
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "created_by", updatable = false)
private String createdBy;
@Column(name = "updated_by")
private String updatedBy;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
String currentUser = getCurrentUser();
this.createdBy = currentUser;
this.updatedBy = currentUser;
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
this.updatedBy = getCurrentUser();
}
// SecurityContextから現在のユーザーを取得
private String getCurrentUser() {
// Spring Security使用時の例
// return SecurityContextHolder.getContext()
// .getAuthentication().getName();
return "system"; // デフォルト値
}
// ゲッター省略
}
|
継承して使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Entity
@Table(name = "tasks")
public class Task extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column
private String description;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private TaskStatus status = TaskStatus.TODO;
// ゲッター・セッター省略
}
|
パターン2: ビジネスルールのバリデーション#
永続化前にビジネスルールを検証し、不正なデータの保存を防ぎます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
@Entity
@Table(name = "reservations")
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private Integer guests;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ReservationStatus status;
@PrePersist
@PreUpdate
protected void validate() {
// 日付の整合性チェック
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException(
"End date must be after start date"
);
}
// 過去の日付への予約を禁止
if (startDate.isBefore(LocalDate.now())) {
throw new IllegalArgumentException(
"Cannot create reservation for past dates"
);
}
// ゲスト数の上限チェック
if (guests < 1 || guests > 10) {
throw new IllegalArgumentException(
"Number of guests must be between 1 and 10"
);
}
}
}
|
パターン3: 暗号化・復号化#
センシティブなデータをデータベースに暗号化して保存し、読み込み時に復号化します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
@Entity
@Table(name = "credentials")
public class Credential {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String username;
// データベースには暗号化された値を保存
@Column(name = "encrypted_password", nullable = false)
private String encryptedPassword;
// アプリケーションで使用する平文(非永続化)
@Transient
private String plainPassword;
@PrePersist
@PreUpdate
protected void encryptPassword() {
if (plainPassword != null) {
this.encryptedPassword = encrypt(plainPassword);
}
}
@PostLoad
protected void decryptPassword() {
if (encryptedPassword != null) {
this.plainPassword = decrypt(encryptedPassword);
}
}
private String encrypt(String value) {
// 実際には適切な暗号化ライブラリを使用
// 例: AES、Jasypt など
return "ENC:" + java.util.Base64.getEncoder()
.encodeToString(value.getBytes());
}
private String decrypt(String encrypted) {
if (encrypted.startsWith("ENC:")) {
return new String(java.util.Base64.getDecoder()
.decode(encrypted.substring(4)));
}
return encrypted;
}
public void setPassword(String password) {
this.plainPassword = password;
}
public String getPassword() {
return plainPassword;
}
}
|
パターン4: CDI/DIを使用したリスナー#
Spring環境では、エンティティリスナーにDIを使用して外部サービスを注入できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import jakarta.persistence.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class AuditLogListener {
private static AuditLogService auditLogService;
@Autowired
public void setAuditLogService(AuditLogService service) {
AuditLogListener.auditLogService = service;
}
@PostPersist
public void logCreate(Object entity) {
auditLogService.logCreate(entity);
}
@PostUpdate
public void logUpdate(Object entity) {
auditLogService.logUpdate(entity);
}
@PostRemove
public void logDelete(Object entity) {
auditLogService.logDelete(entity);
}
}
|
ただし、この手法には制限があります。より堅牢な方法として、HibernateのIntegratorを使用する方法があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import org.hibernate.boot.Metadata;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.event.service.spi.EventListenerRegistry;
import org.hibernate.event.spi.EventType;
import org.hibernate.integrator.spi.Integrator;
import org.hibernate.service.spi.SessionFactoryServiceRegistry;
import org.springframework.stereotype.Component;
@Component
public class AuditIntegrator implements Integrator {
@Override
public void integrate(
Metadata metadata,
SessionFactoryImplementor sessionFactory,
SessionFactoryServiceRegistry serviceRegistry) {
EventListenerRegistry eventListenerRegistry =
serviceRegistry.getService(EventListenerRegistry.class);
// イベントリスナーの登録
eventListenerRegistry.appendListeners(
EventType.POST_INSERT,
new CustomPostInsertEventListener()
);
}
@Override
public void disintegrate(
SessionFactoryImplementor sessionFactory,
SessionFactoryServiceRegistry serviceRegistry) {
// クリーンアップ処理
}
}
|
コールバック実行順序#
複数のコールバックが定義されている場合、以下の順序で実行されます。
- デフォルトリスナー(
persistence.xmlで定義)
- 親クラスのエンティティリスナー(
@EntityListeners)
- 自クラスのエンティティリスナー(
@EntityListeners)
- 親クラスのコールバックメソッド
- 自クラスのコールバックメソッド
flowchart TB
subgraph Execution["コールバック実行順序"]
Default["1. デフォルトリスナー"]
ParentListener["2. 親クラスの@EntityListeners"]
ChildListener["3. 自クラスの@EntityListeners"]
ParentCallback["4. 親クラスのコールバックメソッド"]
ChildCallback["5. 自クラスのコールバックメソッド"]
end
Default --> ParentListener
ParentListener --> ChildListener
ChildListener --> ParentCallback
ParentCallback --> ChildCallback特定のリスナーを除外するには、以下のアノテーションを使用します。
1
2
3
4
5
6
|
@Entity
@ExcludeDefaultListeners // デフォルトリスナーを除外
@ExcludeSuperclassListeners // 親クラスのリスナーを除外
public class SpecialEntity {
// ...
}
|
よくある誤解とアンチパターン#
アンチパターン1: コールバック内でのEntityManager操作#
コールバックメソッド内でEntityManagerやQueryを直接使用することは推奨されません。予期しない動作やデッドロックの原因となります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 避けるべきパターン
@Entity
public class BadExample {
@PersistenceContext
private EntityManager em; // エンティティにインジェクションしない
@PostPersist
protected void afterCreate() {
// コールバック内でのクエリ実行は避ける
em.createQuery("SELECT COUNT(b) FROM BadExample b")
.getSingleResult(); // 危険
}
}
|
代わりに、イベント駆動アーキテクチャを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Entity
@EntityListeners(AsyncEventPublisher.class)
public class GoodExample {
// ...
}
@Component
public class AsyncEventPublisher {
private final ApplicationEventPublisher eventPublisher;
public AsyncEventPublisher(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@PostPersist
public void publishCreatedEvent(Object entity) {
eventPublisher.publishEvent(new EntityCreatedEvent(entity));
}
}
|
アンチパターン2: @PreUpdateと@PrePersistの発火タイミングの誤解#
@PreUpdateは、エンティティを新規作成して同一トランザクション内で変更した場合に発火するかどうかは実装依存です。ポータブルなアプリケーションではこの動作に依存すべきではありません。
1
2
3
4
5
6
7
8
9
10
|
// 注意が必要なケース
@Transactional
public void createAndUpdate() {
Entity entity = new Entity();
entity.setName("initial");
entityManager.persist(entity); // @PrePersist発火
entity.setName("modified");
// @PreUpdateが発火するかは実装依存
}
|
アンチパターン3: 重い処理の実行#
コールバックはトランザクション内で同期的に実行されます。重い処理はトランザクション全体のパフォーマンスに影響を与えます。
1
2
3
4
5
6
7
8
9
|
// 避けるべきパターン
@PostPersist
protected void afterCreate() {
// 外部API呼び出しは避ける
httpClient.post("https://external-api.com/notify", this);
// ファイルI/Oも避ける
Files.write(Path.of("/logs/audit.log"), toString().getBytes());
}
|
アンチパターン4: リレーションシップの変更#
コールバック内でリレーションシップを変更すると、予期しない結果になる可能性があります。
1
2
3
4
5
6
|
// 避けるべきパターン
@PrePersist
protected void beforeCreate() {
// 他のエンティティとの関連を変更しない
this.parent.getChildren().add(this); // 危険
}
|
まとめと実践Tips#
Spring JPAのエンティティライフサイクルコールバックは、横断的関心事を実装する強力な手段です。適切に活用することで、コードの重複を削減し、データの整合性を維持できます。
実践Tips#
-
単一責任の原則を守る: 各コールバックメソッドは1つの責務に集中させます。
-
共通処理はMappedSuperclassに: 監査情報の設定など、複数エンティティで共通の処理は基底クラスに定義します。
-
例外処理を適切に: コールバック内で発生した例外はトランザクションをロールバックします。意図的なバリデーション以外では例外を投げないようにします。
-
Spring Data JPA Auditingとの併用: 監査情報の設定にはSpring Data JPAの@EnableJpaAuditing機能も検討してください。より宣言的なアプローチが可能です。
-
テストを忘れずに: コールバックの動作は統合テストで確認します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@DataJpaTest
class ArticleLifecycleTest {
@Autowired
private TestEntityManager entityManager;
@Test
void createdAtShouldBeSetOnPersist() {
Article article = new Article();
article.setTitle("Test");
article.setContent("Content");
Article saved = entityManager.persistAndFlush(article);
assertThat(saved.getCreatedAt()).isNotNull();
assertThat(saved.getUpdatedAt()).isNotNull();
}
}
|
参考リンク#