Handler-Usecase Architecture

Apex Stem ドキュメント
Apex StemHandler-Usecase ArchitectureArchitectureSalesforceApex
Apex Stem の中核アーキテクチャ。なぜ 2 層なのか、Handler と Usecase それぞれの責務、部品クラスの扱いを通して解説します。

このドキュメントは、Apex Stem の中核となる Handler-Usecase Architecture を、設計思想から構造まで掘り下げて解説します。Apex Stem 導入ガイド で手を動かした後、「Handler や Usecase とは結局何なのか」を理解するための一冊です。

Handler-Usecase Architecture は、Salesforce Apex 開発のための軽量なアプリケーションアーキテクチャです。すべての Apex 処理を Handler (エントリーポイント) と Usecase (ビジネスロジック) の 2 層で設計します。

Handler-Usecase Architecture とは

fflib のような重厚な多層アーキテクチャでもなく、すべてをトリガーやクラスにベタ書きする混沌でもない、第三の道です。

5 年間運用された Salesforce 組織のカオスを立て直す過程で考案され、実証されました。規約として覚えることは「幹となる 2 つの層 (Handler と Usecase)」と「オブジェクト指向の基本原則」だけ。それ以外は現場に委ねます。

なぜ 2 層なのか

Laravel-MVC 派の系譜

Web フレームワークの Laravel は、Controller・Model・View だけを規定し、Service や Action、Repository といった部品はコミュニティの自由に任せています。Rails も同様です。

Handler-Usecase Architecture もこの系譜に立ちます。Handler と Usecase が幹であり、その下に現れる部品 (Reader / Validator / Mapper など) のカテゴリは規定しません。Selector / Domain / Service / UnitOfWork をすべて規定する fflib とは、ちょうど反対側の思想です。

「規定しすぎない」と定めた理由

  1. 業務によって必要な部品が違う。Reader が要る業務もあれば、Validator が要る業務もあります。あらかじめ全部のカテゴリを決めても、使われないものが出ます。
  2. 規定すると形骸化する。5 年運用すると「Selector という名前なのに中身はベタ書き」のような、名ばかりの構造が必ず生まれます。
  3. ジュニアの成長機会になる。「Handler から何を呼ぶか」「どこで部品を切り出すか」を考えること自体が、オブジェクト指向の設計力の訓練になります。
  4. AI 連携時に過剰な規約はノイズになる。AI コーディングアシスタントに「12 個のパターンを守れ」と要求するより、「幹 2 つ + OOP 原則」の方がブレません。

ミノ駆動本との接続

『良いコード/悪いコードで学ぶ設計入門』(ミノ駆動本) で言う「生焼けオブジェクトを作らない」が、Usecase の核です。コンストラクタで必要なものをすべて受け取り、setter で後から状態を足さない。これは Effective Java や Domain-Driven Design の Value Object 思想の Apex 版です。

Handler 層

Handler の責務

Handler の責務は、エントリーポイント固有の作法を吸収し、Usecase に渡すことだけです。ここにビジネスロジックは書きません。対象レコードの絞り込み (フィルタ) は許容されますが、それ以上のことはしません。Laravel における Controller に近い役割です。

5 種のエントリーポイント

Salesforce の Apex には複数のエントリーポイントがあり、それぞれに Handler を用意します。

種類エントリ元
TriggerHandlersDML トリガー (before/after × insert/update/delete)
BatchHandlersBatchable / Schedulable
RestHandlers@RestResource
FlowHandlers@InvocableMethod (Flow から呼ばれる)
SchedulableHandlerspure Schedulable

Trigger.cls の固定パターン

トリガーファイルは Salesforce の慣例に従い、書き方を固定します。7 つのイベントをすべて宣言し、Handler を 1 行で呼ぶだけ。ロジックはトリガーファイルに書きません。

APEX
trigger Opportunity on Opportunity(
  before insert,
  before update,
  before delete,
  after insert,
  after update,
  after delete,
  after undelete
) {
  (new TriggerOppHandler()).execute();
}

TriggerHandler 基底クラス

各 Handler は TriggerHandler 基底クラス (ApexTools が提供) を継承し、必要なフックだけを override します。すべてのフックは protected virtual で、override しないフックは何もしません。

