Claude Media
Claude CodeでApexトリガーをハンドラクラスに分離する手順 — 1オブジェクト1トリガー

Claude CodeでApexトリガーをハンドラクラスに分離する手順 — 1オブジェクト1トリガー

肥大したApexトリガーを、薄いトリガー本体とハンドラクラスに分ける手順です。既存テストを先に通し、規約をCLAUDE.mdとルールに書いて、Claudeの書き換えを縛ります。

Apexトリガーの本文に業務ロジックが溜まったら、トリガーは1オブジェクトに1本だけ残し、処理は OpportunityTriggerHandler のようなハンドラクラスへ移します。Claude Codeに頼むときの要点は2つです。命名と構造の規約を CLAUDE.md と .claude/rules/ に書いておくこと。そして、移す前に既存テストを通して基準線を取り、移したあとに同じテストを同じ手順で回すことです。

この記事では、分離後の構造、規約の書き方、依頼文の順序、途中でハマりやすい挙動、変更が「構造だけ」になっているかを確かめる検査までを組みます。コードは説明用の例です。組織ごとに項目名やテストの中身が違うので、手元の組織で動かして確かめてください。

なぜ1オブジェクトに1本のトリガーにするのか

同じオブジェクトの同じイベントに複数のトリガーがあると、実行順が決まりません。Salesforceの実行順序の説明には、同じイベントに複数のトリガーを定義した場合の順序は保証されないと明記されています。例として、Caseに before insert のトリガーが2本あるとき、2本の発火順は保証されないと書かれています。

順序が保証されないので、片方がもう片方の書き換えた項目を前提にしているコードは、たまたま動いている状態です。デプロイのたびに、あるいは組織をまたいだ移行で、順序が入れ替わっても不思議はありません。トリガーを1本にまとめ、ハンドラクラスの中で呼び出し順をコードとして固定すると、この不確かさが消えます。

トリガー本体を薄くする利点は、実行順だけではありません。

  • テストしやすい。ハンドラのメソッドが List<Opportunity> などを引数に取る形なら、DMLを経由せずに呼べる
  • 差分が読める。トリガーのファイルは数行になり、変更はクラス側に集まる
  • Claudeに頼みやすい。「このクラスのこのメソッドだけ直す」と範囲を絞れる

最後の点が、この記事の主題です。1ファイルに数百行のロジックが入ったトリガーは、Claudeに渡しても何を変えてよいかの境界が曖昧になります。構造を先に決めて規約にしておくと、以降の変更依頼が小さく済みます。

分離後の構造 — トリガー、ハンドラ、サービス

目標の構造は3層です。

層置く場所の例持つ責務
トリガー置く場所の例triggers/OpportunityTrigger.trigger持つ責務ハンドラを1回呼ぶだけ
ハンドラ置く場所の例classes/OpportunityTriggerHandler.cls持つ責務どのイベントで何を呼ぶかの振り分け
サービス置く場所の例classes/OpportunityService.cls持つ責務業務ロジック本体。トリガーの外からも呼べる

トリガー本体は、イベントを列挙してハンドラを呼ぶだけにします。

trigger OpportunityTrigger on Opportunity (
    before insert, before update, after insert, after update
) {
    new OpportunityTriggerHandler().run();
}

ハンドラは、トリガーのコンテキスト変数を読んで振り分けます。コンテキスト変数にある operationType は BEFORE_INSERT、AFTER_UPDATE などを持つ列挙型で、トリガーの種類に応じてロジックを変えるときは switch 文の利用が案内されています。

public class OpportunityTriggerHandler {
    public void run() {
        switch on Trigger.operationType {
            when BEFORE_UPDATE {
                OpportunityService.validateStageChange(
                    (List<Opportunity>) Trigger.new,
                    (Map<Id, Opportunity>) Trigger.oldMap
                );
            }
            when AFTER_INSERT {
                OpportunityService.createFollowUpTasks(
                    (List<Opportunity>) Trigger.new
                );
            }
            when else {
                // このハンドラが扱わないイベントは何もしない
            }
        }
    }
}

ここで押さえたいのは、Trigger.new や Trigger.oldMap を読むのをハンドラまでにとどめている点です。サービス側は List と Map を引数で受け取るので、テストでは Trigger を使わずにレコードを組んで呼べます。この線引きは設計上の選択で、Salesforceが要求する形ではありません。チームで決めて規約にする対象です。

イベントごとのメソッドを作りたければ、beforeUpdate のようなメソッドをハンドラに足し、when BEFORE_UPDATE から呼ぶ形にしても構いません。決めるのは、どの形にそろえるかです。1つのリポジトリに複数の形が混ざると、Claudeは新しいオブジェクトで毎回どの形をまねるかを選ぶことになります。

移す前に、既存テストを先に通して基準線を取る

分離は、動作を変えない移動です。動作が変わっていないことは、移す前後で同じテストが同じ結果になることでしか示せません。そのため、依頼の最初の手順は書き換えではなく、現状のテストの実行です。

テストは組織の上で走ります。sf apex run test が見るのは組織にデプロイ済みのコードとテストなので、ローカルの状態を測りたいなら先にデプロイします。

