関数とクラス
入力から出力を得る処理は、まず関数として実装します。状態や依存関係の共有が設計上の意味を持つ場合にクラスを選びます。
関数を選ぶ場合
- 計算、変換、判定である
- 一つの自己完結した操作である
- 必要な依存関係を引数で明確に渡せる
- 状態を保持せず、同じ入力から同じ結果を得られる
純粋関数はテストしやすく、利用箇所から依存関係を追いやすくなります。関数を名前空間としてまとめるためだけのstaticクラスは作りません。
クラスを選ぶ場合
- 同じ設定や注入された依存関係を、複数の関連操作で共有する
- リクエスト単位のContextや、プロジェクトで統一したユースケースの実行形式を保持する
- 同一性を持つドメインオブジェクトが、不変条件と振る舞いを管理する
- ライフサイクルを持つ資源を安全に扱う
Application層で、一つの操作をexecuteするクラスへリクエスト単位の依存関係を渡す形式をプロジェクト全体で採用する場合があります。この形式は、呼び出し方、認可、エラー変換の境界を揃えるために使います。それ以外で、メソッドが一つだけで状態も依存関係も保持しない処理は、通常は関数で十分です。クラスを導入する場合も、継承よりコンポジションを優先します。
const calculateTotal = (items: readonly Item[]) =>
items.reduce((total, item) => total + item.price, 0)
class CatalogClient {
constructor(
private readonly baseUrl: URL,
private readonly fetcher: typeof fetch,
) {}
findProduct(id: string) {
return this.fetcher(new URL(`/products/${id}`, this.baseUrl))
}
}
不変性
公開する値はreadonlyを基本とし、更新が必要なら新しい値を返します。ただしTypeScriptのreadonlyはコンパイル時の制約、Object.freezeは浅い実行時制約です。深い不変性を保証したものとして扱いません。
配列やオブジェクトを外部から受け取る場合は、必要に応じてコピーし、読み取り専用の型で公開します。変更可能な内部状態を持つ場合は、その更新経路をクラス内へ閉じ込めます。
判断基準
「クラスで書けるか」ではなく、状態や依存関係を保持するか、またはプロジェクトで統一したユースケース境界に参加するかを確認します。どちらにも該当しなければ関数から始め、実際の利用箇所がクラスの境界を示してから変更します。