はじめに
Salesforceの開発ではフローやApexを使った開発が可能です。もちろん、数式項目や各種設定でも十分な機能を作成することができます。しかしながら、そこにルールがない場合「その場しのぎ」なコードや設定やフローが作られ、作成した時は動くので良いのですがそれが徐々に負債として積み上がり、いざ問題が噴出した時には手遅れということがあります。
前回の記事( webエンジニアからSalesforceエンジニアになってみて ) を書いたあと、なぜ私が崩壊しかけていた環境を元に戻し、さらに機能追加などを次々と実践できたのかを考えていました。そして、フローとApexに対して「このルールに沿って作業をすれば崩壊を防ぎながら5年間利用され続けるSalesforce環境を作れる」と確信を持ったので、皆さんに共有いたします。
なお、これは完全に私の個人的な感想であり、これをそのまま利用するも利用しないも一部使うも全ては読者の皆様にお任せします。
この設計指針が向いていない組織・前提条件
次のような組織や前提条件がある場合、本設計指針は合わないため非推奨です。
- ノーコード/ローコードのみで開発する体制の組織
- テストで Assert を書く文化がない、またはテストを形式的にしか扱っていない
- 短期案件のみを前提とし、中長期の保守・改善を行わない
- Apex を「プロコード」と呼び、避けるべきものとして扱う組織
- 設計指針やルールを事前に合意せず、各人の裁量に任せる文化の組織
- レビューやリファクタリングの時間がコストとして認められていない
- 特定の個人への属人化を戦略として許容・温存している
- Salesforce を業務設定の集合体として捉え、ソフトウェアとして扱っていない
- 技術的負債を課題として扱わず、都度の回避で済ませる文化
本設計指針は、長期的に運用され続ける業務システムを、チームとして安全に育てていくことを前提としています。
注意事項
基本的に私のOSSをフル活用することが前提になっています。
ApexEloquent
ApexBlueprint
ApexTrace
次から本編です。どうぞ!↓
Salesforce導入におけるApex設計憲章
1. 目的
本憲章は、Salesforce導入・開発において 短期的な実装速度と、中長期(5年以上)の保守性・拡張性を両立 させることを目的とする。
ノーコード/ローコード/Apex を対立概念として扱わず、責務と変更頻度に応じて最適な手段を選択する ための判断基準を明文化する。
2. 基本思想
2.1 フローとApexの役割分担
- フローは UI層・I/O層 として扱う
- Apexは 業務ロジック(ドメイン層) を担う
フローは「誰でも変更できること」を価値とし、Apexは「安全に変更できること」を価値とする。
3. フロー利用に関する原則
3.1 トリガーフローの原則禁止
- トリガーフローは原則として 使用しない
- オブジェクトイベント起点の業務ロジックは Apex Trigger をエントリーポイントとする
理由:
- 実行順序が制御できない
- 相互作用を考慮しないフローが増殖しやすい
- ガバナ制限の消費が可視化されない
- デバッグおよび影響範囲の特定が極めて困難
3.2 フローの複雑度制限
以下の条件に該当した時点で Apexへ切り替える:
- フロー内で 2重ループ が必要になった場合
- リレーション(親子・関連オブジェクト)を横断するロジックが必要な場合
- ただし、画面表示を必要とする場合はこの限りではない
理由:
- 可読性・保守性が急激に低下する
- 自動テストによる品質保証が困難
- 将来的なリファクタリング耐性が著しく低い
3.3 フローで許可される処理範囲
フローで実装してよい処理は以下に限定する:
- 画面UIの構築
- 単一オブジェクトタイプの作成・更新・削除
- 業務ロジックを含まない値の受け渡し
- Apex ハンドラークラスの呼び出し
4. Apex設計標準(UseCase指向)
4.1 クラス設計の基本構造
- 処理単位で 1 UseCase = 1 Class
- public method は
invoke()のみ - 業務処理に必要なデータはコンストラクタ引数で渡す
4.2 依存関係とデータアクセス
- SOQL / DML は直接記述しない
- データアクセスは
IEloquentインターフェース経由で行う - upsert / delete 対象ごとに IEloquent を分離する
4.3 コンストラクタ設計
- public コンストラクタ
- 業務上必要な引数のみを受け取る
- private または
@TestVisibleコンストラクタ- public コンストラクタの引数に加え、IEloquent を DI 可能とする
これにより DBレスな単体テスト を可能とする。
- public コンストラクタの引数に加え、IEloquent を DI 可能とする
4.4 クエリ記述と差し替え
- クエリは Scribe を用いて記述する
- IEloquent 経由で取得し、実装差し替えを可能とする
4.5 ロギングとトレーサビリティ
- UseCase の開始時・終了時には Trace を必ず記録する
- 例外発生時も Trace から行動履歴を追跡可能とする
5. テスト方針
5.1 単体テスト(UseCase)
基本方針
- 単体テストは UseCase 単位 で作成する
- 単体テストは 内部実装の詳細ではなく、振る舞いと副作用を検証対象とする
- 「どのような処理が行われたか」
- 「どのDMLが実行されたか」
- 「処理が完遂したのか、スキップされたのか」
を観測可能な形で検証する
テストデータと依存関係
- テストデータは MockEntry のモックデータ生成メソッド を使用して生成する
- TestDataFactory のような一括生成は使用しない
- 各テストケースの意図と前提条件がコードから読み取れる構造とする
- DB アクセス・DML は IEloquent を通じて抽象化 する
- upsert / delete などの DML 実行は IEloquent に委譲する
検証方法
- UseCase 内部の DML 実行結果は、以下の 「副作用」を用いて検証する
- IEloquent.upsertedRecord
- IEloquent.deleteCount
- UseCase の処理結果はテスト用の返り値を用意せず、
TraceStackを用いて検証する- 処理が Finish まで到達したか
- 条件により Skip されたか
- ログや Trace は、テスト観点における 振る舞いの証跡 として扱う
禁止事項・注意事項
- UseCase には テストのための返り値やフラグを定義しない
- テストのために UseCase のロジックや責務を歪めない
- Test.isRunningTest() は 原則使用禁止
- やむを得ず使用する場合は、その理由と背景をドキュメントとして必ず残す
この方針の目的
- テストが 設計を縛るのではなく、設計を守る存在 であること
- 人間・AI のどちらが読んでも
- 「なぜこのテストが存在するのか」「何を保証しているのか」が理解できる構造を残すこと
- 長期運用において、テストコード自体が 設計ドキュメントとして機能 する状態を作ること
5.2 エントリポイントとUseCaseの分離
-
UseCase クラスは 直接トリガー・フロー・バッチから呼び出さない
-
必ず以下のような ハンドラクラス をエントリポイントとする
- TriggerHandler
- FlowHandler
- BatchHandler
理由:
- static 呼び出しを避け、コンストラクタインジェクションを利用した依存性注入を可能にするため
- エントリポイントごとの責務を明確に分離するため
ハンドラクラスは UseCase を生成し、invoke() を呼び出す責務のみを持つ。
5.3 結合テスト(Handlerレイヤー)
- 結合テストは ハンドラクラス単位 で実施する
- 実際の DML・トリガー実行を含めた振る舞いを検証対象とする
テストデータ構築方針
- TestDataFactory は原則として使用しない
- ApexBlueprint を用いてテストデータを構築する
目的:
-
各テストケースにおける
- テストの意図
- 再現したい業務状況
- そのために挿入されるデータ
の関係性を明示するため
これにより、
- 後からテストコードを読んだ際に意図を理解しやすい
- AI・人間の双方にとって解析しやすい構造を残せる
6. AI活用に関する方針
- 本設計憲章を Apex実装の基本型 として固定する
- AIはこの型に沿った実装・テスト生成を支援する役割とする
- 設計判断および責務分離の決定は人間が行う
7. 長期運用に対する考え方
- ノーコード/コードという二分法では判断しない
- 変更頻度・影響範囲・テスト容易性を基準とする
- Apexを恐れず「読める・理解できる」状態を組織として維持する
8. 結語
本憲章は実装者の自由を縛るためのものではなく、
将来の変更を容易にし、組織とシステムの寿命を延ばすための共通言語 である。
この思想に基づき、Salesforceを一過性のツールではなく、
継続的に価値を生み出す業務基盤 として育てていく。
コードの型サンプル
トリガーハンドラ
public with sharing class XxxTriggerHandler extends TriggerHandler {
protected override void afterInsert(Map<Id, SObject> newRecordsMap) {
new XxxAfterInsertUseCase(newRecordsMap).invoke();
}
protected override void beforeUpdate(
Map<Id, SObject> newRecordsMap,
Map<Id, SObject> oldRecordsMap
) {
new XxxBeforeUpdateUseCase(newRecordsMap, oldRecordsMap).invoke();
}
}
Batch / Schedule ハンドラ
global with sharing class SampleBatchHandler implements Database.Batchable<SObject>, Schedulable {
/** 任意で指定できる対象レコードID */
private Id targetRecordId;
/**
* constructor
*/
public SampleBatchHandler(Id targetRecordId) {
this.targetRecordId = targetRecordId;
}
/* ===== Schedulable ===== */
global void execute(SchedulableContext ctx) {
Database.executeBatch(new SampleBatchHandler(null), 200);
}
/* ===== Batchable ===== */
global Database.QueryLocator start(Database.BatchableContext bc) {
System.debug('Batch 処理を開始します。');
Query query = (new Query())
.source(SObjectType.getDescribe().getSObjectType()) // ← 実装時に差し替える
.pickAll();
if (this.targetRecordId != null) {
query = query.condition('Id', '=', this.targetRecordId);
}
return Database.getQueryLocator(query.toSoql());
}
global void execute(Database.BatchableContext bc, List<SObject> recordList) {
if (recordList.isEmpty()) {
return;
}
// UseCase に処理を委譲
(new SampleBatchUseCase(recordList)).invoke();
}
global void finish(Database.BatchableContext bc) {
System.debug('Batch 処理が終了しました。');
}
}
UseCase
public class XxxUseCase {
private final IEloquent eloquent;
private final Trace t = Trace.of('XxxUseCase');
private final Map<Id, SObject> records;
public XxxUseCase(Map<Id, SObject> records) {
this(records, null);
}
@TestVisible
private XxxUseCase(Map<Id, SObject> records, IEloquent eloquent) {
this.records = records;
this.eloquent = eloquent != null ? eloquent : new Eloquent();
}
public void invoke() {
this.t.start();
doSomething();
doAnotherThing();
this.t.finish();
}
private void doSomething() {
this.t.log('doSomething start');
// doSomething
this.t.log('doSomething finish');
}
private void doAnotherThing() {
this.t.log('doAnotherThing start');
// doSomething
this.t.log('doAnotherThing finish');
}
}