Apex Stem 導入ガイド
このガイドでは、既存の Salesforce コードベースに Apex Stem を段階的に取り入れる方法を、一歩ずつ解説します。すべてを書き直す必要はありません。今日の状況を少しでも良くする最小の部分から始めて、そこから育てていけます。
以下の 4 ステップは、トップページのカードと対応していますが、こちらはコピーして使える実際のコード付きです。
コード例は ApexEloquent v2.1 以降で動きます (
label()/attach()を使っているため)。v3 では SOQL/DML の既定がユーザーモード (FLS を尊重) になっているので、実行ユーザーの項目権限にご注意ください。集計や焼き付けのように「誰が起こしても完遂すべき処理」は.systemMode()で明示的にオプトアウトします。
ステップ 1: SOQL を 1 本 Scribe に置き換える
いまインラインで書いているクエリを、ApexEloquent の型付きクエリビルダー Scribe 経由に通します。挙動は変わりませんが、次の 2 つが手に入ります。
- テストでの SELECT 漏れ検知。SELECT し忘れたフィールドにコードがアクセスすると、テストが失敗します。
- モック可能性。クエリは
IEloquentを通るため、単体テストでMockEloquentに差し替えられます。
Before
List<Account> accounts = [
SELECT Id, Name, Industry
FROM Account
WHERE Industry = :industry
];
After
Scribe accountScribe = Scribe.of(Account.class)
.field('Id')
.field('Name')
.field('Industry')
.whereEqual('Industry', industry);
List<IEntry> accountEntries = new Eloquent().get(accountScribe);
フィールドは SObjectField ではなく 文字列 ('Id'、'Industry') で渡します。これにより、ビルダーは型システムの制約に縛られず、実行時に動的に組み立てられます。
生の SOQL (
[SELECT ...]) は、テストのアサーション検証などサッとした用途には問題ありません。プロダクションコードではScribeを優先し、モック可能性と安全網を保ちます。
ステップ 2: 結果を IEntry のまま扱う
Eloquent の返り値は SObject ではなく IEntry です。IEntry は SObject の代わりとして振る舞えるラッパーで、SObject のまま扱うのに比べて ApexEloquent が提供する仕組み (SELECT 漏れ検知、数式項目やロールアップのモックなど) の恩恵を受けられます。Apex Stem では、基本的に IEntry のままビジネスロジックを記述することを推奨しています。
フィールドの読み取り
for(IEntry accountEntry : accountEntries) {
Id id = accountEntry.getId(); // 専用 getter
String name = accountEntry.getName(); // 専用 getter
String industry = (String) accountEntry.get('Industry'); // キャスト必須
}
なぜ SObject に落とさないのか
getAsSObject() を使えば SObject として受け取れますが、落とした瞬間、その先で SELECT 漏れ検知が効かなくなります。
// ❌ この items を受け取ったメソッドが未 SELECT の項目を触っても、null が返るだけで静かに通る
List<EstimateItems__c> items = (List<EstimateItems__c>) eloquent.getAsSObject(scribe);
// ✅ IEntry のまま渡せば、未 SELECT のアクセスはその場で例外になる
List<IEntry> items = eloquent.get(scribe);
型付きの SObject (EstimateItems__c) でも同じ穴が開きます。他のメソッドやクラスの引数型を SObject にしない、というところまでが対になっています。
getAsSObject を使ってよいのは、標準 API が SObject を要求するときだけです (Database.SaveResult 系、Approval.process、Messaging など)。「キャストが面倒」「いまは SObject の方が速く書ける」は理由になりません。ここを一度崩すと、以降の歯止めが効かなくなります。
💡 迷ったら「このレコードはクエリ由来か」で判断します。Yes なら
IEntry、その場でnewした新規レコードならSObjectです。doInsertにIEntry版のオーバーロードが無いのは、そのためです (新規レコードに SELECT の概念が無く、包んでも検知しようがない)。
IEntry が具体的にどんな恩恵をもたらすか、いつ SObject に変換してよいかは、ApexEloquent のドキュメント で詳しく解説します。
ステップ 3: Usecase を切り出す
SOQL とロジックの組み合わせが数行を超えてきたら、名前を付けます。Apex Stem の Usecase は、public な表面が invoke() だけのオブジェクトです。
ここでは例として、商談 (Opportunity) に、親である取引先 (Account) の業種をコピーする Usecase を見てみます。
public with sharing class CopyAccountIndustryToOpportunityUsecase {
@TestVisible static final String LBL_FETCH = 'oppFetch';
@TestVisible static final String LBL_UPDATE = 'oppUpdate';
private final Set<Id> opportunityIds;
private final IEloquent eloquent;
private Trace t = Trace.of('商談に親取引先の業種をコピー');
// public コンストラクタ: 本番用、業務入力だけを受け取る
public CopyAccountIndustryToOpportunityUsecase(Set<Id> opportunityIds) {
this(opportunityIds, null);
}
// private (@TestVisible) コンストラクタ: テストで IEloquent を注入
@TestVisible
private CopyAccountIndustryToOpportunityUsecase(
Set<Id> opportunityIds,
IEloquent eloquent
) {
this.opportunityIds = opportunityIds;
this.eloquent = eloquent ?? new Eloquent();
}
public void invoke() {
this.t.start();
if(this.opportunityIds == null || this.opportunityIds.isEmpty()) {
this.t.skip('対象の商談がないため終了。');
return;
}
// 商談と、親取引先の業種を一緒に取得
Scribe oppScribe = Scribe.of(Opportunity.class)
.field('Id')
.parentField(Scribe.asParent('AccountId').field('Industry'))
.whereIn('Id', this.opportunityIds);
List<IEntry> oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe);
// 各商談に、親取引先の業種をコピー
for(IEntry oppEntry : oppEntries) {
IEntry accountEntry = oppEntry.getParent('AccountId');
oppEntry.put('Industry__c', accountEntry.get('Industry'));
}
this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries);
this.t.finish(oppEntries.size() + ' 件の商談に業種をコピー。');
}
}
これが Layered Constructor Pattern です。
- シンプルな本番 API:
new CopyAccountIndustryToOpportunityUsecase(opportunityIds).invoke() - 柔軟なテスト API:
new CopyAccountIndustryToOpportunityUsecase(opportunityIds, mock).invoke() - 生焼けオブジェクトを作らない: すべての依存はコンストラクタの時点で揃う
この Usecase は、
IEloquentを 1 本だけ DI しつつ、取得用 (LBL_FETCH) と更新用 (LBL_UPDATE) でラベル多重化しています。役割ごとにラベルを付けておくと、テストで「取得結果はこう返す」「更新はこう検証する」を独立して書けます。また、取得した
IEntryをgetParentでたどり、putで書き換え、doUpdateにそのまま渡しています。ステップ 2 で触れた「IEntryのまま完結させる」流れの実例です。
ステップ 4: 適切な層でテストする
アーキテクチャは、2 つのテスト戦略と 1 対 1 で対応します。
- Usecase 層 →
MockEloquentによる単体テスト。DB なし、高速、ロジックを網羅できます。 - Handler 層 →
SBlueprintによる結合テスト。実 DML を流し、配線を検証します。
Usecase の単体テスト
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること');
t.start();
// Arrange: 親取引先 (業種 = Technology) を持つ商談を 1 件モック
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.alias('opp')
.autoId(1)
.setParent('AccountId',
MockEntry.of(Account.class).set('Industry', 'Technology'));
Id oppId = oppEntry.getAliasId('opp');
Set<Id> oppIds = new Set<Id>{ oppId };
MockEloquent mock = (new MockEloquent())
.attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
// Act
(new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke();
// Assert: 商談に業種がコピーされていること
List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE);
Assert.areEqual(1, updated.size());
Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);
Assert.isTrue(TraceFlow.isLastFinish());
t.finish();
}
@isTest
static void testInvoke_WhenNoOpportunityIds_ThenSkipped() {
Trace t = Trace.of('正常系: 対象の商談がないときスキップされること');
t.start();
// Arrange
Set<Id> oppIds = new Set<Id>();
MockEloquent mock = new MockEloquent();
// Act
(new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke();
// Assert
Assert.isTrue(TraceFlow.isLastSkip());
t.finish();
}
TraceFlow のアサーションは、戻り値だけでなく どのコードパスを通ったか を確認します。「対象がなくてスキップした」と「処理が正常に完了した」を区別できます。
テスト対象の Handler
結合テストの前に、Usecase を呼び出す Handler 側を用意します。Trigger ファイルは 7 イベントすべてを宣言し、Handler を 1 行呼ぶだけにします。
trigger Opportunity on Opportunity(
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
(new TriggerOppHandler()).execute();
}
Handler は TriggerHandler (ApexTools) を継承し、必要なフックだけ override します。やることは「条件判定」と「Usecase 呼び出し」だけで、ビジネスロジックは書きません。
public with sharing class TriggerOppHandler extends TriggerHandler {
protected override void afterInsert(Map<Id, SObject> newRecordsMap) {
(new CopyAccountIndustryToOpportunityUsecase(newRecordsMap.keySet())).invoke();
}
}
override していないフック (beforeUpdate など) は何もしません。空メソッドで埋める必要はありません。
「特定の項目が変わったときだけ動かしたい」場合は、基底クラスの
getUpdateRecordIdsWithChangedFields(...)を使います。Trigger.isAfterや new/old の比較を手書きしていたら、それは基底クラスが未導入のサインです。
ApexBlueprint を使った Handler の結合テスト
@isTest
static void testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談を insert すると親取引先の業種がコピーされること');
t.start();
// Arrange: ApexBlueprint で業種を持つ取引先と、その子商談を階層構造で組み立てる
SOrchestrator orchestrator = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.alias('acc')
.template(Blueprints.accBasic())
.set('Industry', 'Technology')
.withChildren(
SBlueprint.of(Opportunity.class)
.alias('opp')
.template(Blueprints.oppBasic())
));
// Act: create() で取引先 → 商談の順に insert され、商談 insert 時に Trigger が発火する
Test.startTest();
orchestrator.create();
Test.stopTest();
// Assert: 商談に親取引先の業種がコピーされていること
Opportunity opp = (Opportunity) orchestrator.getByAlias('opp');
Opportunity refetched = [
SELECT Id, Industry__c
FROM Opportunity
WHERE Id = :opp.Id
];
Assert.areEqual('Technology', refetched.Industry__c);
t.finish();
}
実 DML が実際の Trigger を通るため、このテストは 連鎖全体 (Handler → Usecase → ApexEloquent → DB) を検証します。使いどころは絞ります。Handler ごとに代表的なケースを 1 件から 3 件で十分です。ロジックの網羅は Usecase の単体テストに任せます。
その代表 1 本は、バルクにする
トリガーが別のトリガーを呼ぶようなカスケードがある場合、代表ケースのうち 1 本は「本番相当の件数を 1 回の DML で流し、ガバナの余白を確認する」テストにします。
理由は単純で、MockEloquent は実 SOQL を発行しないため、クエリ数の非効率が単体テストからは一切見えないからです。段階的に階層を降りて whereIn を撃つ実装は、単体テストが全緑のまま本番のバルク処理で Too many SOQL queries: 101 を出します。そして上のような単一シナリオの結合テストも、レコードが数件では 100 SOQL の天井に届きません。
// Arrange の階層に times() を足して量産し、
SBlueprint.of(Opportunity.class).template(Blueprints.oppBasic()).alias('opp_{#}').times(30)
// Assert に「ガバナ余白」を足す
Assert.isTrue(
Limits.getQueries() < Limits.getLimitQueries() / 2,
'バルクでも SOQL は上限の半分未満であること。実測 ' + Limits.getQueries()
);
件数は再現に足る最小に留めます (DML 行数の上限 10,000 に注意)。どの Usecase が食っているかを名指ししたい場合は、TraceFlow.usageOf(name) で Usecase 単位に締められます (TraceUsage でガバナ消費を縛る を参照)。
考え方の全体像は テスト戦略 にまとめています。
この先へ
- Apex Stem トップ。各ライブラリの役割と、ソースへのリンク。
設計そのものを掘り下げるなら:
- Handler-Usecase Architecture。2 層の責務、5 種のエントリーポイント、Salesforce 公式の推奨との重なり。
- Layered Constructor Pattern。ステップ 3 で出てきた 2 つのコンストラクタを、独立したテーマとして。
- テスト戦略。ステップ 4 の判断を体系化したもの。失敗時の切り分け、CI/CD の組み方、落とし穴。
各ライブラリを深掘りするなら:
- ApexEloquent ガイド。Scribe のより深い解説 (集計、親項目、サブクエリ、MockEntry の応用パターン)。
- ApexBlueprint ガイド。SBlueprint のテンプレート、兄弟参照の
use()、ネストした親子のwithChildren()。 - ApexTrace ガイド。Trace のライフサイクル、TraceFlow による経路検証、TraceUsage によるガバナ消費の保険への入り口。
- ApexTools ガイド。ステップ 4 で使った
TriggerHandler基底クラスと、DI 可能な HTTP リクエストラッパー。
1 つのライブラリ、1 本のクエリ、1 つの Usecase から始められます。全部を書き直す必要はありません。