Salesforce Platformでカスタムアプリ開発
Salesforce Platform開発ガイド:LWC・Apex・Flowで実現する設計・実装・DevOpsの要点
IDCのレポートでは、Salesforce経済圏は2026年までに世界で約930万人の雇用と1.6兆ドル超の新規ビジネスを創出するとされ、エンタープライズの基幹業務を担うプラットフォームとしての存在感は揺るぎません¹。AppExchangeの公開アプリは7,000件を超え、標準機能と拡張の組み合わせで多様なユースケースを吸収できることが実証されています²。とはいえ、要件が複雑化するほど、ローコード(設定中心)とプロコード(コードによる拡張)の接続点、ガバナ制御(プラットフォームの実行リソース上限)への適合、継続運用の生産性といった現実的な壁が姿を現します。ここでは中上級の開発リーダーに向けて、Salesforce Platformでのカスタムアプリ開発を設計から実装、性能最適化、DevOpsまで一気通貫で整理し、実装例と観測指標を用いて判断のよりどころを提示します。
戦略と設計:業務改善KPIから逆算するアーキテクチャ
業務改善の起点は、画面やオブジェクト構成ではなくビジネスKPIの明確化にあります。一次応答時間、案件成立までのリードタイム、在庫回転率、コンプライアンス逸脱率などの指標を最初に定義し、計測のためのイベント粒度とデータモデルを決めます。Salesforceでは標準オブジェクトで多くの情報は表現可能ですが、監査証跡や複合キー、一貫性の高い集計などはカスタムオブジェクトやBig Objects(大規模・履歴データ向けの永続ストア)、さらには外部システム連携の採用を検討します。重要なのは、ユーザー体験と運用性の間にあるトレードオフを可視化し、標準機能で賄う部分とApex/LWCで拡張する領域の境界を意図的に引くことです。
データモデルでは、参照関係の深さを抑えながらレポーティング要件を満たすスター型寄りの設計が有効です。シェアリングと権限制御はオブジェクトレベルとレコードレベルの双方で一貫性を保ち、“with sharing”(実行ユーザーの共有ルールを尊重してアクセス制御する)を既定とするサービス層でのアクセス制御を徹底します。UI層はLightning App BuilderやDynamic Formsで組み立て、トランザクション性が求められる処理はLWC+Apexに、ルール駆動で変更頻度の高いオートメーションはFlow(ノーコード/ローコードの自動化ツール)に寄せるのが現実的なバランスです⁶。
ROIの見立てとスコープ管理
内製化に舵を切るほど、短期のTTM(Time to Market)と長期のTCO(Total Cost of Ownership)の最適点が肝心です。初期はスコープを体験のコア(例えば案件登録〜承認〜請求連携)に絞り、拡張点をコンポーネント化しておくと、要件追加の費用対効果が読みやすくなります。運用での変更頻度が高い箇所はFlowやカスタムメタデータで外だしし、デプロイ不要の改修幅を広げるほど、運用チームの生産性は安定します。
セキュリティとコンプライアンスの基線
プロファイルではなくPermission Set(権限の付与単位)中心の設計、Field-Level SecurityとValidation Ruleの併用、機密項目の暗号化、イベント監査ログによるアクセス証跡の確保が基線です。クエリやDMLは”with sharing”とCRUD/FLSチェック(作成・参照・更新・削除権限と項目レベルの権限確認)を通過させ、監査可能なエラーハンドリングを実装します⁴。外部連携はNamed Credentials(接続先URLと認証をメタデータ化)とOAuthスコープで最小権限を貫き、シークレットをコードに持ち込まない方針を徹底します⁵。
実装パターン:LWC+Apex+Flowのハイブリッド
UIの反応性はLWC(Lightning Web Components)、ビジネスロジックはApex、変更頻度の高い分岐はFlowという役割分担が現場適合しやすい構成です。ここでは完全な実装例を複数示し、例外処理とガバナ制御を通した堅牢性に焦点を当てます。
Apexサービス層:選択的クエリと健全な例外処理
public with sharing class AccountService {
@AuraEnabled(cacheable=true)
public static List<Account> findByName(String keyword, Integer limitSize) {
try {
if (String.isBlank(keyword)) {
throw new AuraHandledException('keyword is required');
}
Integer l = limitSize == null ? 50 : Math.min(limitSize, 200);
// 選択的クエリ:索引化されやすい条件を優先
return [
SELECT Id, Name, Industry, Rating
FROM Account
WHERE Name LIKE :('%' + keyword + '%')
ORDER BY Name
LIMIT :l
];
} catch (AuraHandledException ahe) {
throw ahe;
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, e.getMessage());
throw new AuraHandledException('検索に失敗しました');
}
}
}
ユーザー可読なメッセージはAuraHandledExceptionで返し、詳細はプラットフォームログに集約します。@AuraEnabled(cacheable=true)により、同一パラメータの再読み込みはLDS(Lightning Data Service)キャッシュが効きます³。なお、大量データが前提の場合は、LIKEの両側ワイルドカードは選択性が低くなりやすいため、前方一致や補助インデックスを併用する設計が望ましいです。
LWC:非同期取得と入力値のバリデーション
import { LightningElement, track, wire } from 'lwc';
import findByName from '@salesforce/apex/AccountService.findByName';
export default class AccountLookup extends LightningElement {
@track keyword = '';
@track records = [];
@track error;
handleChange(e) {
this.keyword = e.target.value?.trim();
}
@wire(findByName, { keyword: '$keyword', limitSize: 50 })
wiredAccounts({ data, error }) {
if (data) {
this.records = data;
this.error = undefined;
} else if (error) {
this.error = error?.body?.message || 'エラーが発生しました';
this.records = [];
}
}
}
<template>
<lightning-card title='Account Lookup'>
<div class='slds-p-around_small'>
<lightning-input label='Keyword' value={keyword} onchange={handleChange}></lightning-input>
<template if:true={records}>
<lightning-datatable key-field='id' data={records}
columns='[{"label":"Name","fieldName":"Name"},{"label":"Industry","fieldName":"Industry"}]'></lightning-datatable>
</template>
<template if:true={error}>
<div class='slds-text-color_error'>{error}</div>
</template>
</div>
</lightning-card>
</template>
UI側では入力トリムによる無駄なコール削減と、wireの標準キャッシュを活用します。レイテンシの体感を損ねる要因はDOMレンダリングにもあるため、行数に制限をかけ、ページネーションや仮想スクロールへの切替を検討します。
トリガパターン:ハンドラ分離と一括対応
trigger OpportunityTrigger on Opportunity (before insert, before update, after insert) {
if (Trigger.isBefore && Trigger.isInsert) {
OpportunityTriggerHandler.beforeInsert(Trigger.new);
}
if (Trigger.isBefore && Trigger.isUpdate) {
OpportunityTriggerHandler.beforeUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isAfter && Trigger.isInsert) {
OpportunityTriggerHandler.afterInsert(Trigger.new);
}
}
public with sharing class OpportunityTriggerHandler {
public static void beforeInsert(List<Opportunity> newList) {
for (Opportunity o : newList) {
if (o.CloseDate == null) {
o.CloseDate = System.today().addDays(30);
}
}
}
public static void beforeUpdate(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
// 差分に基づき最小限のDMLや通知を行う
}
public static void afterInsert(List<Opportunity> newList) {
// 非同期へ逃がす処理はQueueableへ委譲
System.enqueueJob(new OpportunityNotifier(newList));
}
}
Queueable+外部連携:Named Credentialsで安全な呼び出し
public with sharing class OpportunityNotifier implements Queueable, Database.AllowsCallouts {
private List<Opportunity> opps;
public OpportunityNotifier(List<Opportunity> opps){ this.opps = opps.deepClone(); }
public void execute(QueueableContext ctx) {
Http h = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:MyWebhook/opportunities');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(10000);
req.setBody(JSON.serialize(opps));
try {
HttpResponse res = h.send(req);
if (res.getStatusCode() >= 300) {
throw new CalloutException('Failed with status ' + res.getStatus());
}
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Webhook error: ' + e.getMessage());
}
}
}
Named Credentialsにより、エンドポイントと認証情報はメタデータに委ねられ、コードからシークレットが排除されます。Webhookの到達保証が重要な場合はPlatform Eventsや外部の再試行基盤を併用し、少なくとも一度はDeliverされる経路を確保する設計が検討に値します⁵。
テスト容易性:SeeAllData=falseと観測可能性
@IsTest
private class AccountServiceTest {
@IsTest static void findByName_returnsRecords() {
Account a = new Account(Name='Acme Test');
insert a;
Test.startTest();
List<Account> result = AccountService.findByName('Acme', 10);
Test.stopTest();
System.assertEquals(1, result.size());
}
@IsTest static void findByName_throwsOnBlank() {
try {
AccountService.findByName('', 10);
System.assert(false, 'should throw');
} catch (AuraHandledException e) {
System.assert(e.getMessage().contains('keyword'));
}
}
}
データ分離と観測可能性を保つため、SeeAllData=falseの原則を守り、テストは意図した副作用のみを検証します。非同期処理はTest.startTest/stopTestでキューをドレインし、リトライやエラー分岐を含めて網羅率を高めます。
パフォーマンス最適化:ガバナ制御とUXの両立
プラットフォームのガバナ制御は制約ではなく設計の指針です。SOQLクエリ数、DML行数、ヒープ、CPU時間といった上限を定数と捉え、最小のクエリで最大の情報を取り出す設計へ寄せます。選択的クエリのためには索引化フィールドの利用とフィルタ選択性の向上が要点で、Query Planツール(実行計画のコスト確認)でコストを確認しながら条件式を見直します⁷。
選択的クエリの例とQuery Planの活用
List<Account> jp = [
SELECT Id, Name, Industry
FROM Account
WHERE BillingCountry = 'Japan' AND Name LIKE 'A%'
ORDER BY Name
LIMIT 200
];
国コードなどの高選択性フィールドを先頭に置き、LIKEは前方一致を基本とします。Query Planでコストが高い場合は結合順序や追加インデックスの申請を検討します。大量処理が前提の集計はAggregate SOQLやReporting Snapshot、あるいは外部DWHへのオフロードを前提に設計すると、CPU時間の安定化に寄与します⁷。
軽量ベンチの観測と目安SLO
public with sharing class Bench {
@AuraEnabled(cacheable=true)
public static Map<String, Integer> measure() {
Integer cpuBefore = Limits.getCpuTime();
List<Account> xs = [SELECT Id, Name FROM Account WHERE BillingCountry = 'Japan' ORDER BY Name LIMIT 200];
Integer cpuAfter = Limits.getCpuTime();
return new Map<String, Integer>{
'cpu' => (cpuAfter - cpuBefore),
'soql' => Limits.getQueries()
};
}
}
開発用Scratch Orgで、索引化フィルタのない後方一致LIKEを前方一致+国コード条件に置き換えると、同一データボリュームでもCPU時間の観測値が改善するケースがあります。UIトランザクションでは、初回描画はサブ秒台、二回目以降はLDSキャッシュ併用でさらに短縮されることを目安に、軽量ベンチをリリース前後で継続計測し、閾値逸脱時に検知できるようにしておくと効果的です³⁷。
運用・DevOps:変更を日常化するための仕組み
運用で磨く開発には、環境戦略とパッケージ戦略が欠かせません。Scratch Orgとソース駆動開発を基本とし、Unlocked PackagesやDevOps Centerを採用すれば、差分の見える化とロールバック容易性が向上します。レビューはPull Requestベースで行い、ユニットテストとメタデータ検証を自動化します。外部秘匿情報はSecretsに集約し、デプロイ先ごとにNamed Credentialsやカスタムメタデータで設定値を切り替えます⁵。
GitHub ActionsでのCI/CD一例
name: deploy
on: [push]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: salesforcecli/action-setup-sf@v2
- name: Auth to target org
run: sf org login jwt --username ${{ secrets.SF_USERNAME }} --jwt-key-file ./server.key --client-id ${{ secrets.SF_CLIENT_ID }} --instance-url ${{ secrets.SF_INSTANCE_URL }} --set-default
- name: Install packages
run: sf package install --package <PACKAGE_ID> --wait 10 --publish-wait 10
- name: Run tests
run: sf apex run test --result-format human --wait 10 --code-coverage
- name: Deploy metadata
run: sf project deploy start --ignore-warnings
自動化の目的は失敗をゼロにすることではなく、失敗を早く・安く・安全にできる状態を作ることです。テレメトリとしてデプロイ頻度、変更失敗率、平均復旧時間、リードタイムを常時観測し、変動要因をチームで言語化していく取り組みが、カスタムアプリの寿命を伸ばします。GitHub ActionsとSalesforce DX(CLI)を組み合わせると、標準的なCI/CDパイプラインをコードで再現しやすく、継続的な品質保証に寄与します⁸。
まとめ:業務価値に最短距離で到達するために
Salesforce Platformの強みは、標準と拡張の接点にあります。KPIから逆算したデータモデルと権限制御を基盤に、UIはLWC、ロジックはApex、運用はFlowやメタデータで調整するという責務分担が、速度と安定の両立をもたらします。選択的クエリとキャッシュで体感速度を底上げし、軽量ベンチで変化を可視化し続ければ、改善の手は止まりません。次の一歩として、現場の主要ユースケースを一つだけ選び、本文の実装パターンを骨格に小さなリリースを設計してみてください。数週間後に測定されるKPIの変化は、次の設計判断の確かな材料になり得ます。
参考文献
- IDC: Salesforce Economy 2021 Press Release (2021-09-20). https://www.salesforce.com/news/press-releases/2021/09/20/idc-salesforce-economy-2021/
- AppExchange: 10 Million Installs — Salesforce Blog. https://www.salesforce.com/blog/appexchange-10-million-installs/
- Apex Result Caching for Lightning Web Components — Salesforce Developers. https://developer.salesforce.com/docs/platform/lwc/guide/apex-result-caching
- Field-Level Security in Apex — Salesforce Developers Blog. https://www.salesforce.com/blog/learn-moar-in-spring-20-with-field-level-security-in-apex/
- Protect Secrets Using Platform Features (Named Credentials, etc.) — Trailhead. https://trailhead.salesforce.com/content/learn/modules/secure-secrets-storage/protect-secrets-using-platform-features
- Use Lightning App Builder with LWC — Salesforce Developers. https://developer.salesforce.com/docs/platform/lwc/guide/use-app-builder
- Writing Efficient Queries (includes Query Plan usage) — Trailhead. https://trailhead.salesforce.com/content/learn/modules/database_basics_dotnet/writing_efficient_queries
- Using Salesforce DX with GitHub Actions — Salesforce Developers Blog. https://developer.salesforce.com/blogs/2020/01/using-salesforce-dx-with-github-actions