主なフックは beforeInsert / beforeUpdate / beforeDelete / afterInsert / afterUpdate / afterDelete / afterUndelete、そして必ず最後に呼ばれる andFinally です。加えて「特定フィールドが変更されたレコードだけを絞り込む」ヘルパー (getUpdateRecordIdsWithChangedFields など) も提供されます。

コード例

ここでは Apex Stem 導入ガイド で扱った CopyAccountIndustryToOpportunityUsecase (商談に親取引先の業種をコピーする Usecase) を、Trigger から呼び出してみます。

APEX
public with sharing class TriggerOppHandler extends TriggerHandler {
  protected override void afterInsert(Map<Id, SObject> newRecordsMap) {
    Set<Id> opportunityIds = newRecordsMap.keySet();
    (new CopyAccountIndustryToOpportunityUsecase(opportunityIds)).invoke();
  }
}

Handler がしているのは「Trigger.new から商談 ID を集めて、Usecase に渡す」ことだけです。業種をコピーするロジックは一切ここにありません。それは Usecase の仕事です。

特定フィールドの変更時だけ Usecase を呼びたい場合は、基底クラスのヘルパーで絞り込みます。

APEX
protected override void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
  Set<Id> needIds = this.getUpdateRecordIdsWithChangedField(Opportunity.AccountId);
  (new CopyAccountIndustryToOpportunityUsecase(needIds)).invoke();
}

Usecase 層

Usecase の責務

Usecase は、単一の業務ロジックを実装します。public なメソッドは invoke() ただ 1 つ。それ以外はすべて private です。invoke() の戻り値の型は処理の性質に応じて選びます (LWC から呼ばれる Usecase は Result DTO、Trigger や Batch 駆動の多くは void など)。Laravel における Service や Action に近い役割です。

生焼けオブジェクトを作らない

Usecase は、コンストラクタで必要な依存をすべて受け取ります。setter で後から状態を足すことはしません。「コンストラクタを呼んだ時点で、その Usecase は完成している」状態を保ちます。

2 つのコンストラクタ

Usecase には 2 つのコンストラクタを用意します。

  • public コンストラクタ。本番用。業務に必要な入力だけを受け取り、データアクセスなどの依存はデフォルト生成します。
  • @TestVisible private コンストラクタ。テスト用。依存を引数で受け取り、テスト時にモックを注入できるようにします。
APEX
public with sharing class CopyAccountIndustryToOpportunityUsecase {
  @TestVisible static final String LBL_FETCH = 'oppFetch';
  @TestVisible static final String LBL_UPDATE = 'oppUpdate';
 
  private final Set<Id> opportunityIds;
  private final IEloquent eloquent;
  private Trace t = Trace.of('商談に親取引先の業種をコピー');
 
  // public: 本番用、業務入力だけを受け取る
  public CopyAccountIndustryToOpportunityUsecase(Set<Id> opportunityIds) {
    this(opportunityIds, null);
  }
 
  // private (@TestVisible): テストで IEloquent を注入
  @TestVisible
  private CopyAccountIndustryToOpportunityUsecase(
    Set<Id> opportunityIds,
    IEloquent eloquent
  ) {
    this.opportunityIds = opportunityIds;
    this.eloquent = eloquent ?? new Eloquent();
  }
 
  public void invoke() {
    // ... ビジネスロジック (導入ガイドのステップ 3 に全文) ...
  }
}

この「本番用とテスト用でコンストラクタを分ける」やり方を Layered Constructor Pattern と呼びます。詳しくは Layered Constructor Pattern を参照してください。手を動かす具体例は 導入ガイドのステップ 3 にあります。

なお、Salesforce 公式が示すリファクタリング例も、まったく同じ形のコンストラクタになっています (後述の「Salesforce 公式の推奨と重なる」を参照)。

単体 Usecase と オーケストレーター Usecase

Usecase には 2 つの形態があります。

  • 単体 Usecase。private メソッドだけで完結する、小規模なロジック。上の CopyAccountIndustryToOpportunityUsecase がこれにあたります。
  • オーケストレーター Usecase。複数の部品クラス (Reader や Validator、あるいは別の Usecase) を統合する、大規模なロジック。

