Apex Stem 導入ガイド

Apex Stem ドキュメント
Apex StemApexEloquentApexBlueprintApexTraceFull Guide
既存の Salesforce コードベースに Apex Stem を取り入れる 4 ステップを、動くコードとともに解説します。

このガイドでは、既存の 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

APEX
List<Account> accounts = [
  SELECT Id, Name, Industry
  FROM Account
  WHERE Industry = :industry
];

After

APEX
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 です。IEntrySObject の代わりとして振る舞えるラッパーで、SObject のまま扱うのに比べて ApexEloquent が提供する仕組み (SELECT 漏れ検知、数式項目やロールアップのモックなど) の恩恵を受けられます。Apex Stem では、基本的に IEntry のままビジネスロジックを記述することを推奨しています。

フィールドの読み取り

APEX
for(IEntry accountEntry : accountEntries) {
  Id id = accountEntry.getId();                       // 専用 getter
  String name = accountEntry.getName();               // 専用 getter
  String industry = (String) accountEntry.get('Industry'); // キャスト必須
}

なぜ SObject に落とさないのか

getAsSObject() を使えば SObject として受け取れますが、落とした瞬間、その先で SELECT 漏れ検知が効かなくなります

APEX
// ❌ この 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.processMessaging など)。「キャストが面倒」「いまは SObject の方が速く書ける」は理由になりません。ここを一度崩すと、以降の歯止めが効かなくなります。

💡 迷ったら「このレコードはクエリ由来か」で判断します。Yes なら IEntry、その場で new した新規レコードなら SObject です。doInsertIEntry 版のオーバーロードが無いのは、そのためです (新規レコードに SELECT の概念が無く、包んでも検知しようがない)。

IEntry が具体的にどんな恩恵をもたらすか、いつ SObject に変換してよいかは、ApexEloquent のドキュメント で詳しく解説します。

ステップ 3: Usecase を切り出す

SOQL とロジックの組み合わせが数行を超えてきたら、名前を付けます。Apex Stem の Usecase は、public な表面が invoke() だけのオブジェクトです。

ここでは例として、商談 (Opportunity) に、親である取引先 (Account) の業種をコピーする Usecase を見てみます。

APEX
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) でラベル多重化しています。役割ごとにラベルを付けておくと、テストで「取得結果はこう返す」「更新はこう検証する」を独立して書けます。

また、取得した IEntrygetParent でたどり、put で書き換え、doUpdate にそのまま渡しています。ステップ 2 で触れた「IEntry のまま完結させる」流れの実例です。

ステップ 4: 適切な層でテストする

アーキテクチャは、2 つのテスト戦略と 1 対 1 で対応します。

  • Usecase 層 → MockEloquent による単体テスト。DB なし、高速、ロジックを網羅できます。
  • Handler 層 → SBlueprint による結合テスト。実 DML を流し、配線を検証します。

Usecase の単体テスト

APEX
@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 行呼ぶだけにします。

APEX
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 呼び出し」だけで、ビジネスロジックは書きません。

APEX
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 の結合テスト

APEX
@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 の天井に届きません。

APEX
// 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 でガバナ消費を縛る を参照)。

考え方の全体像は テスト戦略 にまとめています。

この先へ

設計そのものを掘り下げるなら:

  • 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 から始められます。全部を書き直す必要はありません。