Claude CodeでApexのガバナ制限違反を探して一括処理に直す
ループ内のSOQL・DMLをサブエージェントに洗い出させ、bulkifyしたあとsf apex run testで確かめる手順です。
Apexのトリガーは、レコードを1件ずつ処理する書き方のまま本番に出ると、データ移行や一括更新の日に止まります。原因はたいてい、for ループの中に置いたSOQLとDMLです。Claude Codeでは、この違反を探す専用のサブエージェントを .claude/agents/ に置き、書き換えたあとは sf apex run test で確かめる流れにすると、レビューと検証を同じ手順で繰り返せます。
この記事では、読み取り専用のレビュー用サブエージェントの定義、トリガーとハンドラーの渡し方、違反を再現するバルクテスト、bulkify後の再実行までを順に組みます。コードは説明用の例です。出力は環境ごとに変わるので、手元の組織で sf を動かして確かめてください。
ガバナ制限とは何か — SOQL 100回・DML 150回が破綻する条件
ガバナ制限は、Apexの実行エンジンがトランザクションごとに数える上限です。マルチテナント環境で暴走したコードが共有資源を占有しないための仕組みで、超えると実行時例外が発生し、この例外は捕まえて処理を続けることができません。try/catchで包んでも救えないため、超えない書き方に直すしかありません。
同期処理の主な上限は次のとおりです。
| 項目 | 同期 | 非同期 |
|---|---|---|
| 発行できるSOQLクエリ数 | 同期100 | 非同期200 |
| SOQLで取得できる総レコード数 | 同期50,000 | 非同期50,000 |
| 発行できるDMLステートメント数 | 同期150 | 非同期150 |
| DMLで処理できる総レコード数 | 同期10,000 | 非同期10,000 |
| SalesforceサーバーでのCPU時間 | 同期10,000ミリ秒 | 非同期60,000ミリ秒 |
| ヒープサイズ | 同期6 MB | 非同期12 MB |
よく引かれる「SOQL 100回・DML 150回」は同期処理の数字です。バッチApexやfutureメソッドなどの非同期ではSOQLが200回まで許され、DMLは同じ150回です。バッチApexの上限は、execute メソッドが処理する1バッチごとにリセットされます。
数えているのは「文の実行回数」であり、ソースコード上の行数ではありません。ループの中にSOQLが1行あれば、ループが回った回数だけ数えられます。レコードを1件ずつ処理するトリガーが、1件の更新では通っても、一括更新で複数レコードを受け取った途端に101回目のクエリで落ちるのはこのためです。
トリガーは既定で一括処理を前提にしています。データ移行、Bulk API、所有者の一括変更や一括削除のような操作は、1回のトリガー実行に複数のレコードを渡します。1件ずつの前提で書いたコードは、手で1件ずつ画面から更新するテストでは気づけません。
どこが数えられるか — 見落としやすい5つの型
レビューを機械に任せる前に、何を違反と呼ぶかを決めておきます。サブエージェントの指示文にそのまま書ける粒度です。
- ループ内の静的SOQL。
forの中の[SELECT ... ]は、周回ごとに1回数えられます。 - ループ内のDML。
insert/update/delete/undelete/upsert/mergeと、対応するDatabase.のメソッドが対象です。Approval.processやDatabase.convertLeadもDMLの回数に入ります。 - ループから呼ばれるメソッドの中にあるSOQL・DML。呼び出し側のループには何も書いてありません。ハンドラークラスに処理を逃がす構成で最も起きる型です。
- 動的クエリ。
Database.queryやDatabase.getQueryLocatorの呼び出しも、SOQLの発行数に数えられます。 - 親子サブクエリ。親子関係のサブクエリを含むクエリでは、親子関係1つごとに追加のクエリとして数えられます。このクエリには、上位のクエリ数の3倍という別の上限が付きます。
3が肝です。grep で [SELECT を探して、その行が for ブロックの内側にあるかを見るだけの検査は、1と2しか拾えません。呼び出しの連鎖を追う作業は人間が嫌がる種類の仕事で、コードを読んで判断できるエージェントに向きます。
読み取り専用のレビュー用サブエージェントを定義する
サブエージェントは、.claude/agents/ に置くMarkdownファイルです。フロントマターに name と description があれば読み込まれ、その下に書いたMarkdownが、そのサブエージェントのシステムプロンプトになります。プロジェクト固有のものは .claude/agents/ に置いてバージョン管理に含めると、チームで共有できます。
違反の洗い出しでは、ファイルを書き換えさせません。tools を Read, Grep, Glob に絞り、報告だけを返させます。
---
name: apex-governor-reviewer
description: Apexのトリガー・クラス・バッチから、ループ内のSOQL/DMLなどガバナ制限違反を洗い出して書き換え案を返す。Apexコードを変更したとき、トリガーを追加したときに積極的に使う(use proactively)。
tools: Read, Grep, Glob
model: inherit
---
あなたはSalesforce Apexのガバナ制限に詳しいレビュアーです。
渡されたトリガーとハンドラー、そこから呼ばれるクラスを読み、
次の違反を探してください。コードは変更しません。
探す対象:
1. for / while ループ内の [SELECT ...] と Database.query 系の呼び出し
2. ループ内の insert / update / delete / undelete / upsert / merge
(Database.insert 等と Approval.process を含む)
3. ループの中から呼ばれるメソッドの先にある上記(呼び出しを追うこと)
4. 親子サブクエリを含むクエリ(追加のクエリとして数えられる)
手順:
1. 渡されたファイルを読み、呼び出し先のクラスを Glob / Grep で探して読む
2. 違反ごとに、ファイル名・行番号・違反の種類・周回数が何に比例するかを示す
3. 書き換え案は Set / Map でキーを集め、ループの外で1回だけ
クエリ・DML する形で、差分が分かる短いコードにして返す
出力は「違反一覧」「書き換え案」「判断に迷った箇所」の3見出しにします。description に「use proactively」と書いておくと、Claudeが自動で委任する場面が増えます。確実に呼びたいときは次節の @ メンションを使います。
model: inherit は親の会話と同じモデルで動かす指定です。ループをまたぐ呼び出しの追跡は推論の量が多いので、軽量モデルへ固定する前に、まず親と同じモデルで取りこぼしの有無を見るほうが安全です。
カスタムのサブエージェントは、CLAUDE.mdを初期コンテキストに読み込みます。チームのApex規約(トリガーは1オブジェクト1本、ロジックはハンドラーに置く、など)をCLAUDE.mdに書いておけば、サブエージェントの指示文を肥大させずに済みます。
## Apex の規約(CLAUDE.md の抜粋例)
- トリガー本体は薄く保ち、処理は `*TriggerHandler` クラスに置く
- ループ内で SOQL / DML を発行しない。Set / Map に集めて1回で処理する
- Apex を変えたら apex-governor-reviewer に見せ、
対応するテストクラスを `sf apex run test` で実行するトリガーとハンドラーを渡して違反を洗い出す
サブエージェントは、親の会話とは別のコンテキストで動き、自分で読んだ内容は親に戻りません。戻るのは要約です。そのため、親の会話でトリガーを開いていたとしても、渡したいファイルのパスは依頼文に明記します。
@"apex-governor-reviewer (agent)" force-app/main/default/triggers/OpportunityTrigger.trigger
と、そこから呼ばれるクラスを調べて@ メンションは、そのサブエージェントを確実に動かす指定です。依頼文の全体はClaudeに渡り、サブエージェントに届くタスク文はClaudeが書きます。メンションが決めるのは「どのサブエージェントを呼ぶか」だけで、渡す文面そのものではありません。自然文で「apex-governor-reviewerに見せて」と書く方法もありますが、その場合は呼ぶかどうかをClaudeが判断します。
違反のあるトリガーの例を置きます。商談が作られるたびに、取引先の「受注累計」項目を足し込むトリガーです。項目名は説明用の仮のものです。
trigger OpportunityTrigger on Opportunity (after insert) {
for (Opportunity opp : Trigger.new) {
Account acc = [SELECT Id, Total_Won_Amount__c
FROM Account WHERE Id = :opp.AccountId];
acc.Total_Won_Amount__c =
(acc.Total_Won_Amount__c == null ? 0 : acc.Total_Won_Amount__c)
+ opp.Amount;
update acc;
}
}このコードを渡すと、レビュー用サブエージェントからは、次のような形で報告が返ってくる想定です(出力の一例で、実際の文面は実行のたびに変わります)。
- 違反1: 3行目のSOQLが
Trigger.newの件数ぶん発行される - 違反2: 8行目の
updateがTrigger.newの件数ぶん発行される - 判断に迷った箇所: 同じ取引先に商談が複数ぶら下がる場合の足し込み
3つ目が重要です。同じ取引先に属する商談を1回のトリガーで複数受け取ると、取引先ごとの合計を先に作らないと正しい値になりません。単純にSOQLとDMLを外へ出すだけでは、1件ずつの更新が互いに上書きしあう別のバグを作ります。
先に失敗するバルクテストを置く
直す前に、違反を再現するテストを書きます。ガバナ制限の違反は、テストが1件ずつしかデータを作らないと露出しません。上限の100回と150回を確実に超える件数を、1回の insert で渡します。
@IsTest
private class OpportunityBulkTest {
@IsTest
static void bulkInsertStaysWithinGovernorLimits() {
Account acc = new Account(Name = 'Bulk Test');
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
opps.add(new Opportunity(
Name = 'Opp ' + i, AccountId = acc.Id,
StageName = 'Closed Won', CloseDate = Date.today(),
Amount = 1000));
}
insert opps;
Account result = [SELECT Total_Won_Amount__c
FROM Account WHERE Id = :acc.Id];
System.assertEquals(200000, result.Total_Won_Amount__c);
}
}200件の商談を、同じ取引先に向けて1回の insert で作ります。ループ内SOQLは200回、ループ内DMLも200回になるため、同期の上限100回と150回のどちらも超えます。最後の assertEquals は、足し込みの合計値が正しいかも確かめます。違反を直したつもりで合計がずれる事故を、同じテストが拾います。
テストメソッドの上限は、メソッドごとに別々に数えられます。テスト内で先に作った取引先の insert 1回も、そのメソッドのDML回数に入ります。
実行はSalesforce CLIの sf apex run test です。
sf apex run test --tests OpportunityBulkTest \
--synchronous --result-format human --concise--tests には、テストクラス名と、必要ならテストメソッド名を指定します。--synchronous は、1つのクラスのテストメソッドを同期的に実行するフラグで、指定しないとテストは非同期に走り、テスト実行IDだけがすぐ返ります。非同期のまま待ちたいときは --wait に分数を渡し、時間内に終わらなければ表示されたIDを sf apex get test --test-run-id に渡して結果を取りに行きます。--concise は失敗したテストだけを表示する人間向け出力のオプションです。
このコマンドを使うには、組織の「すべてのデータの参照」システム権限が必要です。この権限は既定で無効で、システム管理者だけが有効にできます。Claude Codeにコマンドを実行させる前に、接続先の組織とユーザーにこの権限があるかを確認してください。
ここで制限超過の例外でテストが失敗すれば、再現できています。失敗しないなら、テストが違反の経路を通っていません。商談の条件(ステージや金額)がトリガーの分岐を通る値になっているか、見直します。
書き換えさせて、同じテストで確かめる
レビューの報告をもとに、親の会話で書き換えを指示します。サブエージェントを読み取り専用にしてあるので、編集は親の会話が担当します。
apex-governor-reviewer の報告に沿って OpportunityTrigger を書き換えて。
取引先ごとの合計を先に作ってから、SOQL と update は1回ずつにする。
終わったら sf apex run test --tests OpportunityBulkTest --synchronous を実行して書き換え後の形は次のようになります。キーを Map に集め、クエリと更新はループの外に出します。
trigger OpportunityTrigger on Opportunity (after insert) {
// 取引先ごとの加算額を先に集計する
Map<Id, Decimal> addByAccount = new Map<Id, Decimal>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId == null || opp.Amount == null) continue;
Decimal current = addByAccount.containsKey(opp.AccountId)
? addByAccount.get(opp.AccountId) : 0;
addByAccount.put(opp.AccountId, current + opp.Amount);
}
// クエリも DML も1回だけ
List<Account> accs = [SELECT Id, Total_Won_Amount__c FROM Account
WHERE Id IN :addByAccount.keySet()];
for (Account a : accs) {
a.Total_Won_Amount__c =
(a.Total_Won_Amount__c == null ? 0 : a.Total_Won_Amount__c)
+ addByAccount.get(a.Id);
}
update accs;
}セットやマップで一意なキーを集め、クエリの結果もマップで引く、という書き方は、一括処理のトリガーで定番の型です。IN :セット でクエリを1回にまとめ、結果を Map<Id, SObject> に入れて、ループの中ではマップから引くだけにします。
違反を直す1周の流れ
- 1
再現テストを赤にする
200件を1回のinsertで渡すテストを実行し、制限超過の例外で失敗することを確かめます。
- 2
サブエージェントで洗い出す
トリガー、ハンドラー、呼び出し先のパスを @ メンションで渡し、違反一覧と書き換え案を受け取ります。
- 3
親の会話で書き換える
報告をもとに、Set / Mapに集めてループの外で1回だけクエリとDMLを発行する形へ直します。
- 4
同じテストを緑にする
先に書いたテストをもう一度実行し、例外が消えて合計値のassertも通ることを確認します。
最後のテストが緑になった時点で、このトリガーに限った違反は解消しています。ほかのトリガーやバッチクラスにも同じ型があるなら、同じサブエージェントに、対象を force-app/main/default/ 全体へ広げて調べさせます。
件数の制限を消しても、別の上限が残る
SOQLとDMLの回数を直しても、ガバナ制限がなくなるわけではありません。回数の上限を避けた分、次の上限が効いてきます。
- SOQLで取得できる総レコード数は50,000
- DMLで処理できる総レコード数は10,000
- CPU時間は同期で10,000ミリ秒。DMLの処理にかかったアプリケーションサーバーのCPU時間も、この枠に数えられる
書き換えた後にこの数字の近くまで来る処理は、Trigger.new を回す内側の計算を軽くするか、バッチApexのように1バッチごとに上限がリセットされる非同期処理へ移す設計の話になります。サブエージェントの報告に「判断に迷った箇所」の見出しを残しておくのは、こうした設計判断を、機械的な書き換えと混ぜないためです。
実行中に残りの余裕を調べたい場合は、Limits クラスのメソッドを使います。たとえば getDMLStatements は、これまでに実行したDMLの回数を返します。テストの途中でこの値を System.debug に出すと、直したあとの余裕が数字で見えます。
レビュー用サブエージェントに任せる範囲と、人が見る範囲
静的な読み取りだけでは、判断が割れる箇所があります。たとえば、ループの周回数が小さな定数で固定されているなら、ループ内のSOQLが実害になるとは限りません。報告に「周回数が何に比例するか」を書かせているのは、この見分けを人が数秒でできるようにするためです。私の見立てでは、周回数が Trigger.new やクエリ結果に比例するものから先に直し、固定回数のものは優先度を下げるのが無駄の少ない順序です。
サブエージェントが見られないのは実行時のデータ量です。実際の組織で1回のトランザクションにどれだけのレコードが入るかは、コードからは分かりません。バルクテストの件数と、本番の一括操作の件数は別物なので、上限に近い処理は、サンドボックスで本番に近い件数を流して確かめます。
人間のコードレビューの層と、AIを組み込んだレビューの全体像は、Claude Codeチームのコードレビューで整理しています。ここで作ったサブエージェントは、その層のうち「機械的に見つけられる違反」を受け持つ部品にあたります。決定論的なテストで違反を見つける構成は、Temporalのワークフローで決定性違反をテストで見つけるとも共通します。
まとめ
Apexのガバナ制限の違反は、実行時例外が捕まえられないため、書き方で避けるしかありません。レビューは読み取り専用のサブエージェントに任せ、書き換えは親の会話、確認は先に書いた200件のバルクテストと sf apex run test に分けると、見つけた違反が本当に消えたかを同じ物差しで確かめられます。
ハンドラーを経由する呼び出しはgrepでは拾えないので、呼び出し先を追わせる指示をサブエージェントの手順に入れておくのが、この構成の要点です。Salesforce側のデータを会話で操作する別の連携は、ClaudeとSalesforceの連携方法にあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。