後から「やらなければよかった」と思った設計:Managed Package内での直接コールアウト実装

後から「やらなければよかった」と思った設計

Salesforce 開発者としてのキャリアで、managed packages に関して最も後悔している設計判断の一つは、Managed Package が提供するApexクラスやトリガーの内部に、直接外部システムへのコールアウトロジックを実装したことだ。

当時、私はあるクライアントプロジェクトにアサインされていた。そのプロジェクトでは、特定の業務フローを実現するために、AppExchange から導入された Managed Package (以下、MP) をコアとして利用していた。この MP は、Salesforce 内でのデータ管理には非常に優れていたが、特定の外部システムとの連携機能が不足していた。

その時、なぜそう判断したのか

プロジェクトの要件として、MP のオブジェクトに特定のデータが保存された際に、そのデータを外部の基幹システムへリアルタイムで連携する必要があった。開発期間はタイトで、外部システム連携部分の要件も比較的シンプルに見えた。

MP のベンダーは、その時点では柔軟な拡張ポイント(例えば、外部システム連携用のサービスインタフェースや、特定のイベント発行機能など)を提供していなかった。一方で、MP が提供する Apex トリガーやクラスから、容易にカスタムApexクラスを呼び出すことができた。例えば、MP のオブジェクトの after insert トリガーから、自分で作成した CalloutService.callExternalSystem() のようなスタティックメソッドを直接コールする、という手法だ。

私は「これでいけるだろう」と判断した。MP のトリガーハンドラクラスに、新しいレコードが作成された場合にカスタムApexクラスのメソッドを呼び出すロジックを追加した。外部連携のビジネスロジックはシンプルだったし、何よりもこれが一番手っ取り早く、かつ要件を満たせる方法に見えたのだ。

結果として後悔した点

最初はうまくいっているように見えた。開発も無事に完了し、機能テストもパスした。しかし、本番稼働後、そしてMPのアップグレードサイクルが回り始めてから、私の判断ミスが明確になった。

  • MPアップグレードの悪夢

    Managed Package は定期的に機能追加やバグ修正のためにアップグレードが必要になる。アップグレードのたびに私は恐怖を感じていた。私が追加したカスタムコードが、MP の新しいバージョンとコンフリクトを起こさないか?MP 側が、私が使っている同名のクラスやメソッドを内部で使い始めたらどうなるのか?幸いにも致命的なコンフリクトは発生しなかったが、アップグレードのデプロイは常に胃の痛む作業だった。

    もし MP 側が、私の利用していた内部的な拡張ポイントの仕様を変更したり、私と同じ名前のカスタムオブジェクトやフィールドを導入したりしたら、恐ろしいことになっていただろう。

  • カスタマイズ性の欠如と保守コストの増大

    顧客からの追加要件で「特定の条件下では外部連携をスキップしたい」「エラー発生時は3回までリトライしたい」といった要望が出た際、対応が非常に困難だった。私のコードは MP のロジックと密結合しており、MP の内部挙動を理解した上で慎重に修正する必要があった。まるで MP のコードを直接編集しているような感覚だ。

    これにより、カスタムコードの修正・テストにかかる時間とコストが跳ね上がった。そして、MP のアップグレードごとに、私のカスタムコードが意図通りに動作し続けるかを確認するための広範囲なリグレッションテストが必須となった。

  • 責任範囲の曖昧さ

    外部連携で問題が発生した際、「これは MP のバグなのか?それとも我々が追加したコールアウトロジックのせいなのか?」という責任の切り分けが非常に難しかった。MP ベンダーに問い合わせても「お客様のカスタムコードによるものでは?」と言われ、こちらで調査すると MP のバージョンアップが原因の一端であったり、その逆もあり、無駄な調査工数が頻発した。

  • ガバナ制限のリスク管理

    MP のトリガー内で同期コールアウトを実行していたため、DML操作を含むトランザクションのコールアウト制限 (10コールアウト、合計120秒) を常に意識する必要があった。MP 側の処理量が増えた際、予期せずこの制限に引っかかるリスクも常に付きまとっていた。MP 側の処理で既に多くのSOQL/DMLが実行されている可能性があり、その上で私のコールアウトロジックがさらにリソースを消費するため、ボトルネックになりやすかった。