どちらも「public は invoke() のみ」「生焼けオブジェクトを作らない」という原則は同じです。

部品クラスの扱い

Handler-Usecase Architecture は、Usecase の下に現れる部品クラスのカテゴリを規定しません

業務によって現れる部品の性質はバラバラです。「Reader / Validator / Mapper を必ず作れ」とは言いません。ある業務では Reader が要り、別の業務では Validator が要る。それは現場が判断します。

共通するルールはひとつだけです。部品クラスも Usecase と同じく「生焼けオブジェクトを作らない」原則に従います。コンストラクタで必要なものをすべて受け取り、完成した状態でしか存在できないオブジェクトにします。

切り出し方は段階的でかまいません。最初は Usecase 内の private メソッドとして書き、複雑になってきたら独立したクラスに切り出す。最初から部品を作りすぎないことが大事です。

テスト戦略 (概要)

Handler-Usecase Architecture の 2 層は、2 つのテスト戦略と 1 対 1 で対応します。

テスト種別DB アクセステスト OSS
Usecase 層単体テストなし (モック)ApexEloquent (MockEloquent / MockEntry)
Handler 層結合テストあり (実 DML)ApexBlueprint (SBlueprint / SOrchestrator)

ロジックの網羅は Usecase の単体テストで行います。Handler の結合テストは「Trigger や Batch を経由して Usecase が正しく呼ばれ、期待通り動くか」の代表ケースを 1 件から 3 件に絞ります。

テスト戦略の詳細は テスト戦略 を参照してください。手を動かす具体例は 導入ガイドのステップ 4 にあります。

Salesforce 公式の推奨と重なる

ここまでの設計は、痛みから経験的にたどり着いたものです。後から知ったことですが、Salesforce 公式も同じ設計を推奨しています

関心の分離

公式ブログ Reduce Deployment Test Time with Smarter Apex Test Runs より。

business logic and database interfacing should be separate concerns

(日本語訳) ビジネスロジックと DB インターフェースは、別々の関心事であるべきだ

そして公式が示すリファクタリングの手順は、そのまま Handler-Usecase の手順になっています。DB アクセスを別クラスに切り出し、インターフェースを抽象化し、DI で差し替え可能にし、テストではモック実装を注入する。

コンストラクタの形も同じ

公式記事のサンプルコードです。

APEX
public class OpportunityService {
    private OpportunityServiceDbHandler dbHandler;
 
    public OpportunityService() {
        this(new OpportunityServiceDbHandlerImpl());   // 本番用
    }
 
    @TestVisible private OpportunityService(
            OpportunityServiceDbHandler dbHandler) {   // テスト用 (DI)
        this.dbHandler = dbHandler;
    }
}

上で説明した Layered Constructor Pattern と、構造が一致します。public コンストラクタが本番用で内部に委譲し、@TestVisible private がテスト用に依存を受け取る。同じ問題に対する、同じ答えです。

二層構成

テストの分け方についても、公式はこう書いています。

The vast majority of your tests should be true unit tests.

(日本語訳) テストの大部分は、真の単体テストであるべきだ

Testing triggers, for example, requires real DML execution, as there is no substitute for validating the execution order.

Such tests aren't unit tests; they're integration or functional tests. Use them sparingly: only when you need to test a trigger or a particularly important or complex integration flow.

(日本語訳) たとえばトリガのテストは、実際の DML 実行を必要とする。実行順序を検証する手段は他にないからだ。そうしたテストは単体テストではなく、結合テストや機能テストである。控えめに使うこと。トリガのテストが必要なとき、あるいは特に重要・複雑な統合フローのテストが必要なときに限る。

「単体が大部分」「トリガは実 DML が必要」「結合は控えめに」。上のテスト戦略の表と同じことを言っています。

公式と違うところ

公式のやり方は正しいのですが、サービスごとに DbHandler クラスとインターフェースとモック実装を手書きすることになります。Usecase が 50 個あれば、その 3 倍のクラスが付いてきます。SOQL の組み立ても、テストデータの依存解決も、毎回自分で書くことになります。

