インターフェース分離の原則
SOLID の I - クライアントが使わないメソッドへの依存を強制しない原則
インターフェース分離の原則とは
インターフェース分離の原則 (Interface Segregation Principle, ISP) は、SOLID 原則の I にあたる設計原則で、「クライアントが使わないメソッドへの依存を強制してはならない」。
ここで言う分離の基準は、インターフェースの大きさそのものではなく呼び出し側の使い方である。同じクラスを使う場合でも、注文を参照するだけの集計処理と、注文を確定させる更新処理では必要なメソッドが違う。この使い方の単位でインターフェースを切り、各クライアントには自分が呼ぶ分だけを見せる。Martin Fowler は 2006 年の記事でこの切り方をロールインターフェースと呼び、供給側が単一の大きなインターフェースだけを公開するヘッダーインターフェースと対比している。供給側のクラスが複数のロールインターフェースを実装するのは、この考え方では通常の姿である。
依存を絞る理由は 2 つある。1 つは実装義務で、使わないメソッドまで実装させられると、空実装や例外送出でごまかす場所が生まれる。もう 1 つは変更の波及で、インターフェースに宣言があるだけで、そのメソッドを一度も呼ばないクライアントまで再コンパイルと再デプロイの対象になる。後者は静的型付けで配布単位が分かれている環境ほど痛く、ISP の実務上の効き目はこちら側が大きい。
違反の例
まず、供給側の都合で 1 つにまとめたインターフェースが実装を壊す例を見る。
// ❌ ISP 違反: 巨大なインターフェース
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
}
// ロボットは eat() と sleep() を実装できない
class Robot implements Worker {
work() { /* OK */ }
eat() { throw new Error('Robots do not eat'); } // ❌ 無意味な実装
sleep() { throw new Error('Robots do not sleep'); }
attendMeeting() { /* OK */ }
}
準拠の例
呼ぶ側の役割ごとに切り出せば、実装できないメソッドを抱え込まずに済む。1 つのクラスが複数のインターフェースを実装している点は分離の失敗ではない。
// ✅ ISP 準拠: 小さなインターフェースに分割
interface Workable { work(): void; }
interface Eatable { eat(): void; }
interface Sleepable { sleep(): void; }
interface MeetingAttendable { attendMeeting(): void; }
// 人間: 全て実装
class Human implements Workable, Eatable, Sleepable, MeetingAttendable {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
attendMeeting() { /* ... */ }
}
// ロボット: 必要なものだけ実装
class Robot implements Workable, MeetingAttendable {
work() { /* ... */ }
attendMeeting() { /* ... */ }
}
TypeScript での実践
実務で頻出するのは、ロボットの例のように実装が不可能な場合よりも、実装はできるが特定のクライアントは呼ばない場合である。読み取りしかしない集計処理に更新メソッドまで見せてしまう形が典型で、書き込み側の変更が集計側に波及する。
// ❌ ISP 違反: Repository が読み書き両方を強制
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}
// 読み取り専用のサービスに save/delete は不要
// ✅ ISP 準拠: 読み取りと書き込みを分離
interface ReadRepository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
}
interface WriteRepository<T> {
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}
// 読み取り専用サービス
class ReportService {
constructor(private repo: ReadRepository<Order>) {}
async getReport() { return this.repo.findAll(); }
}
TypeScript の型互換性は構造的部分型に基づくため、implements の宣言がなくても必要なメンバーが揃っていれば代入できる。つまり依存の広さを決めるのは実装側の宣言ではなく、引数やプロパティに書いた型である。受け取る型を ReadRepository<Order> と狭く書いておけば、渡された実体が save を持っていても集計処理からは触れられない。逆に引数の型を大きなインターフェースのまま残すと、テストで差し替えるたびに、そのテストでは呼ばないメソッドまでスタブを埋める作業が発生する。
ISP 違反のサイン
- インターフェースのメソッドを空実装や
throwで埋めている - インターフェースの一部のメソッドしか使わないクライアントが多い
- インターフェースの変更が無関係なクライアントに影響する
- テストダブルを書くたびに、そのテストでは呼ばないメソッドの穴埋めが必要になる
分ける単位の決め方
細かく割ればよいという原則ではない。メソッド 1 つずつに型を作ると、呼び出し側は必要なロールを毎回組み合わせて宣言することになり、どの型を要求すべきか探す手間と宣言の長さが増える。単位は使い方の種類に合わせるのが妥当で、同じ使い方をする複数のクライアントに別々の型を用意する意味はない。過不足の判断には次の点が使える。
- そのインターフェースの全メソッドを呼ぶクライアントが 1 つでもあるか (なければ広すぎる)
- 分けた型が常に同じ組み合わせでしか使われていないか (であれば分けすぎ)
- 型の分離を実装やデータストアの分離まで持ち込んでいないか (読み取りと書き込みの型を分けても、実装クラスや接続先は 1 つでよい)
Go では 1 つか 2 つのメソッドを持つインターフェースが一般的で、io.Writer のようにメソッド名から型名を付ける。1 つの型が複数のインターフェースを満たすことも前提とされており、ロール単位で切る発想が標準ライブラリの形に現れている。
他の SOLID 原則との関係
ISP は単独で効く原則ではなく、他の原則と組み合わせて働く。
| 原則 | ISP との関係 |
|---|---|
| 単一責任 (SRP) | クラスの責務を分離 → インターフェースも分離 |
| リスコフ置換 (LSP) | 使わないメソッドを例外送出で埋めると LSP も同時に破る。ロール単位に切れば契約を守れる実装だけが残る |
| 依存性逆転 (DIP) | 依存先を抽象にするのが DIP、その抽象をどこで切るかを決めるのが ISP |
さらに掘り下げるなら関連書籍が参考になる。
この記事は役に立ちましたか?
関連用語
SOLID 原則
オブジェクト指向設計の 5 つの基本原則で、保守性と拡張性の高いコードを実現する
リスコフの置換原則
SOLID の L - サブタイプはスーパータイプと置換可能でなければならない原則
依存性逆転の原則
SOLID の D - 上位モジュールは下位モジュールに依存せず、両者とも抽象に依存すべきという設計原則
アダプターパターン
互換性のないインターフェースを変換し、既存のコードを変更せずに連携させるデザインパターン
抽象クラス
直接インスタンス化できず、サブクラスに共通のインターフェースと部分的な実装を提供するクラス
Null Object パターン
null チェックの代わりに、何もしないオブジェクトを使って条件分岐を排除するデザインパターン