コードベース設計
深いモジュールを設計する: 小さなインターフェースの裏に大量の振る舞いを持ち、クリーンなシームに配置され、そのインターフェースを通してテストできるもの。コードを設計・再構築する場面ではどこでもこの言葉と原則を使う。目指すのは、呼び出し側にとってのレバレッジ、保守する側にとっての局所性、そして誰にとってもテストしやすさである。
用語集
これらの用語を正確に使うこと。「コンポーネント」「サービス」「API」「境界」などに置き換えない。一貫した言葉を使うこと自体が目的である。
モジュール(Module): インターフェースと実装を持つあらゆるもの。意図的にスケールを問わない: 関数、クラス、パッケージ、複数レイヤーにまたがるスライスでもよい。避ける言葉: ユニット、コンポーネント、サービス。
インターフェース(Interface): 呼び出し側がそのモジュールを正しく使うために知る必要のあるすべて: 型シグネチャだけでなく、不変条件、順序制約、エラーモード、必要な設定、パフォーマンス特性も含む。避ける言葉: API、シグネチャ(型レベルの表面だけを指すため狭すぎる)。
実装(Implementation): モジュールの中身、そのコード本体。アダプターとは異なる: あるものは小さなアダプターでありながら大きな実装を持つこともあれば(Postgresリポジトリ)、大きなアダプターでありながら小さな実装を持つこともある(インメモリのフェイク)。話題がシームであれば「アダプター」、それ以外は「実装」を使う。
深さ(Depth): インターフェースにおけるレバレッジ。呼び出し側(やテスト)が学ぶ必要のあるインターフェースの単位あたり、どれだけの振る舞いを行使できるか。小さなインターフェースの裏に大量の振る舞いがあるとき、モジュールは深い(deep)。インターフェースが実装とほぼ同じくらい複雑なとき、モジュールは浅い(shallow)。
シーム(Seam) (Michael Feathers): その場所を編集せずに振る舞いを変更できる場所。モジュールのインターフェースが存在する位置のこと。シームをどこに置くかは、その裏に何を置くかとは別の設計判断である。避ける言葉: 境界(boundary)(DDDの境界づけられたコンテキストと意味が重複するため)。
アダプター(Adapter): シームでインターフェースを満たす具体的なもの。(中身が何かではなく)どの枠を埋めるかという役割を表す。
レバレッジ(Leverage): 深さによって呼び出し側が得るもの。学ぶべきインターフェースの単位あたり、より多くの能力が得られること。1つの実装が、N個の呼び出し箇所とM個のテストに対して元を取る。
局所性(Locality): 深さによって保守する側が得るもの。変更・バグ・知識・検証が、呼び出し側全体に散らばるのではなく、1箇所に集中すること。1回直せば、どこでも直る。
深い vs 浅い
深いモジュール = 小さなインターフェース + 大量の実装:
┌─────────────────────┐
│ Small Interface │ ← メソッド数が少なく、パラメータもシンプル
├─────────────────────┤
│ │
│ Deep Implementation│ ← 複雑なロジックが隠れている
│ │
└─────────────────────┘
浅いモジュール = 大きなインターフェース + わずかな実装(避けるべき):
┌─────────────────────────────────┐
│ Large Interface │ ← メソッドが多く、パラメータも複雑
├─────────────────────────────────┤
│ Thin Implementation │ ← ただ素通しするだけ
└─────────────────────────────────┘
インターフェースを設計するときは、次を問う:
- メソッドの数を減らせないか?
- パラメータをもっとシンプルにできないか?
- もっと多くの複雑さを内側に隠せないか?
原則
- 深さはインターフェースの性質であり、実装の性質ではない。 深いモジュールは、内部的には小さくモック可能で交換可能な部品で構成されていてもよい。それらはインターフェースの一部ではないだけである。モジュールは(インターフェースにある)外部シームに加えて、(実装内部にのみ存在し、自分自身のテストにのみ使われる)内部シームを持つこともある。
- 削除テスト。 そのモジュールを削除したと想像する。複雑さが消え去るなら、それはただの素通しだった。複雑さが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; }表面積を小さくする。 メソッドが少なければ必要なテストも少なくなる。パラメータが少なければテストのセットアップもシンプルになる。
用語間の関係
- モジュールはちょうど1つのインターフェースを持つ(呼び出し側とテストに提示する表面)。
- 深さはモジュールの性質であり、そのインターフェースに対して測られる。
- シームはモジュールのインターフェースが存在する場所である。
- アダプターはシームに位置し、インターフェースを満たす。
- 深さは、呼び出し側にはレバレッジを、保守する側には局所性をもたらす。
採用しなかった考え方
- 深さを実装行数とインターフェース行数の比とする考え方(Ousterhout): 実装を水増しすることを助長してしまう。代わりに「深さ=レバレッジ」を採用する。
- 「インターフェース」をTypeScriptの
interfaceキーワードやクラスの公開メソッドとする考え方: 狭すぎる。ここでの「インターフェース」は呼び出し側が知るべきすべての事実を含む。 - 「境界(boundary)」という言葉: DDDの境界づけられたコンテキストと意味が重複する。シームまたはインターフェースと言うこと。
さらに深く
- 依存関係を踏まえてクラスタを深化させる方法については DEEPENING.md を参照: 依存関係のカテゴリ、シームの規律、「積み重ねるのではなく置き換える」テスト戦略。
- 代替インターフェースの探索については DESIGN-IT-TWICE.md を参照: 並列サブエージェントを立ち上げて、インターフェースを何通りも根本的に異なる形で設計させ、深さ・局所性・シームの配置で比較する。