Salesforce Apexクラスのベストプラクティス:スケーラブルな開発のための設計ガイド

背景と応用シナリオ

Salesforceプラットフォームでカスタムアプリケーションを構築する上で、Apexは中核をなすプログラミング言語です。ビジネスポセスの自動化、外部システムとの連携、あるいはLightning Web Components (LWC) のバックエンドロジックなど、その用途は多岐にわたります。Salesforce開発者として、私たちは単に「動くコード」を書くだけでなく、将来の機能拡張やメンテナンスの容易性、そしてパフォーマンスを考慮した「高品質なコード」を書く責任があります。

特にプロジェクトの規模が大きくなるにつれて、Apexクラスの設計がその成否を大きく左右します。適切に設計されていないコードは、以下のような問題を引き起こす可能性があります。

  • メンテナンスの困難さ:ロジックが1つの巨大なクラスに集中していると、小さな修正でも全体への影響範囲が読みにくく、デバッグに時間がかかります。
  • 再利用性の欠如:特定の機能が密結合していると、他の場所で同じロジックを再利用することができません。
  • テストの複雑化:多くの責務を持つクラスは、テストケースの作成が複雑になり、網羅的なテストが困難になります。
  • ガバナ制限違反:非効率なコードは、SOQLクエリやDMLステートメントの制限に容易に抵触します。

この記事では、Salesforce開発者の視点から、スケーラブルで保守性の高いアプリケーションを構築するためのApexクラス設計の原則とベストプラクティスについて、具体的なコード例を交えながら解説します。


原理説明

優れたApexクラスを設計するための基盤となるのは、オブジェクト指向プログラミング (Object-Oriented Programming, OOP) の原則です。ここでは、Salesforce開発において特に重要な設計パターンと原則をいくつか紹介します。

単一責任の原則 (Single Responsibility Principle - SRP)

SRPは、「クラスはたった一つの責務を持つべきである」という原則です。言い換えれば、クラスを変更する理由は一つだけでなければなりません。この原則に従うことで、クラスはよりシンプルで理解しやすく、テストも容易になります。Salesforce開発では、ロジックを以下のようなレイヤーに分割することが一般的です。

  • トリガーハンドラー (Trigger Handler):トリガーの実行コンテキスト(例:`before insert`, `after update`)を判断し、適切なビジネスロジックを呼び出す責務を持ちます。トリガー自体にはロジックを記述しません。
  • サービスクラス (Service Layer):特定のビジネスプロセスやオブジェクトに関するビジネスロジックをカプセル化します。複数の場所(トリガー、LWCコントローラー、バッチ処理など)から再利用されることを意図しています。
  • セレクタークラス (Selector Layer):データベースからのクエリ(SOQL)発行に特化したクラスです。複雑なクエリを一元管理し、再利用性を高め、コードの可読性を向上させます。
  • ドメインクラス (Domain Layer):特定のsObject(例:Account)に関連するロジックをカプセル化し、レコードの検証やデフォルト値の設定などを行います。

これらの責務を異なるクラスに分離することで、コードベース全体の見通しが良くなり、各コンポーネントを独立して開発・テストできるようになります。

トリガーハンドラーパターン (Trigger Handler Pattern)

Salesforceのベストプラクティスとして最もよく知られているものの一つが、トリガーロジックをトリガーファイルから分離する「トリガーハンドラーパターン」です。トリガーファイル内には一切ロジックを書かず、ハンドラークラスのメソッドを呼び出すだけに留めます。

利点:

  • テスト容易性:ハンドラークラスは単なるApexクラスであるため、テストメソッド内でインスタンス化して直接メソッドを呼び出すことができ、トリガーを発火させるDML操作なしにロジックをテストできます。
  • 再利用性:トリガー以外の場所(例:バッチ処理)からでも同じロジックを呼び出すことが可能です。
  • コードの整理:トリガーの実行コンテキストごとにロジックをメソッドに分割することで、コードが整理され、可読性が向上します。

インターフェースの活用 (Using Interfaces)

インターフェース (Interface) は、メソッドのシグネチャ(名前、引数、戻り値)のみを定義する契約書のようなものです。クラスがインターフェースを実装 (implement) すると、そのインターフェースで定義されたすべてのメソッドを実装することが強制されます。

これにより、疎結合な設計が可能になります。例えば、異なる計算ロジックを持つ複数のクラスを、同じインターフェースを実装することで統一的に扱うことができます。これは、特に動的に処理を切り替える必要がある場合に強力です。


示例代码

