ソフトウェア設計

[SOLID #2] SOLID原則の実践編(後編)ISP・DIP、依存性注入が存在する理由

前回はSOLIDの最初の3文字、SRP・OCP・LSPを扱いました。今回は残りの2文字を整理します。

読了 4 分
[SOLID #2] SOLID原則の実践編(後編)ISP・DIP、依存性注入が存在する理由のカバー画像

前回はSOLIDの最初の3文字、SRP・OCP・LSPを扱いました。今回は残りの2文字を整理します。

ISP(インターフェース分離の原則)とDIP(依存性逆転の原則)です。

特にDIPは、SwinjectでもFactoryでも、SwiftでDIライブラリを使うと必ず出会う「依存性注入(DI)」の理論的な基盤です。この記事を読み終えるころには、なぜそれらのライブラリがそのような形になっているのか分かるはずです。


ISP:インターフェース分離の原則

まず定義から見てみましょう。

クライアントは、自分が使用しないメソッドへの依存を強制されるべきではない。

難しく聞こえますが、実際の問題を見るとすぐに理解できます。複合機のプロトコルを1つ作ったとしましょう。

protocol Machine {
    func print(_ doc: Document)
    func scan(_ doc: Document)
    func fax(_ doc: Document)
}

// 旧型プリンターは印刷しかできない...
class OldPrinter: Machine {
    func print(_ doc: Document) { /* 正常に動作 */ }
    func scan(_ doc: Document) { fatalError("スキャン不可") }  // 無理な実装
    func fax(_ doc: Document) { fatalError("ファックス不可") }   // 無理な実装
}

印刷しかしないプリンターに、スキャンとファックスまで実装させる状況です。結局、例外を投げるだけの偽メソッドが生まれますが、これは前回見たLSP違反でもあります。肥大化したインターフェースは、このように2つの原則を連鎖的に破ります。

対処法は簡単です。インターフェースを役割単位に分割します。

protocol Printer { func print(_ doc: Document) }
protocol Scanner { func scan(_ doc: Document) }
protocol Fax { func fax(_ doc: Document) }

class OldPrinter: Printer { ... }                      // 印刷のみ
class MultiFunction: Printer, Scanner, Fax { ... }     // すべて対応する複合機

それぞれができることだけを契約します。実務では、次のような兆候として現れます。プロトコルに準拠したのに「このメソッドは私たちには関係ない…」という空実装が生まれたら、そのプロトコルは分割すべきタイミングです。


DIP:依存性逆転の原則

名前のせいで誤解されやすい原則です。定義は2行です。

上位モジュールは下位モジュールに依存してはならない。両者は抽象に依存すべきである。

抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。

コードで見てみましょう。注文サービスがメール送信ライブラリを直接使っている状況です。

// 上位モジュール(ビジネスロジック)が下位モジュール(実装の詳細)に直接依存
import SendGrid

class OrderService {
    private let mailer = SendGridClient(apiKey: apiKey)

    func completeOrder(_ order: Order) {
        // 注文処理...
        mailer.send(to: order.email, message: "注文完了")  // SendGridに固定される
    }
}

この構造には2つの問題があります。SendGridを別のサービスに変更するにはビジネスロジックを開く必要があり、テストのたびに実際のメールが送信されます。

DIPを適用すると、矢印の方向が逆になります。

// 抽象は上位モジュール(注文ドメイン)が定義
protocol NotificationSender {
    func send(to: String, message: String) async throws
}

class OrderService {
    private let sender: NotificationSender

    init(sender: NotificationSender) {  // 抽象のみに依存
        self.sender = sender
    }

    func completeOrder(_ order: Order) async throws {
        try await sender.send(to: order.email, message: "注文完了")
    }
}

// 詳細(SendGrid)が抽象に従う
class SendGridSender: NotificationSender { ... }
class SlackSender: NotificationSender { ... }
class FakeSender: NotificationSender { ... }  // テスト用

もともと「注文サービス → SendGrid」だった依存関係が、今では「注文サービス → プロトコル ← SendGrid」になります。詳細がドメインに向かって頭を下げるように見えるため、「逆転」と呼ばれます。

矢印の方向が逆転する点こそがDIPのすべてです
矢印の方向が逆転する点こそがDIPのすべてです
矢印の方向が変わることが「逆転」の正体です
矢印の方向が変わることが「逆転」の正体です

だからDIフレームワークがある

ここで自然な疑問が生まれます。「では、SendGridSender()は誰が作ってくれるのでしょうか?」

この組み立てを代わりに行うのが、依存性注入(DI)コンテナです。Swiftの世界で使われるSwinjectやFactoryのようなDIライブラリがしているのは、まさにこれです。クラスはプロトコルだけを見るようにし、実際の実装を差し込む役割をライブラリが担います。

DIPは原則(方向)で、DIはそれを実現する技法(ツール)です。この区別を押さえるだけでも、面接の回答が一段上がります。

実装は差し替え可能で、組み立てはフレームワークの役割です
実装は差し替え可能で、組み立てはフレームワークの役割です

SOLID全体のまとめ

2回にわたったSOLIDを5行に圧縮すると、こうなります。

原則 一言でいうと
SRP 変更を求める人が違うなら、コードも分離する
OCP 頻繁に変わる箇所には、修正ではなく追加で対応する
LSP 子は親の契約を破ってはならない
ISP 使いもしないメソッドの実装を強制しない
DIP ビジネスロジックを詳細に依存させない

5つすべてが、結局は同じ方向を指しています。変更の影響範囲を小さくすることです。

ただし、これらの原則をすべてのコードに機械的に適用すると、かえってKISSとYAGNIを破ることになります。原則は、実際に変更が頻繁に起きる場所を選んで使う道具です。このバランス感覚こそが本当の実力だと思います。

あわせて読みたい