sf project deploy start --source-dir force-app/main/default
sf apex run test --tests OpportunityTriggerTest --synchronous \
  --result-format human

sf project deploy start はローカルのプロジェクトからメタデータを組織へデプロイするコマンドです。デプロイ対象を絞るフラグは sf project deploy start --help で確かめてください。--tests はテストクラス名を、--synchronous は1クラスを同期的に実行するフラグで、指定しないとテスト実行IDだけがすぐ返ります。

ここで見るのは、合否と件数です。失敗が最初から出ているなら、分離の前に失敗の原因を決めます。既存の失敗を残したまま分離すると、移したせいで壊れたのか、元から壊れていたのかが分からなくなります。

そのトリガーを直接検証するテストが無い場合は、移す前にテストを足します。こちらはApexテストクラスのカバレッジを75%に上げる手順にあるとおり、測定と生成を分けて回せます。分離の依頼に「テストも一緒に書いて」と混ぜると、移動の妥当性を測る物差しが、同じ変更の中で作られてしまいます。テストを足す作業と構造を移す作業は、別のコミットに分けるほうが安全です。

規約をCLAUDE.mdとパス指定ルールに書く

Claudeは CLAUDE.md の内容を、強制される設定ではなくコンテキストとして読みます。そのため、従いやすい書き方にすることが要点です。ドキュメントでは、検証できる具体さで書く、1ファイル200行以内を目安にする、といった指針が示されています。「ハンドラを使うこと」ではなく、「ファイル名はこうする」「トリガー本体にはこれを書かない」まで書きます。

プロジェクト全体に常時載せる分は、CLAUDE.md に短く置きます。

## Apex の構造規約
- トリガーは1オブジェクトに1本。名前は `<Object>Trigger`
- トリガー本体は `new <Object>TriggerHandler().run();` の1行だけにする
- ハンドラは `<Object>TriggerHandler`、業務ロジックは `<Object>Service` に置く
- Trigger.new / Trigger.oldMap を読むのはハンドラまで。Service には引数で渡す
- 構造を変えるときは、先に対象のテストクラスを実行して結果を示す

一方、トリガーやクラスを触るときだけ効けばよい細かい指示は、パス指定ルールに分けます。.claude/rules/ に置いたMarkdownに paths フロントマターを付けると、Claudeが一致するファイルを読んだり書いたりしたときに読み込まれます。

---
paths:
  - "force-app/**/triggers/*.trigger"
  - "force-app/**/classes/*TriggerHandler.cls"
---
 
# Apex トリガー分離ルール
 
- 動作を変えない。条件、項目の代入順、`addError` のメッセージをそのまま移す
- 移した処理に SOQL / DML を足さない。新しいクエリが必要なら止めて報告する
- ハンドラの共有ルールの宣言(with sharing など)は自分で決めず、
  選択肢と影響を示して確認を取る
- 1回の変更で扱うオブジェクトは1つ。テストクラスは編集しない

CLAUDE.md に全部書くと、Apexに触れない会話でも毎回コンテキストを使います。パス指定ルールにしておくと、トリガーやハンドラを読んだときにだけ載ります。サブディレクトリの CLAUDE.md も、そのディレクトリのファイルを読んだ時点で載る仕組みなので、force-app/ の下に置く手もあります。どちらでも、ルール側のファイルは1ファイル1テーマにして短く保ちます。

読み込まれたかどうかは、/context のMemory filesの一覧で確かめます。パス指定ルールは、一致するファイルをClaudeが読むまで載らないので、新しく書いたルールの動作確認は「トリガーのファイルを読ませてから /context を開く」順で行います。

依頼文は「測る → 移す → 測る」の順に縛る

規約があっても、依頼文に順序を書かなければ、Claudeは移動を先に始めることがあります。順序は依頼文で明示します。大きなトリガーなら、先にPlanモードで計画だけを出させ、計画を読んでから実行に移すと、移す範囲の食い違いを実装前に潰せます。

force-app/main/default/triggers/OpportunityTrigger.trigger を
ハンドラ構造に分けて。CLAUDE.md の Apex 構造規約に従うこと。
 
手順:
1. OpportunityTriggerTest を実行して結果を貼る(まだファイルは変えない)
2. トリガー本体に何が入っているかを、イベントごとに箇条書きにする
3. OpportunityTriggerHandler と OpportunityService を作り、
   ロジックをそのまま移す。条件や処理の順序は変えない
4. トリガー本体を1行の呼び出しに置き換える
5. デプロイして、手順1と同じテストを実行し、結果を比べる
 
テストクラスは編集しない。結果が手順1と違ったら、直さずに止めて報告して。

手順2が効きます。トリガーにどんなロジックが入っているかを先に言葉にさせると、before と after の両方に同じ処理が入っているなど、移す前に気づきたい点が表に出ます。読むのは人間です。ここで認識が食い違っていたら、実装の前に訂正できます。

手順5の最後の1文も重要です。テストが落ちたとき、Claudeがテストの期待値を書き換えて通そうとする経路は、依頼文で先に塞いでおきます。「テストクラスは編集しない」と「違ったら止める」を組み合わせると、構造を変える作業にテストの書き換えが混ざらなくなります。

