
※この記事にはアフィリエイトリンクを含みます。
Contents
TriggerHandlerパターンで「受付窓口」に変える
結論から言います。
トリガーファイルに直接ロジックを書き続けると、必ず『誰も触りたくないトリガー』が生まれます。
解決策は、トリガーファイルを「受付窓口」に限定して、処理の中身はHandlerクラスに丸投げする設計に変えることです。これがTriggerHandlerパターンです。
構造としてはシンプルです。
AccountTrigger.trigger(受付窓口:1〜3行)
↓
AccountTriggerHandler.cls(ロジックの実体)
↓
AccountService.cls(ドメインロジック)
トリガーファイルはこれだけになります。
trigger AccountTrigger on Account (
before insert, before update,
after insert, after update, after delete
) {
new AccountTriggerHandler().run();
}
これで再帰制御・テスト・可読性の問題がまとめて片づきます。
500行のトリガーを前に、手が止まった
Salesforce開発の現場で、こういうコードを見たことはないでしょうか。
trigger AccountTrigger on Account (before insert, before update, after insert) {
for (Account acc : Trigger.new) {
if (Trigger.isBefore && Trigger.isInsert) {
acc.Name = acc.Name.trim();
// 処理がどんどん増えていく...
}
if (Trigger.isAfter && Trigger.isInsert) {
// 別の処理...
}
}
}
最初は数十行だったはずが、機能追加のたびに膨れ上がる。
気づけば500行超。「ここ触ったら何か壊れそう」と感じながら、それでも追記していく。
開発歴1〜3年のうちに、一度はこのトリガーに当たります。今回はそのトリガーを根本から直す方法を、実装コード付きで整理します。
直書きトリガーが壊れていく3つの理由
① 再帰呼び出しが止まらない
トリガー内でレコードを更新すると、再びトリガーが発火します。
「とりあえず再帰防止」でよく見るコードがこれです。
trigger AccountTrigger on Account (after update) {
if (AccountTrigger.hasRun) return;
AccountTrigger.hasRun = true;
// ...
}
このフラグはトランザクション内でリセットされないため、バッチ処理や複合更新で想定外の動作を引き起こします。各トリガーに散らばったフラグは、いずれ管理しきれなくなります。
② 処理の順序が追いにくい
before insert、after update など複数のコンテキストが1ファイルに混在すると、「どのタイミングでどの処理が走るか」をコードから読み取るのに時間がかかります。
レビュー工数が増え、バグの見落としにつながります。
③ 単体テストが書きにくい
トリガー本体にロジックが混入していると、テストクラスから「特定のメソッドだけを呼ぶ」ことができません。
テストカバレッジを上げるためにデータを大量に用意する必要が生まれ、テストが遅く・脆くなっていきます。
「直書きあるある」4選
現場でよく見かけるパターンを整理します。
① 再帰防止フラグがトリガーごとにバラバラ オブジェクトが増えるたびに同じフラグを各ファイルにコピペ。管理の起点がどこかわからなくなる。
② コメントで「ここから〇〇処理」と仕切り始める 1ファイルがセクション分けされ始めたら、分割のサインです。コメントで整理しようとするほど、読みづらくなります。
③ テストクラスでDMLを大量に書く 「テスト用データを用意しないとロジックに届かない」状態は、設計の問題です。テストが重くなり、CIが遅くなります。
④ 「このトリガー、誰が書いたの」案件になる コンテキストとロジックが混在すると、追跡コストが跳ね上がります。コードレビューで指摘しにくい「空気のバグ」が生まれます。
・そのとき、コードを読むのに何分かかりましたか。
TriggerHandlerの実装:3ファイルで完成する
① 基底クラス(TriggerHandler.cls)
再帰防止と各コンテキストへの振り分けを担います。1度作ればすべてのトリガーで使い回せます。
public virtual class TriggerHandler {
@TestVisible
private static Map<String, Set<String>> bypassedHandlers =
new Map<String, Set<String>>();
private String handlerName;
public TriggerHandler() {
this.handlerName = getHandlerName();
}
public void run() {
if (isBypassed(this.handlerName)) return;
if (Trigger.isBefore) {
if (Trigger.isInsert) beforeInsert();
if (Trigger.isUpdate) beforeUpdate();
if (Trigger.isDelete) beforeDelete();
} else if (Trigger.isAfter) {
if (Trigger.isInsert) afterInsert();
if (Trigger.isUpdate) afterUpdate();
if (Trigger.isDelete) afterDelete();
if (Trigger.isUndelete) afterUndelete();
}
}
protected virtual void beforeInsert() {}
protected virtual void beforeUpdate() {}
protected virtual void beforeDelete() {}
protected virtual void afterInsert() {}
protected virtual void afterUpdate() {}
protected virtual void afterDelete() {}
protected virtual void afterUndelete() {}
public static void bypass(String handlerName) {
if (!bypassedHandlers.containsKey(handlerName)) {
bypassedHandlers.put(handlerName, new Set<String>());
}
}
public static void clearBypass(String handlerName) {
bypassedHandlers.remove(handlerName);
}
public static Boolean isBypassed(String handlerName) {
return bypassedHandlers.containsKey(handlerName);
}
private String getHandlerName() {
return String.valueOf(this).substring(0, String.valueOf(this).indexOf(':'));
}
}
② Handlerクラス(AccountTriggerHandler.cls)
オブジェクト固有のロジックをここに集約します。使わないコンテキストのメソッドはオーバーライドしないだけでOKです。
public class AccountTriggerHandler extends TriggerHandler {
private List<Account> newList;
private Map<Id, Account> oldMap;
public AccountTriggerHandler() {
this.newList = (List<Account>) Trigger.new;
this.oldMap = (Map<Id, Account>) Trigger.oldMap;
}
protected override void beforeInsert() {
AccountService.trimNames(this.newList);
}
protected override void afterInsert() {
AccountService.createDefaultContacts(this.newList);
}
protected override void afterUpdate() {
AccountService.syncToExternalSystem(this.newList, this.oldMap);
}
}
③ テストのポイント:Handlerを直接呼ぶ
重要なロジックはHandlerのメソッドをDMLなしで呼び出してテストします。
@isTest
private class AccountTriggerHandlerTest {
@isTest
static void testBeforeInsert_trimNames() {
List<Account> accounts = new List<Account>{
new Account(Name = ' テスト株式会社 ', BillingCity = '札幌市')
};
Test.startTest();
AccountService.trimNames(accounts);
Test.stopTest();
System.assertEquals('テスト株式会社', accounts[0].Name, '前後の空白が除去されていること');
}
}
バイパス機能の使いどころ
データ移行やバッチ処理でトリガーをスキップしたいときに使います。
TriggerHandler.bypass('AccountTriggerHandler');
// ... DML処理 ...
TriggerHandler.clearBypass('AccountTriggerHandler');
注意点が2つあります。
bypass() したまま処理が終わると、同一トランザクション内の後続処理もスキップされます。finally ブロックで必ず clearBypass() を呼んでください。
また、HandlerクラスのコンストラクタでTrigger.newを参照しているため、テスト内でHandlerを直接インスタンス化するとエラーになります。ロジックはServiceクラスに切り出し、Serviceクラスをテストする設計にするとこの問題を回避できます。
既存トリガーのリファクタリングは、段階的に
※本番環境への適用前に、必ずサンドボックスで動作確認してください。
一気にやろうとすると危険です。以下のステップで進めるのが現実的です。
Step 1:まず TriggerHandler.cls(基底クラス)だけ追加する。既存コードには触れない。
Step 2:影響範囲が最小のオブジェクト(新規追加予定か、変更頻度が低いもの)を1つ選び、Handlerクラスを作成してトリガーを移行する。
Step 3:カバレッジとリグレッションテストを確認したうえで、次のオブジェクトに着手する。
元のトリガーファイルを残しつつ、Handlerクラスにロジックを転記→テスト合格→古いロジックをコメントアウト→本番適用後に削除、という順序で進めると切り戻しが楽です。
今日やること
次に読んでほしい記事
TriggerHandlerで設計を整えたあと、ServiceクラスやSelectorクラスの整理に進む人が多いです。
次はApex開発での設計判断まわりの話を書いています。
まとめ
直書きトリガーが抱える問題は、再帰・可読性・テストの3つです。TriggerHandlerパターンはこの3つをまとめて解決します。
導入コストは低く、基底クラス1本追加するだけで始められます。新規プロジェクトなら初日から、既存プロジェクトでも次のリファクタリングのタイミングで検討してみてください。
トリガーが1〜3行になったとき、コードレビューがかなり楽になります。
Salesforce は salesforce.com, Inc. の登録商標です。