ApexEloquent がやったのは、この DbHandler を汎用化して 1 つにまとめたことです。

公式のパターンApexEloquent
DB アクセスの抽象サービスごとに interface を手書きIEloquent 1 つ
本番実装サービスごとに Impl を手書きEloquent 1 つ
モックサービスごとに Mock を手書きMockEloquent 1 つ
クエリ組み立て生 SOQL を手書きScribe (ビルダー)
テストデータ手組みMockEntry / ApexBlueprint

発明ではありません。公式が示した設計を、毎回手書きしなくて済むように汎用化しただけです。

逆に言えば、この設計を採るのに特定の OSS は必須ではありません。公式のやり方で手書きしてもよいし、fflib でも Apex Fluently でも構いません。Apex Stem が 4 つの OSS を組み合わせているのは、それらが Handler-Usecase のテスト戦略に直接整合するからです。

他フレームワークとの立て分け

fflib との立て分け

Handler-Usecase Architecture は fflib を否定するものではありません。シーンが違えば、選ぶものも違います。

観点fflibHandler-Usecase Architecture
哲学規定主義 (Java EE / Spring 派)最小骨格 (Laravel / Rails 派)
必須概念Selector, Domain, Service, UnitOfWorkHandler, Usecase
部品の規定ありなし (現場判断)
既存組織への導入リライト前提1 メソッドずつ侵食可能
AI 連携規約が多く AI が迷いやすい規約 2 個 + 原則で AI に伝えやすい
向いている場面50 人以上の大規模チームで統一したいとき1 から 5 人で、スピードと AI 連携を優先したいとき

Andy Fawcett 氏が築いた fflib の業績には敬意を払います。そのうえで、Handler-Usecase Architecture は「別の場面のための、別の選択肢」です。


Apex Fluently との関係

近年、もう一つの選択肢として Apex Fluently (Beyond The Cloud) があります。SOQL Lib、DML Lib、Async Lib、Cache Manager など 8 つのライブラリを集めた「現代的な fflib の代替」を掲げる OSS 群です。

ただ、Apex Fluently は 意図的にアーキテクチャを規定しません。アプリケーションの層分けや責務の分け方には踏み込まず、ライブラリを個別に採用できる「道具集」として設計されています。

つまり、Apex Fluently と Handler-Usecase Architecture は、そもそも比べる土俵が違います。3 つを位置取りで見るとこうなります。

  • fflib: 重厚なアーキテクチャ (Selector / Domain / Service / UnitOfWork)
  • Apex Fluently: 純粋な道具 (アーキテクチャは規定しない)
  • Handler-Usecase Architecture: 最小のアーキテクチャ (Handler + Usecase)

Apex Stem は、Handler-Usecase Architecture (アーキテクチャ) と 4 つの OSS (道具) の両方を提供します。Apex Fluently は Apex Stem の OSS 層 (ApexEloquent 等) と同じレイヤーにいて、Handler-Usecase Architecture と直接競合する関係ではありません。

そして Handler-Usecase Architecture は道具非依存です。理屈の上では、Apex Fluently のライブラリを Handler 層や Usecase 層の下で使うこともできます。Apex Stem が ApexEloquent / ApexBlueprint / ApexTrace / ApexTools を組み合わせるのは、それらが Handler-Usecase Architecture のテスト戦略 (Usecase ↔ MockEloquent、Handler ↔ ApexBlueprint) に直接整合するからで、アーキテクチャがその組み合わせを強制しているわけではありません。

AI 時代における位置づけ

先に、順序をはっきりさせておきます。

責務を分けている理由は、DI するため、つまりテスト可能にするためです。AI のコンテキストに収めるためではありません。 責務で切ったら、結果として変更に必要なコンテキストも小さくなり、AI にも都合が良かった。この順序です。

この区別には実利があります。根拠の寿命が違うからです。

分割の動機AI の性能が上がったら
AI のコンテキストに収めるため根拠が薄れる (収まるようになるので)
DI・テスト可能性のため根拠は残る (無限のコンテキストがあっても、DB に触らない速く決定的なテストは欲しい)