統合するとき、挙動が変わりやすい4か所

1オブジェクトにすでに複数のトリガーがあるときは、統合そのものが挙動を変えます。次の4点は、依頼の前に人間が頭に置いておくと、Claudeの報告を読みやすくなります。

1. 呼び出し順を決める必要がある。既存のトリガーが複数あるなら、今の実行順はたまたまの順です。統合では、どちらを先に呼ぶかを決め、ハンドラ内で固定します。互いに依存がないはずの処理でも、ある項目を書き換えた結果を別の処理が読んでいないかを、Claudeに確認させます。

2. 1回の呼び出しが受け取る件数。200件を超えるDMLは200件ずつのバッチに分けて処理され、バッチごとにトリガーが呼ばれます。Trigger.size が数えるのは、そのバッチの件数だけです。ハンドラ側でレコード数を前提にした処理を書くと、移行データの投入で壊れます。バルク前提の書き方はガバナ制限違反を探して一括処理に直す手順で確かめます。

3. staticな変数の持ち越し。部分成功を許すDMLでは、トリガーが最初の試行で発火し、その後の再試行でもう一度発火します。同じトランザクションの一部なので、トリガーが触れる静的クラス変数はリセットされません。再帰を防ぐためのstaticフラグをハンドラに移すと、この再発火で「すでに処理済み」と判断され、再試行の処理が素通りする可能性があります。フラグの置き場所と持たせる単位は、人が決めてから頼みます。

4. addError の位置。レコードにエラーを付けて保存を止める処理は、before のトリガーで行うほうが、ユーザーの待ち時間が短く済むと説明されています。before に書かれていた検証を、移す途中で after 側のメソッドに置くと、動作が変わります。手順2の箇条書きで、when BEFORE_UPDATE に対応させる処理を確認します。

どれも、テストが通っても気づきにくい変化です。1と3は、テストが「たまたまの順序」でしか動かしていないと見落とします。

構造だけが変わったことを機械で確かめる

テストの再実行とは別に、「トリガー本体に何も残っていないか」と「テストを書き換えていないか」は、機械で検査できます。ここは CLAUDE.md に書くより、フックにするほうが確実です。ドキュメントでは、特定のタイミングで必ず実行したい指示は、Claudeの判断に任せずフックにすることが案内されています。

PostToolUse のフックは、ファイルの書き込みや編集のあとに走ります。終了コード2で終わると、標準エラーの内容がClaudeに見える形で返ります。トリガーのファイルにSOQLやDML、ループが残っていたら、その場で差し戻す検査を作れます。

#!/bin/bash
# .claude/hooks/check-thin-trigger.sh
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
  *.trigger) ;;
  *) exit 0 ;;
esac
if grep -nE '\[SELECT|^\s*(insert|update|delete|upsert) |\bfor *\(' "$file" >&2
then
  echo "トリガー本体にロジックが残っています。ハンドラへ移してください。" >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": ".claude/hooks/check-thin-trigger.sh" }
        ]
      }
    ]
  }
}

フックの入力は標準入力へJSONで渡され、書き込み対象のパスは tool_input.file_path から読めます。matcher に Edit|Write を指定すると、ファイルの編集と書き込みのときだけ走ります。この正規表現は粗い検査です。DML文は行頭にあるものだけを拾うので、Database.insert(...) のような呼び出しや、コメント内の単語への反応は手元で調整してください。このフックが見るのはトリガー本体だけで、ハンドラの中身までは保証しません。

もう一つは、テストクラスが書き換わっていないことの確認です。手元の差分で見ます。

git diff --stat -- 'force-app/**/*Test.cls'
git diff --name-only -- force-app

1行目が空なら、テストクラスは触られていません。2行目には、.trigger、新規のハンドラとサービス、それぞれのメタデータファイルだけが並ぶはずです。想定外のクラスが混ざっていたら、依頼の範囲を超えて触られています。

最後に、デプロイして手順1と同じコマンドでテストを回し、件数と結果を基準線と並べます。同じなら、分離は動作を変えていない、という最低限の根拠が揃います。それ以上の確かさは、本番相当のデータ量での実行と、レビューで確認する範囲です。

まとめ

トリガーの分離は、構造を決めて、順序を縛れば、Claudeに任せやすい作業です。1オブジェクト1トリガー、トリガー本体は呼び出し1行、Trigger.* を読むのはハンドラまで、という規約を CLAUDE.md に置き、細かい禁止事項はパス指定ルールに分けます。依頼は「テストで基準線を取る、ロジックをそのまま移す、同じテストで比べる」の順にし、テストクラスの編集を禁じます。

迷うのは、構造ではなく挙動が動く4か所です。呼び出し順、バッチ件数、staticフラグ、addError の位置は、移す前に人が決めます。そのうえで、構造だけが変わったことをフックと差分で確かめると、分離のレビューは「ロジックが変わっていないか」という一点に絞れます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/memory
  2. code.claude.com/docs/en/hooks
  3. developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_orde…
  4. developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_cont…
  5. developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_defi…
この記事を共有:XはてブLinkedIn