ここでは、取引先 (Account) が更新されたときに、関連するすべての取引先責任者 (Contact) の住所を更新するというシナリオを想定し、トリガーハンドラーパターンとサービスクラスを用いた実装例を示します。

1. トリガー (AccountTrigger.trigger)

トリガーは非常にシンプルに保ち、ハンドラークラスに処理を委譲するだけです。これにより、ロジックの実行順序や再帰的な呼び出しの制御をハンドラー側で一元管理できます。

trigger AccountTrigger on Account (before insert, before update, before delete,
                                after insert, after update, after delete, after undelete) {
    // すべてのロジックをハンドラークラスに委譲
    AccountTriggerHandler handler = new AccountTriggerHandler();

    if (Trigger.isAfter && Trigger.isUpdate) {
        handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
    }
}

2. トリガーハンドラー (AccountTriggerHandler.cls)

ハンドラーは、トリガーのコンテキスト変数(`Trigger.new`, `Trigger.oldMap`など)を受け取り、適切なサービスクラスのメソッドを呼び出します。

public class AccountTriggerHandler {
    
    /**
     * @description after update イベントで実行されるロジック
     * @param newAccounts トリガーで更新された取引先レコードのリスト
     * @param oldAccountMap 更新前の取引先レコードのMap (ID, Account)
     */
    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        // 請求先住所が変更された取引先のみを対象とする
        Set<Id> accountIdsToProcess = new Set<Id>();
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldAccountMap.get(newAcc.Id);
            if (newAcc.BillingStreet != oldAcc.BillingStreet || 
                newAcc.BillingCity != oldAcc.BillingCity ||
                newAcc.BillingState != oldAcc.BillingState ||
                newAcc.BillingPostalCode != oldAcc.BillingPostalCode ||
                newAcc.BillingCountry != oldAcc.BillingCountry) {
                
                accountIdsToProcess.add(newAcc.Id);
            }
        }

        if (!accountIdsToProcess.isEmpty()) {
            // ビジネスロジックをサービスクラスに委譲
            AccountService.updateRelatedContactAddresses(accountIdsToProcess);
        }
    }
}

3. サービスクラス (AccountService.cls)

サービスクラスは、実際のビジネスロジックを含みます。このメソッドは静的 (static) であり、インスタンス化せずに呼び出すことができます。また、一括処理(バルク処理)に対応していることが重要です。

public class AccountService {

    /**
     * @description 指定された取引先IDに関連するすべての取引先責任者の郵送先住所を更新する
     * @param accountIds 住所を同期する取引先のIDのセット
     */
    @future // DML操作を非同期で実行し、トリガーのコンテキストでのMixed DMLエラーを回避
    public static void updateRelatedContactAddresses(Set<Id> accountIds) {
        // 関連する取引先をクエリ
        List<Account> accountsWithContacts = [SELECT Id, BillingStreet, BillingCity, BillingState, BillingPostalCode, BillingCountry, 
                                                     (SELECT Id, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry 
                                                      FROM Contacts) 
                                              FROM Account 
                                              WHERE Id IN :accountIds];

        List<Contact> contactsToUpdate = new List<Contact>();
        for (Account acc : accountsWithContacts) {
            for (Contact con : acc.Contacts) {
                con.MailingStreet = acc.BillingStreet;
                con.MailingCity = acc.BillingCity;
                con.MailingState = acc.BillingState;
                con.MailingPostalCode = acc.BillingPostalCode;
                con.MailingCountry = acc.BillingCountry;
                contactsToUpdate.add(con);
            }
        }

        if (!contactsToUpdate.isEmpty()) {
            // 一括でDML操作を実行
            update contactsToUpdate;
        }
    }
}

4. テストクラス (AccountServiceTest.cls)

サービスクラスのロジックを検証するためのテストクラスです。適切なテストデータを作成し、メソッドを呼び出し、結果をアサーション (Assertion) で確認します。

@isTest
private class AccountServiceTest {