つまり Handler-Usecase Architecture は、「AI が賢くなったら要らなくなる設計」ではありません。土台は AI の性能と独立しています。

そのうえで、AI にはよく効きます

規約が薄いことには、はっきりした実利があります。Claude Code のような AI コーディングアシスタントには、プロジェクトのルールを記したファイル (CLAUDE.md) を読み込ませます。fflib の規約をこのファイルに書こうとすると 200 行を超え、例示も必要になり、AI が迷うリスクが上がります。

一方 Handler-Usecase Architecture なら、アーキテクチャの核心は次のような短い記述で AI に伝わります。実際に、この内容を CLAUDE.md に置くことで、Claude Code がこのアーキテクチャに沿って Apex を書いてくれています。

MARKDOWN
## アーキテクチャ (Handler + Usecase の 2 段構え)
 
すべての Apex 処理は Handler (エントリーポイント) + Usecase
(ビジネスロジック) の 2 段構えで設計する。
 
### 責務分離
- Handler: エントリーポイントから受け取った引数を解釈し、
  適切な Usecase を呼び出す。ビジネスロジックは持たない。
- Usecase: 単一の業務ロジックを実装する。コンストラクタで
  パラメータを受け取り、public は invoke() のみ。
 
### Handler の実装パターン
Handler は「条件判定」と「Usecase 呼び出し」だけを行う。
Trigger ファイルは 7 イベントを宣言し、Handler を 1 行で呼ぶ。
 
### Usecase の標準パターン
1. 2 つのコンストラクタ (本番用 public / テスト用 @TestVisible private)
2. public は invoke() のみ、他はすべて private
3. 依存は null-coalescing で本番デフォルト (eloquent ?? new Eloquent())
 
### テスト戦略
- Usecase の単体テスト: MockEloquent でロジック分岐を網羅
- Handler の結合テスト: 実 DML で代表ケースを 1〜3 本

上の記述は 23 行です。これは机上の主張ではありません。KrileWorks 自身が実プロジェクトでこの記述を使い、AI と協働してコードを書いています。「短い規約で、AI がアーキテクチャを守る」が実証されているわけです。

23 行が指しているのは「アーキテクチャ」だけです

誤解を避けるために、はっきりさせておきます。実際のプロジェクトで AI に渡している規約は、23 行では終わりません。KrileWorks が公開している apex-architecture スキル は 393 行あります。

矛盾しているように見えますが、中身を分けると理由が分かります。

内訳行数中身
アーキテクチャの定義23上のスニペット。2 層の責務 / Usecase の形 / テストの棲み分け
+ コード例146同じことを Trigger と Usecase の実物で示した版
+ ハウスルールと罠393ディレクトリ構成 / 命名規則と 40 字制限 / Constants / __ 連続の罠 / 共通化の判断基準

増えた 216 行はアーキテクチャではありません。 「クラスをどこに置くか」「何という名前にするか」「Salesforce のどこで転ぶか」であって、どのアーキテクチャを選んでも別途必要になるものです。fflib を採用しても、この 216 行は消えません。

つまりこういうことです。

  • アーキテクチャを伝えるコストは薄い — ここが Handler-Usecase の利点で、23 行という数字はその主張
  • プロジェクトを運用するコストは厚い — こちらはアーキテクチャの選択とは独立にかかる

導入時に必要なのは前者だけです。23 行を CLAUDE.md に置けば、AI はこの形でコードを書き始めます。後者は、プロジェクトの規約が固まってきてから足していけば足ります。

📦 実プロジェクトで使っている完全版が要る場合は、Agent Skills から取得できます。

URL を AI に渡して「この内容を取り込んで」と言うだけで使えます。

ただし、粒度については留保があります

「分ける理由」は当分変わらないと考えていますが、どこまで細かく分けるかの粒度は別の話です。

「1 Usecase = 1 業務ロジック」という細かさまでが最適だと主張できる根拠は、いまのところ持っていません。細かく切ることにはコストもあります (クラス数が増える、全体像が追いにくくなる)。AI の性能が上がれば、もっと粗い粒度で十分という結論になる可能性は普通にあります。

ここに書いているのは、2026 年時点で妥当だと考えている粒度です。

次に読む