C. 後から「やらなければよかった」と思った設計
あれは今から数年前、とあるマネージドパッケージが提供する機能を我々のカスタムロジックに組み込む必要があった時の話だ。当時、私はSalesforce開発者として、そのパッケージのApexクラスを直接呼び出す設計を採用した。
具体的に言うと、パッケージのglobalクラスに定義されたglobalメソッドを、我々のApexトリガーハンドラから直接呼び出す、というものだった。納期はタイトで、そのパッケージのドキュメントには「このメソッドを使って、このオブジェクトを作成する」と書かれていた。シンプルにそれを使えば良いと、その時は本当にそう思ったのだ。何となく「globalだから大丈夫だろう」という安易な思い込みもあった。
当時の私には、マネージドパッケージのglobalキーワードが持つ意味の重さや、アップグレード時のリスクに対する認識が甘かった。globalは確かに外部から呼び出せることを保証するが、それはあくまでパッケージベンダーがそのメソッドのシグネチャを変更しない限り、という話だ。そして、たとえシグネチャが変わらなくても、内部ロジックの変更が我々の期待する挙動と異なる結果をもたらす可能性も考慮していなかった。
後悔の始まり:パッケージアップグレードの地獄
問題が顕在化したのは、数ヶ月後のパッケージアップグレード時だ。ベンダーから新機能追加とバグ修正のため、パッケージのメジャーバージョンアップがリリースされた。我々は何も考えずにサンドボックスでアップグレードを試みた。すると、我々のカスタムApexクラス、特にパッケージのメソッドを呼び出している部分でコンパイルエラーが発生した。
なんと、ベンダーが以前globalとして公開していたメソッドの一部が、新しいバージョンではprivateに変更されていたのだ。あるいは、シグネチャ(引数の型や数)が変わってしまっていた。パッケージベンダーからすれば、そのメソッドは「内部向け」であり、意図せずglobalになっていたか、あるいはその後の設計変更でglobalにする必要がなくなったのかもしれない。しかし、我々からすれば、それは完全に「API破壊」だった。
我々の開発環境はコンパイルエラーの山となり、テストクラスも軒並み落ちた。その時初めて、私は青ざめた。マネージドパッケージの提供するAPIを、まるで自社の共通ライブラリのように直接呼び出すことの危険性を痛感した。
当時の対処と、今ならどうするか
当時の私は、とにかく動かすために、ベンダーのリリースノートを必死に読み込み、代替となるglobalメソッドがないか探した。幸い、その時は別のglobalメソッドが提供されていたため、そちらに切り替えることで対処できた。しかし、もし代替がなかったら、我々のカスタムロジック自体を大幅に見直す必要があっただろう。テストクラスの修正も地獄だった。パッケージのテストデータをMockせずに直接使っていたため、パッケージ側のデータ構造変更がテストクラスの作成ロジックに影響し、大量のテストデータレコードを修正する羽目になった。これは特に@isTest(SeeAllData=true)を使っていたテストクラスで顕著だった。今なら絶対にこれを避け、MockingフレームワークかTest.loadData()でスタティックリソースを使い、テストデータ依存を減らすだろう。
今なら、マネージドパッケージの機能をカスタムApexから利用する際には、必ず「パッケージラッパー」や「パッケージサービス」といった、一枚薄いレイヤーを挟む設計にする。
// 当時のダメな設計(イメージ)
public class MyTriggerHandler {
public static void doSomething(List<Account> newAccounts) {
// 直接パッケージのクラスを呼び出す
// これは非常に危険なアンチパターンだった
for (Account acc : newAccounts) {
MyManagedPackage.SomeGlobalClass.processAccount(acc.Id);
}
}
}
「今なら別の選択をする」という点では、以下のようなアプローチを取る。
// 今ならこう設計する:パッケージサービス層を挟む
public interface IManagedPackageService {
void processAccount(Id accountId);
// 他のパッケージ依存メソッドもここに定義
}
public class MyManagedPackageServiceImpl implements IManagedPackageService {
public void processAccount(Id accountId) {
// パッケージのバージョンアップで呼び出し元が変わっても、
// この実装だけを修正すれば良いようにする
MyManagedPackage.SomeGlobalClass.processAccount(accountId);
}
}
// テスト用のモック実装
@IsTest
public class MyManagedPackageServiceMock implements IManagedPackageService {
public void processAccount(Id accountId) {
// テスト時はパッケージのロジックを呼ばずに、
// 期待する結果をモックする
System.debug('Mocked processAccount called for ' + accountId);
}
}
public class MyTriggerHandler {
// 依存性注入を考慮し、サービスを外部から渡せるようにする
private IManagedPackageService packageService;
public MyTriggerHandler() {
this(new MyManagedPackageServiceImpl()); // デフォルト実装
}
@TestVisible
private MyTriggerHandler(IManagedPackageService service) {
this.packageService = service;
}
public static void doSomething(List<Account> newAccounts) {
MyTriggerHandler handler = new MyTriggerHandler();
for (Account acc : newAccounts) {
handler.packageService.processAccount(acc.Id);
}
}
}
// テストクラスではMockを注入
@IsTest
private class MyTriggerHandlerTest {
@IsTest
static void testDoSomething() {
// Set up test data
List<Account> testAccounts = new List<Account>{
new Account(Name = 'Test Account 1'),
new Account(Name = 'Test Account 2')
};
insert testAccounts;
Test.startTest();
// Mockサービスを注入してテスト
MyTriggerHandler handler = new MyTriggerHandler(new MyManagedPackageServiceMock());
MyTriggerHandler.doSomething(testAccounts); // Staticメソッドなので、ここがネックになるが...
// 今ならdoSomethingをインスタンスメソッドにするか、
// ヘルパーメソッド経由で呼び出しをラップする
Test.stopTest();
// Assertions
// ...
}
}
上記のコードはあくまで概念的なものだが、このようにインターフェースと実装を分離し、パッケージ依存をサービス層に閉じ込めることで、パッケージ側の変更に対して頑健なシステムにできる。少なくとも、変更の影響範囲を最小限に抑えることができる。テスト時もMockを注入することで、パッケージ側のGovernor Limitや複雑な初期設定なしにテスト可能になる。
あの頃は、Governor Limitsに関しても、直接パッケージのメソッドを呼んでいるからといって、それが我々のコードの限界に直接的に影響を与えるという認識が薄かった。パッケージが内部で消費するSOQLクエリやDML操作も、最終的にはトランザクション全体のGovernor Limitsに加算されるわけだから、その影響も考慮すべきだった。
あの経験は、私にとってマネージドパッケージとの向き合い方を根本から見直すきっかけとなった。パッケージのglobalキーワードは、単に「呼び出せる」という意味だけでなく、「ベンダーがそのAPIの安定性を保証する意図がある」と捉えるべきだったし、その保証が崩れた時のリスクも常に意識すべきだった。これは当時の自分向けのメモだ。同じ轍を踏まないように。
コメント
コメントを投稿