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
  • 引数なし(エンティティクラス内で定義する場合)
  • staticfinalは不可
  • 任意のアクセス修飾子を使用可能

エンティティリスナークラスによる定義

複数のエンティティで共通のコールバック処理を実装する場合、エンティティリスナークラスを使用します。

 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) {
        // クリーンアップ処理
    }
}

コールバック実行順序

複数のコールバックが定義されている場合、以下の順序で実行されます。

  1. デフォルトリスナー(persistence.xmlで定義)
  2. 親クラスのエンティティリスナー(@EntityListeners
  3. 自クラスのエンティティリスナー(@EntityListeners
  4. 親クラスのコールバックメソッド
  5. 自クラスのコールバックメソッド
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. 単一責任の原則を守る: 各コールバックメソッドは1つの責務に集中させます。

  2. 共通処理はMappedSuperclassに: 監査情報の設定など、複数エンティティで共通の処理は基底クラスに定義します。

  3. 例外処理を適切に: コールバック内で発生した例外はトランザクションをロールバックします。意図的なバリデーション以外では例外を投げないようにします。

  4. Spring Data JPA Auditingとの併用: 監査情報の設定にはSpring Data JPAの@EnableJpaAuditing機能も検討してください。より宣言的なアプローチが可能です。

  5. テストを忘れずに: コールバックの動作は統合テストで確認します。

 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();
    }
}

参考リンク