Salesforce Apexクラスのベストプラクティス:エンタープライズアーキテクチャ設計

背景と応用シナリオ

Salesforce アーキテクトとして、私は単に機能するコードを書くこと以上に、スケーラブルで、保守性が高く、セキュアなソリューションを設計することに重点を置いています。その中心にあるのが Apex クラスの設計です。Apex は Salesforce Platform 上で複雑なビジネスロジックを実行するための強力な言語ですが、その力を無計画に行使すると、組織はすぐに「Technical Debt (技術的負債)」の山に埋もれてしまいます。技術的負債とは、短期的な解決策を選んだ結果、将来的に発生するであろう追加の開発コストや保守コストを指します。

Apex クラスは、以下のような多様なシナリオで不可欠です:

  • 複雑なビジネスロジックの実装: 標準のワークフロールールやプロセスビルダーでは実現不可能な、複数オブジェクトにまたがる複雑なデータ検証や自動化処理。
  • カスタムユーザーインターフェースのバックエンド処理: Lightning Web Components (LWC) や Aura Components から呼び出され、サーバーサイドでデータのクエリ、加工、保存を行う。
  • 外部システムとの連携: REST や SOAP API を介して外部システムとリアルタイムまたはバッチでデータを同期する。
  • 非同期処理: 大量データを扱うバッチ処理や、時間のかかる処理をバックグラウンドで実行する Queueable Apex, Schedulable Apex, Future メソッド。

これらのシナリオにおいて、場当たり的なクラス設計は、パフォーマンスの低下、デバッグの困難さ、そして新機能追加時の影響範囲の肥大化を招きます。堅牢なアーキテクチャは、これらの課題を克服し、Salesforce 組織の長期的な健全性を保つための基盤となるのです。


原理説明

優れた Apex クラスアーキテクチャの核心は、Separation of Concerns (SoC)、すなわち「関心の分離」という原則にあります。これは、アプリケーションを構成する各部分が、それぞれ異なる関心事(責任)を持つように分割するという考え方です。これにより、コードの再利用性が高まり、テストが容易になり、変更時の影響を最小限に抑えることができます。Salesforce の文脈では、SoC は以下のようなレイヤー構造として具体化されます。

1. Trigger Handler (トリガーハンドラー) レイヤー

トリガーは、コード実行の入り口ですが、ロジックそのものを含むべきではありません。トリガーは「いつ実行するか」を決定するだけで、「何を実行するか」はハンドラークラスに委譲します。これにより、「One Trigger Per Object (1オブジェクトにつき1トリガー)」というベストプラクティスが実現しやすくなり、実行順序の制御が容易になります。

2. Service (サービス) レイヤー

ビジネスプロセスやユースケース単位のロジックをカプセル化するレイヤーです。例えば、「商談が成立したら、注文と契約を生成する」といった一連のビジネスロジックを一つのサービスメソッドにまとめます。UI コントローラーやバッチクラス、トリガーハンドラーなど、様々な場所からこのサービスを呼び出すことで、ロジックの重複を防ぎ、一貫性を保ちます。

3. Selector (セレクター) レイヤー

このレイヤーの唯一の責任は、データベースからのレコードのクエリです。特定のオブジェクトに対する SOQL クエリをこのレイヤーのクラスに集約します。これにより、同じようなクエリがコードベースに散在するのを防ぎます。また、フィールドレベルセキュリティ (FLS) のチェックを一元的に適用したり、クエリのパフォーマンスチューニングを容易にしたりするメリットもあります。

4. Domain (ドメイン) レイヤー

特定の sObject に固有のロジック(検証、デフォルト値設定、複雑な計算など)を管理します。トリガーコンテキストで実行されることが多く、対象オブジェクトのレコードが常に一貫性のある状態を保つことを保証します。

これらのレイヤーを適切に組み合わせることで、各 Apex クラスは単一の責任を持つようになり、システム全体の見通しが格段に良くなります。アーキテクトとしては、開発チームがこれらの原則を理解し、遵守するように導くことが極めて重要です。


示例コード

