コードベース設計
ディープモジュールを設計する:小さなインターフェースの背後に多くの振る舞いを持ち、クリーンなシームに配置され、そのインターフェースを通じてテスト可能なモジュール。コードを設計または再構成するあらゆる場面で、この言語と原則を使用してください。目的は、呼び出し側にとってのレバレッジ、保守者にとっての局所性、そして全員にとってのテスト容易性です。
用語集
以下の用語を正確に使用してください。「コンポーネント」「サービス」「API」「境界」などの言葉で代用しないでください。一貫した言語がすべての要点です。
Module(モジュール):インターフェースと実装を持つあらゆるもの。意図的にスケールにとらわれません:関数、クラス、パッケージ、または階層をまたがるスライス。避けること:ユニット、コンポーネント、サービス。
Interface(インターフェース):呼び出し側がモジュールを正しく使用するために知る必要があるすべて:型シグネチャだけでなく、不変条件、順序制約、エラーモード、必要な設定、パフォーマンス特性。避けること:API、シグネチャ(狭すぎる、型レベルの表面のみを指す)。
Implementation(実装):モジュールの内部にあるもの、そのコードの本体。「アダプタ」とは区別します。あるものは小さなアダプタで大きな実装を持つ(PostgreSQLリポジトリ)ことも、大きなアダプタで小さな実装を持つ(インメモリフェイク)こともあります。シームが話題のときは「アダプタ」を使い、それ以外は「実装」を使います。
Depth(深さ):インターフェースにおけるレバレッジ。呼び出し側(またはテスト)が学習しなければならないインターフェースの単位あたりに実行できる振る舞いの量。モジュールが深いとは、小さなインターフェースの背後に大量の振る舞いがある場合であり、浅いとは、インターフェースが実装とほぼ同じくらい複雑な場合です。
Seam(シーム)(マイケル・フェザーズ):その場所で編集せずに振る舞いを変更できる場所。モジュールのインターフェースが存在する位置。シームをどこに置くかは、その背後に何を置くかとは別の設計上の判断です。避けること:境界(DDDの境界づけられたコンテキストと重複する)。
Adapter(アダプタ):シームでインターフェースを満たす具体的なもの。役割(どのスロットを埋めるか)を記述し、実体(内部にあるもの)は記述しません。
Leverage(レバレッジ):深さから呼び出し側が得るもの。インターフェースの学習単位あたりの能力が増加します。1つの実装がN個の呼び出しサイトとM個のテストで償却されます。
Locality(局所性):深さから保守者が得るもの。変更、バグ、知識、検証が、呼び出し側に分散するのではなく、一箇所に集中します。一度修正すれば、どこでも修正されます。
深い vs 浅い
ディープモジュール = 小さなインターフェース + 多くの実装:
┌─────────────────────┐
│ 小さなインターフェース │ ← 少数のメソッド、単純なパラメータ
├─────────────────────┤
│ │
│ 深い実装 │ ← 複雑なロジックが隠されている
│ │
└─────────────────────┘
浅いモジュール = 大きなインターフェース + 少ない実装(避けるべき):
┌─────────────────────────────────┐
│ 大きなインターフェース │ ← 多数のメソッド、複雑なパラメータ
├─────────────────────────────────┤
│ 薄い実装 │ ← 単なるパススルー
└─────────────────────────────────┘
インターフェースを設計するときは、自問してください:
- メソッドの数を減らせるか?
- パラメータを単純化できるか?
- より多くの複雑さを内部に隠せるか?
原則
- 深さはインターフェースの特性であり、実装の特性ではない。 深いモジュールは内部で小さなモック可能・交換可能な部品から構成されていても構いません。それらは単にインターフェースの一部ではないだけです。モジュールは、内部シーム(実装にプライベートで、自身のテストで使用される)と、インターフェースにある外部シームの両方を持つことができます。
- 削除テスト。 モジュールを削除することを想像してください。複雑さが消えるなら、それはパススルーでした。複雑さがN個の呼び出し側に再出現するなら、それは存在価値がありました。
- インターフェースはテスト表面である。 呼び出し側とテストは同じシームを横断します。インターフェースを超えてテストしたい場合、おそらくモジュールの形状が間違っています。
- 1つのアダプタは仮説上のシームを意味する。2つのアダプタは現実のシームを意味する。 実際に何かがそのシームをまたいで変化しない限り、シームを導入しないでください。
テスト容易性のための設計
良いインターフェースはテストを自然にします:
-
依存関係を受け入れ、作成しない。
// テスト可能 function processOrder(order, paymentGateway) {} // テスト困難 function processOrder(order) { const gateway = new StripeGateway(); } -
結果を返し、副作用を生み出さない。
// テスト可能 function calculateDiscount(cart): Discount {} // テスト困難 function applyDiscount(cart): void { cart.total -= discount; } -
表面積を小さく保つ。 メソッドが少ない=必要なテストが少ない。パラメータが少ない=テストのセットアップが単純。
関係性
- Moduleは正確に1つのInterfaceを持つ(呼び出し側とテストに提示する表面)。
- DepthはModuleの特性であり、そのInterfaceに対して測られる。
- SeamはModuleのInterfaceが存在する場所である。
- AdapterはSeamに位置し、Interfaceを満たす。
- Depthは呼び出し側にLeverageを、保守者にLocalityをもたらす。
却下されたフレーミング
- 深さを実装行数とインターフェース行数の比率とする(Ousterhout):実装を膨らませるインセンティブを与える。代わりにレバレッジとしての深さを使用します。
- 「インターフェース」をTypeScriptの
interfaceキーワードやクラスのパブリックメソッドとみなす:狭すぎる。ここでのインターフェースには、呼び出し側が知る必要のあるすべての事実が含まれます。 - 「境界」:DDDの境界づけられたコンテキストと重複する。シームまたはインターフェースと言いましょう。
さらに深く
- 依存関係を考慮したクラスターの深化、DEEPENING.mdを参照:依存関係のカテゴリ、シームの規律、置き換え・階層化しないテスト。
- 代替インターフェースの探索、DESIGN-IT-TWICE.mdを参照:並列サブエージェントを起動してインターフェースを根本的に異なる複数の方法で設計し、深さ、局所性、シーム配置を比較する。