今なら別の選択をする

当時の私は、とにかく「目の前の問題を早く解決する」ことにフォーカスしすぎていた。もし今同じ状況に直面したら、絶対にこの方法を選ばない。

今ならどうするか?

  1. Platform Event / Change Data Capture を活用する

    MP のオブジェクトの変更を直接検知するのではなく、Platform Event や Change Data Capture (CDC) を活用し、イベント駆動で連携ロジックを起動する。MP がPlatform Eventを発行しない場合でも、MP のオブジェクトに対する DML を CDC で捕捉し、そこから外部連携用のカスタムApexクラスを非同期で起動する。

    これにより、MP の内部ロジックと外部連携ロジックを完全に疎結合にできる。MP のアップグレードが直接カスタムコードに影響を与えるリスクを最小限に抑えられるし、連携ロジックのテストも独立して行える。

  2. Managed Package 提供元との連携強化

    可能であれば、MP のベンダーに拡張ポイントの追加や、特定の外部連携機能の標準サポートを依頼する。彼らが提供する公式の拡張メカニズムがあれば、最も安全かつ持続可能な方法となる。

  3. ミドルウェアの利用

    Salesforce の外に、MuleSoft や AWS Lambda といったミドルウェアを挟むことを検討する。Salesforce からは単純にメッセージキューにメッセージをプッシュするだけにし、実際の外部システム連携ロジックはミドルウェアで完結させる。これにより、Salesforce 側のガバナ制限のリスクを大幅に軽減できる。

  4. External Services を最大限活用する

    Apexコールアウトを直接書くのではなく、External Services を利用して OpenAPI Specification (Swagger) から連携ロジックを宣言的に定義する。ただし、これは外部APIが OpenAPI に対応している場合に限られる。これでもMP内のコードと疎結合にする課題は残るため、1と併用すべきだろう。


当時のコード(今ならこうは書かない)

これは、当時の私が実際に書いた(非常に簡略化した)コードのイメージだ。MP のトリガーから直接呼び出すような設計をしていた。

public class MyExternalSystemCalloutService {

    // ⚠️ 今ならこうは書かない。Managed Packageのトリガーや内部ロジックから
    //    直接このメソッドを同期的に呼び出すのは避けるべき。
    //    Platform EventやQueueable Apexを介して非同期に呼び出すべき。
    public static void processAndCallExternal(List<MyManagedObject__c> newObjects) {
        List<String> dataToSend = new List<String>();
        for (MyManagedObject__c obj : newObjects) {
            // ここでManaged Packageのオブジェクトデータから外部システムに送るデータを準備
            dataToSend.add(obj.MyDataField__c);
        }

        if (!dataToSend.isEmpty()) {
            callExternalSystem(dataToSend);
        }
    }

    private static void callExternalSystem(List<String> payload) {
        HttpRequest req = new HttpRequest();
        req.setMethod('POST');
        // Named Credential を使用していたが、それでもリスクはあった
        req.setEndpoint('callout:MyNamedCredential/api/v1/data');
        req.setHeader('Content-Type', 'application/json');
        req.setBody(JSON.serialize(new Map<String, List<String>>{'data' => payload}));
        req.setTimeout(60000); // タイムアウト設定

        Http http = new Http();
        try {
            HttpResponse res = http.send(req);
            if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
                System.debug('Callout successful: ' + res.getBody());
            } else {
                System.error('Callout failed with status: ' + res.getStatusCode() + ' - ' + res.getBody());
                // エラー処理、リトライロジックもこのメソッド内に閉じ込められていた
                // 実際はエラーログをCustom Objectに記録していたが、再試行は手動だった
            }
        } catch (System.CalloutException e) {
            System.error('Callout exception caught: ' + e.getMessage());
            // ネットワークエラーや予期せぬ例外のハンドリング
        }
    }
}

当時の私は、このコードが「機能要件を満たし、迅速にデプロイできる」という側面だけを見ていた。しかし、その後の保守運用、特にManaged Packageのライフサイクルを考慮すると、このような密結合な設計は避けるべきだったと痛感している。

これは当時の自分向けのメモだ。次に同じ轍を踏まないために。

コメント