ここでは、最も基本的かつ重要なアーキテクチャパターンである「Trigger Handler Pattern (トリガーハンドラーパターン)」の公式ドキュメントに基づく例を示します。トリガーファイル (AccountTrigger) はロジックを持たず、実際の処理をハンドラークラス (AccountTriggerHandler) に委譲している点に注目してください。

トリガー: AccountTrigger.trigger

このトリガーは非常にシンプルです。トリガーイベントに応じて、ハンドラークラスの適切なメソッドを呼び出すことだけに専念しています。

trigger AccountTrigger on Account (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    // すべてのロジックをハンドラークラスに委譲する
    AccountTriggerHandler.handleTrigger(
        Trigger.new,
        Trigger.old,
        Trigger.newMap,
        Trigger.oldMap,
        Trigger.operationType
    );
}

ハンドラークラス: AccountTriggerHandler.cls

このクラスが実際のビジネスロジックを格納します。Trigger.operationType を使用して、どのDML操作(INSERT, UPDATEなど)が発生したかを判断し、適切なロジックを実行します。

public with sharing class AccountTriggerHandler {

    // トリガーから呼び出されるメインのハンドリングメソッド
    public static void handleTrigger(
        List<Account> newRecords,
        List<Account> oldRecords,
        Map<Id, Account> newMap,
        Map<Id, Account> oldMap,
        System.TriggerOperation operationType
    ) {
        // switchステートメントでトリガーの操作タイプを判別
        switch on operationType {
            when BEFORE_INSERT {
                // レコード挿入前の処理を呼び出す
                handleBeforeInsert(newRecords);
            }
            when AFTER_INSERT {
                // レコード挿入後の処理を呼び出す
                handleAfterInsert(newRecords);
            }
            when AFTER_UPDATE {
                // レコード更新後の処理を呼び出す
                handleAfterUpdate(newRecords, oldMap);
            }
            // 他のイベント(BEFORE_UPDATE, AFTER_DELETEなど)も同様に追加可能
        }
    }

    // レコードが挿入される前に実行されるロジック
    private static void handleBeforeInsert(List<Account> newAccounts) {
        // 例: 新規取引先の名前にプレフィックスを追加する
        for (Account acc : newAccounts) {
            acc.Name = 'New: ' + acc.Name;
        }
    }

    // レコードが挿入された後に実行されるロジック
    private static void handleAfterInsert(List<Account> newAccounts) {
        // 例: 関連するToDoを作成する
        // このロジックはバルク対応(一括処理対応)している必要がある
        List<Task> tasksToInsert = new List<Task>();
        for (Account acc : newAccounts) {
            tasksToInsert.add(new Task(
                Subject = 'Follow up with ' + acc.Name,
                WhatId = acc.Id
            ));
        }
        if (!tasksToInsert.isEmpty()) {
            insert tasksToInsert;
        }
    }

    // レコードが更新された後に実行されるロジック
    private static void handleAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
        // 例: 年間売上が変更された場合に通知を行う
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldMap.get(newAcc.Id);
            if (newAcc.AnnualRevenue != oldAcc.AnnualRevenue) {
                // 通知ロジックなどをここに記述
                System.debug('Account ' + newAcc.Name + ' Annual Revenue changed.');
            }
        }
    }
}

注意事項

堅牢な Apex クラスを設計する上で、以下の点に常に注意を払う必要があります。

1. Governor Limits (ガバナ制限)

Salesforce はマルチテナント環境であるため、すべての組織がリソースを公平に利用できるよう、厳格なガバナ制限が設けられています。アーキテクトは、コードがこれらの制限を超えないように設計する責任があります。

  • Bulkification (一括処理化): コードは常に単一のレコードではなく、複数のレコード(最大200件)のリストを処理できるように設計する必要があります。SOQLクエリやDMLステートメントをforループ内で実行することは、最も避けるべきアンチパターンです。
  • 効率的なクエリ: Selector レイヤーを利用して、必要なフィールドのみを取得し、適切なWHERE句で結果をフィルタリングする SOQL クエリを作成します。
  • CPU Time Limit: 複雑な計算やネストされたループは CPU 時間を消費します。効率的なアルゴリズムを選択し、処理を最適化することが重要です。

2. Security (セキュリティ)

