💭

Salesforce Apex 共通ロジックのまとめ方

2021/12/28に公開

PolicyObjectパターンを使用する

Salesforceでレイヤードアーキテクチャ※の採用を検討すると、実行するコンテキスト(状況)やガバナ制限などでうまくかみ合わないと考えました、よりシンプルなPolicyObjectを用いてロジックを共通化します。

※Apex エンタープライズパターン: サービスレイヤ
https://trailhead.salesforce.com/ja/content/learn/modules/apex_patterns_sl

PolicyObjectパターンはRuby on Railsで使用されることが多いデザインパターンです(たぶん)

実装方法

PolicyObjectで「取引先責任者が20才以上の時に購入可能」というロジックを記述した例です。

// インスタンスメソッドの場合
public with sharing class ContactPolicy {
  private final Contact aContact {get;set;}

  public ContactPolicy(Contact aContact) {
    this.aContact = aContact;
  }

  public static Boolean canPurchase() {
    Boolean birthdate20 = aCountact.Birthdate.addYears(20);
    return birthdate20 <= Date.today();
  }
}

// staticメソッドの場合
public with sharing class ContactPolicy {
  public static Boolean canPurchase(Contact aContact) {
    Boolean birthdate20 = aCountact.Birthdate.addYears(20);
    return birthdate20 <= Date.today();
  }
}

インスタンスメソッドでもstaticメソッドでもどちらでもよいです。
(パターン的に正しいのはインスタンスメソッド。好みで決めてよいと思います)

SObjectのListを渡すのではなく一つのSObjectを渡します。
複数レコードを扱うTriggerやBatch、単一のレコードを扱うVisualforceやLWCのコントローラーなどでContactPolicyを呼び出すことによりロジックの共通化が可能となります。

共通化の単位にSObjectを選んだ理由

SalesforceでLWCやVisualforceを使用する場合に「入力フォーム用のDto」や「出力用のDto」を作成することはなくSObjectをそのまま使用します。(複雑な要件であればDtoを作成する)
そのため、SObjectに処理を寄せる方針がよいと考えSObject単位で共通化することに選択しました。

関連オブジェクトがある場合の実装方法

取引先の「従業員数」項目が100以上かつ取引先責任者の「リードソース」項目がWebの場合にメールアラートを送信する場合

子から親を参照の場合

関連レコードを取得またはSObject変数に設定します。

// 使用する側
Contact aContact = [SELECT LeadSource, Account.NumberOfEmployees FROM Contact LIMIT 1];
System.debug(ContactPolicy.canSendEMailAlert(aContact));

// PolicyObject側
public with sharing class ContactPolicy {
  public static Boolean canSendEMailAlert(Contact aContact) {
    return aContact.LeadSource == 'Web' && aContact.Account.NumberOfEmployees >= 100;
  }
}

親から子を参照の場合

PolicyObjectは1レコードのオブジェクトをコンストラクタや引数に渡すパターンなので
ContactPolicyではなく親側のAccountPolicy側のPolicyObject実装する

// 使用する側
Account aAccount = [SELECT NumberOfEmployees (SELECT LeadSource FROM Contacts) FROM Contact LIMIT 1];
System.debug(AccountPolicy.canSendEMailAlert(aAccount));

// PolicyObject側
public with sharing class AccountPolicy {
  public static Boolean canSendEMailAlert(Account aAccount) {
    if (!(aAccount.NumberOfEmployees >= 100)) {
      return false;
    }
    // いずれかの取引先責任者のリードソースがWebの場合
    for (Contact aContact : aAccount.Contacts) {
      if (aContact.LeadSource == 'Web') {
        return true;
      }
    }
    return false;
  }
}

関連しないオブジェクトやプリミティブ型を渡したい場合

引数で渡す。これしかないので


// PolicyObject側
public with sharing class AccountPolicy {
  public static Boolean canSendEMailAlert(Account aAccount, Integer hoge, CustomObject__c aCustomObject) {
    // ...
    return true;
  }
}

条件となる値を取得したい場合

「取引先責任者が20才以上の時に購入可能」というロジックで
VisualForceやLWCで「'20XX/11/11'から購入可能です」または「'20'才以上で購入可能です」と表示したい場合のデータの取り方

Dtoを使用する

public with sharing class ContactPolicy {
  public static CanPurchaseDto canPurchase(Contact aContact) {
    Boolean birthdate20 = aCountact.Birthdate.addYears(20);

    CanPurchaseDto result = new CanPurchaseDto();
    result.canPurchaseDate = birthdate20;
    result.canPurchaseAge = 20;
    result.result = birthdate20 <= Date.today();

    return result;
  }

  public class CanPurchaseDto() {
    public Date canPurchaseDate {get;set;}
    public Integer canPurchaseAge {get;set;}
    public Boolean result {get;set;}
  }
}

PolicyObjectの分割

単一責任の原則に基づいてアクターが異なる場合はコードを分割する
EmployeeプロファイルとPartnerプロファイルがありプロファイル毎でロジックが異なる場合の例

ContactPolicy           // 共通
ContactEmployeePolicy   // Employee用のPolicyObject
ContactPartnerPolicy    // Partner用のPolicyObject

どう分割するかは正解はなく案件ごとでより良いものを選択します。

ノーコードの場合

「引数がSObjectで戻り値を返す」というFlowを作成すればできるかもしれません。
(詳しくないので可能かどうかの判断ができない)

自動テストについて

SalesforceのApexテストはテストピラミッドの階層※のIntegrationTestです。
IntegrationTestは「実行ユーザのプロファイル」「カスタム表示ラベルの値」「カスタムメタデータの値」などApexコード以外も含んだテストです。
そのためテストが壊れやすく保守工数が高くなると考えています。

PolicyObjectのコードを対象としたUnitTestを行うことで、IntegrationTestとのテストケース数のバランスを取りやすくなります。
(UnitとIntegrationのテストをどれくらい行うかのテスト設計は必要です。)

※テスト ピラミッドの階層
https://developer.android.com/training/testing/fundamentals?hl=ja#testing-pyramid

最後に

実は案件で試したことがなく生煮えの状態ですが、思いついたので記事にしました。

Discussion