    @isTest
    static void testUpdateRelatedContactAddresses() {
        // 1. テストデータの準備
        Account testAcc = new Account(
            Name = 'Test Account',
            BillingStreet = '123 Main St',
            BillingCity = 'San Francisco',
            BillingState = 'CA',
            BillingPostalCode = '94105',
            BillingCountry = 'USA'
        );
        insert testAcc;

        Contact testCon = new Contact(
            LastName = 'Test Contact',
            AccountId = testAcc.Id
        );
        insert testCon;

        // 2. テスト対象メソッドの実行
        Test.startTest();
        // 住所を変更して取引先を更新(これがトリガーを発火させる)
        testAcc.BillingCity = 'Palo Alto';
        update testAcc;
        Test.stopTest();

        // 3. 結果の検証
        // @futureメソッドは非同期で実行されるため、トリガーの完了直後には更新されていない
        // ここでは、updateRelatedContactAddressesを直接呼び出すことでロジックをテストする
        // 実際のテストでは、@futureの完了を待つか、モックを使用する
        Set<Id> accountIds = new Set<Id>{ testAcc.Id };
        
        // テストのために直接メソッドをコール
        // @futureメソッドのテストは少し工夫が必要ですが、ここではロジックの単体テストに焦点を当てます
        // ⚠️ @future メソッドを直接テストするには、Test.startTest()とstopTest()の間でDMLを実行し、
        // stopTest()の後にクエリを発行して非同期処理の結果を確認する必要があります。
        // 上記のトリガー経由のテストでは、stopTest()が非同期処理の完了を待機します。

        Contact updatedCon = [SELECT MailingCity FROM Contact WHERE Id = :testCon.Id];
        System.assertEquals('Palo Alto', updatedCon.MailingCity, '取引先責任者の住所が正しく更新されていません。');
    }
}

注意:上記のコードは developer.salesforce.com に記載されている概念とパターンに基づいていますが、特定のページからの直接のコピーではありません。トリガーフレームワーク、サービスクラス、および単体テストのベストプラクティスを組み合わせた実践的な例として構成されています。


注意事項

ガバナ制限 (Governor Limits)

Salesforceはマルチテナント環境であるため、すべてのApexコードは厳格なガバナ制限の下で実行されます。1回のトランザクション内で実行できるSOQLクエリの数(100回)やDMLステートメントの数(150回)には上限があります。ループ内でSOQLやDMLを実行することは、最も一般的なガバナ制限違反の原因です。常にコードを一括処理対応 (Bulkified) に設計し、SetやMapを効果的に使用して、一度に複数のレコードを効率的に処理できるようにしてください。

テストカバレッジ (Test Coverage)

本番環境にApexコードをデプロイするには、最低でも75%のコードカバレッジが必要です。しかし、これは最低要件であり、高品質なアプリケーションを目指すなら90%以上を目標とすべきです。重要なのは、カバレッジのパーセンテージだけでなく、ポジティブなシナリオ、ネガティブなシナリオ、そして境界値など、ロジックのすべての分岐を検証するアサーション (`System.assertEquals()`, `System.assertNotEquals()`など) を含めることです。

セキュリティと共有設定 (Security and Sharing Settings)

Apexクラスは、`with sharing`、`without sharing`、または `inherited sharing` というキーワードで実行時の共有設定を制御できます。

  • `with sharing` (デフォルト): クラスは現在のユーザーの共有ルールを尊重します。ユーザーがアクセス権を持たないレコードは、コード内でも参照・更新できません。
  • `without sharing`: 共有ルールを無視して、すべてのレコードにアクセスできます。システムレベルの操作を行う際に使用しますが、権限昇格のリスクがあるため慎重に使用する必要があります。
  • `inherited sharing`: このクラスを呼び出した親クラスの共有設定を継承します。意図しない共有設定での実行を防ぐため、ベストプラクティスとされています。

セキュリティを確保するため、原則として `inherited sharing` または `with sharing` を指定することを推奨します。


まとめとベストプラクティス

高品質なApexクラスを設計することは、Salesforceアプリケーションの長期的な成功に不可欠です。最後に、開発者として心に留めておくべきベストプラクティスをまとめます。

  1. 一つのクラス、一つの責務:ロジックを機能ごとにクラスに分割し、SRPに従います。
  2. トリガーにロジックを書かない:トリガーハンドラーパターンを常に使用し、トリガーはディスパッチャーとしてのみ機能させます。
  3. ビジネスロジックをサービスレイヤーに集約:再利用可能でテストしやすいコンポーネントとしてビジネスロジックをカプセル化します。
  4. 常に一括処理を意識する:コードが一度に1レコードでも200レコードでも正しく、効率的に動作するように設計します。
  5. 網羅的な単体テストを作成する:コードカバレッジだけでなく、アサーションを通じてロジックの正当性を証明します。
  6. 共有設定を明示的に定義する:セキュリティを考慮し、`inherited sharing` または `with sharing` をクラス定義に含めます。
  7. 命名規則を守り、コメントを記述する:他の開発者がコードの意図を容易に理解できるように、分かりやすい名前を付け、複雑な部分にはコメントを残します。

これらの原則を日々の開発業務に取り入れることで、あなたはより堅牢で、スケーラブルで、そして保守しやすいSalesforceアプリケーションを構築できる優れた開発者となることができるでしょう。

コメント