セキュリティは後付けできるものではありません。設計の初期段階から組み込む必要があります。

  • Sharing Model (共有モデル): Apex クラスはデフォルトでシステムコンテキスト(without sharing)で実行されます。つまり、現在のユーザーの共有ルールを無視してすべてのデータにアクセスできてしまいます。意図しないデータ漏洩を防ぐため、原則としてクラスには with sharing キーワードを明示的に指定し、ユーザーの権限を尊重するように設計します。システムレベルの操作が必要な場合にのみ、慎重に without sharing を使用します。
  • CRUD/FLS (項目レベルセキュリティ): ユーザーにオブジェクトや項目へのアクセス権があるかを確認せずに DML 操作やクエリを実行するべきではありません。SObjectType.getDescribe()Security.stripInaccessible() メソッドを使用して、アクセス権をプログラムでチェックすることがベストプラクティスです。
  • SOQL Injection (SOQLインジェクション): ユーザー入力を直接 SOQL クエリに連結しないでください。常に静的クエリとバインド変数を使用するか、動的クエリの場合は String.escapeSingleQuotes() でエスケープ処理を行ってください。

3. Error Handling (エラー処理) と Logging (ロギング)

予期せぬエラーは必ず発生します。堅牢なエラー処理とロギング戦略は、問題の迅速な特定と解決に不可欠です。

  • Try-Catch ブロック: DML 操作や Callout など、失敗する可能性のあるコードは必ず try-catch ブロックで囲みます。
  • 適切な例外処理: エラーをキャッチしただけで握りつぶすのではなく、ユーザーに分かりやすいメッセージを表示したり、管理者に通知したり、カスタムのログオブジェクトに詳細を記録したりするなど、適切なアクションを取るべきです。Platform Events を利用した非同期ロギングフレームワークも、大規模組織では有効な選択肢です。

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

Salesforce では、本番環境に Apex コードをデプロイするために最低 75% のテストカバレッジが要求されます。しかし、アーキテクトとしては、カバレッジ率を単なる数字として捉えるべきではありません。テストはコードの品質を保証し、将来のリファクタリングを安全に行うためのセーフティネットです。レイヤー化されたアーキテクチャは、各コンポーネントを個別にテストすることを容易にし、高品質なテストコードの作成を促進します。


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

Apex クラスは Salesforce Platform の能力を最大限に引き出すための鍵ですが、その力は disciplined なアーキテクチャ設計によってのみ、持続可能な価値を生み出します。場当たり的な実装は短期的な成功をもたらすかもしれませんが、長期的には組織の俊敏性を奪い、イノベーションを阻害する技術的負債となります。

Salesforce アーキテクトとして、私は以下のベストプラクティスを遵守することを強く推奨します:

  • 関心の分離 (SoC) を徹底する:

    トリガーハンドラー、サービス、セレクター、ドメインといったレイヤー構造を採用し、各クラスに単一の責任を持たせる。

  • One Trigger Per Object を守る:

    オブジェクトごとにトリガーを一つに限定し、すべてのロジックをハンドラークラスに委譲して実行順序を制御する。

  • コードを常に Bulkify する:

    すべてのロジックが単一レコードだけでなく、複数レコードのリストを効率的に処理できるように設計する。

  • 共有設定を意識する:

    クラスには原則として with sharing を指定し、データセキュリティを最優先に考える。

  • ハードコーディングを避ける:

    ID、URL、固定の文字列などをコードに直接書き込まず、カスタムメタデータ型やカスタム設定を利用して、柔軟性と保守性を高める。

  • 再利用可能なメソッドを作成する:

    共通のロジックはユーティリティクラスやサービスクラスに切り出し、コードの重複を排除する。

  • 意味のあるテストを作成する:

    75% のカバレッジを達成するだけでなく、ポジティブ、ネガティブ、バルクシナリオなど、様々なユースケースを網羅するアサーション主導のテストを記述する。

これらの原則に基づいた Apex クラスの設計は、初期の開発コストがわずかに増加するかもしれませんが、その投資は、将来の保守性、スケーラビリティ、そしてビジネスの変化に対応する能力という形で、何倍にもなって返ってくるでしょう。

コメント