😊

SOLID原則を説明してみる(DIP: 依存性逆転の原則)

に公開

はじめに

ソフトウェア設計において、SOLID原則 は保守性の高いコードを書くための重要な指針です。
本記事では、その中でも 大量に記事があるので今更な 特に有名な「依存性逆転の原則(Dependency Inversion Principle, DIP)」について解説します。

SOLID原則を説明してみるシリーズ

ぜひこちらもお願いします😆

DIPとは:ソフトウェアの価値を技術詳細から守る原則

DIP(依存性逆転の原則)を一言で表すなら:

こんな経験はありませんか?

  • ❌ 「データベースを MySQL から PostgreSQL に変更したら、ビジネスロジックまで
    書き直す羽目になった...」
  • ❌ 「外部APIの仕様変更で、本来変更不要なはずのアプリケーションロジックまで
    影響を受けた...」

根本原因は、ロジックというソフトウェアの本質的な価値技術的な手段依存していることにあります。

DIPで解決すること

  • ✅ 技術選択の変更(e.g. MySQL → PostgreSQL)がロジックに影響しない
  • ✅ 最小限の変更で機能を切り替えられる

DIPによって、技術的な手段がロジックというソフトウェアの本質的な価値依存するようになります。
それによって上記のような利点を得られます。

なぜ起こるのか

そもそも、なぜこのような問題が起こるか?
それはソフトウェアの構造上の不可欠な依存という性質にあります。

「依存性逆転の原則」は文字通り、依存関係の方向性に関する原則です。
依存関係の方向が重要な理由は、変更による影響の伝搬に関わるからです。

依存があるということは「依存するもの」と「依存されるもの」が存在します。
この時、「依存されるもの」が変更されたとき、「依存するもの」はその影響は受けることになります。
詳細は「ソフトウェアの依存関係と変更の伝搬方向について」にてご確認ください。

APIの良くある構成例で controller/usecase/repository(repo) で説明します。( controller はほぼ空気です)
このような設計だと、技術的な手段が「依存されるもの」になり、ロジック[1]が「依存するもの」になります。

その結果以下のような構造が生まれます。

説明のために客室予約システムのキャンセル処理を例にしてみます。
ID をキーに予約データを検索して、予約のステータスが「予約済み」ならデータを削除するというシンプルなユースケースとしましょう。

コードに書き起こすと下記のようになります。

room/usecase モジュール例( room/repo/mysql に依存)
package usecase

import (
	"log"

    // 💣 DBがpostgreSQLになって、実装が`room/repo/postgresql`になったら?
	"room/repo/mysql"
)

type ReservationUsecase struct {
    // 💥 package書き換え
    cli mysql.RoomReservationClient
}

func (u ReservationUsecase) Cancel(reservationID string) error {

	if roomReservation, err := u.cli.FindByID(reservationID); err != nil {
        switch err.(type) {
        // 💥 package書き換え
        case mysql.NotFoundRoomReservation :
            // エラー毎に必要な処理
            return err
        }
        return err
	}

    if roomReservation.Reserved() {
        if err := u.cli.Cancel(reservationID); err != nil {
            // エラー時のハンドリング
        }
    }

	return nil
}
room/repo/mysql モジュール例
package mysql

import (
	"database/sql"
	"encoding/json"

    "github.com/go-sql-driver/mysql" dmysql
)

// ❗ usecase都合の定義にもかかわらず循環参照になるので、こちらに定義
type (          
    NotFoundRoomReservation struct {Message string, Key string}
    InternalServerErr       struct {Message string, Key string}   
    // ...その他エラー定義
)

type RoomReservation struct {
	ID         string `db:"id"`
	Status     string `db:"status"`
}
    // ❗ 明らかなロジックにもかかわらず、repo層に定義される...
func (r RoomReservation) Reserved() bool { return r.Status == "reserved" }

type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

func (c RoomReservationClient) FindByID(
    reservationID string
) (*RoomReservation, error) {
    query := `SELECT id, status FROM room_reservations WHERE id = ?`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            return nil, NotFoundRoomReservation{
                Message: err.Error(),
                Key: reservationID,
            }
        }
            
        if mysqlErr, ok := err.(*dmysql.MySQLError); ok {
            // MySQLエラーコードで判定
            switch mysqlErr.Number {
            case 2013:
                return nil, InternalServerErr{
                    Message: "Lost connection to MySQL server",
                    Key: reservationID,
                }
            // ...
            }
        }
    }
    
    return &RoomReservation{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

func (c RoomReservationClient) Cancel(reservationID string) error {
    // 割愛
}

「データベースの処理を担うリポジトリの実装(クライアント)を MySQL 用から PostgreSQL 用に変えたら、ロジックまで変えることになった」というのは、この構造が引き起こします。

どうするか

依存性を逆転します!

といっても意味不明なのですが、やることはInterface(I/F) を使った隠ぺいです。
図に usecase/service・entity という新しいパッケージが追加した例を示します。

usecase が service に定義された I/F に依存することになり、ロジックが技術的実装に直接依存しなくなりました。
そして I/F に隠れるように Client がいます。この Client が実際の処理を行うのですが、usecase からは I/F のこと見えなくなります。
Client が I/F を隠れ蓑にする感じです。

以下にそのコードを示します。

room/entity モジュール例

entityとは

usecase がビジネスロジックを利用してユーザーの要望をソフトウェア的に実現するレイヤーなら、entity はビジネスの知識をそのまま表現するイメージです。

package entity

// ✅ アプリケーションロジック・技術的なことには一切関わらない
type RoomReservation struct {
    ID     string
    Status string
}
// ビジネスルールを定義(当然、アプリケーションロジック・技術的なことは知らない)
func (r RoomReservation) Reserved() bool { return r.Status == "reserved" }
room/usecase/service モジュール例
package service

// tagなどの技術的な情報はもたない
type RoomReservationInput struct {
    ID     string
    Status string
}

// ✅ データ取得の仕様だけを知っている(具体的な技術的関心事は実装側に移譲)
type RoomReservationAccessor interface {
    FindByID(string) (*RoomReservationInput, error)
    Cancel(string) error
}
room/usecase モジュール例(entity・serviceに依存)
package usecase

import (
	"log"

    // ✅ repoに関して何もimportしていない → repoについてusecaseは何もしらない
    "room/entity"
    "room/usecase/service"
)

// repoをusecaseに依存させれるようになったので移動
type (          
    NotFoundRoomReservation struct {Message string, Key string}
    InternalServerErr       struct {Message string, Key string}   
    // ...その他エラー定義
)

type RoomReservationUsecase struct {
    // ✅ I/FでClientの実装を隠ぺい!
    accessor service.RoomReservationAccessor
}

func NewRoomReservation(
    accessor service.RoomReservationAccessor,
) RoomReservationUsecase {
    return RoomReservationUsecase {
        accessor: accessor,
    }
}

func (u RoomReservationUsecase) Cancel(reservationID string) error {
    // ✅ accessorを隠れ蓑にクライアントの実体が処理を行う!
    input, err := u.accessor.FindByID(reservationID)
	if err != nil {
        switch err.(type) {
        // ✅ 技術側の都合でエラー定義が変わることはなくなった
        case NotFoundRoomReservation:
            // 型が必要になる処理
            return err
        }
        return err
	}
    
    roomReservation := entity.RoomReservation{ID: input.ID, Status: input.Status}
    if roomReservation.Reserved() {
        if err := u.accessor.Cancel(roomReservation.ID); err != nil {
            // 割愛
        }
    }

	return nil
}
room/repo/mysql モジュール例(usecase・service に依存)
package mysql

import (
    "database/sql"
    "encoding/json"

    "github.com/go-sql-driver/mysql" dmysql

    // アプリケーションロジックに依存するようになる
    "room/usecase"
    "room/usecase/service"
)

// ✅ 外部データを受け取るだけの定義(ビジネスロジックが適切なモジュールに移動)
type RoomReservation struct {
	ID     string `db:"id"`
	Status string `db:"status"`
}

// usecase.RoomReservationAccessorを実装(Goは暗黙的)
type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

func (c RoomReservationClient) FindByID(
    reservationID string,
// ✅ ロジック層に依存
) (*service.RoomReservationInput, error) {
    query := `SELECT id, status FROM room_reservations WHERE id = ?`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            // ✅ ロジックに依存
            return nil, NotFoundRoomReservation{
                Message: err.Error(), Key: reservationID,
            }
        }
            
        if mysqlErr, ok := err.(*dmysql.MySQLError); ok {
            // MySQLエラーコードで判定
            switch mysqlErr.Number {
            case 2013:
                // ✅ ロジックに依存
                return nil, InternalServerErr{
                    Message: "Lost connection to MySQL server",
                    Key: reservationID,
                }
            // ...
            }
        }
    }
    // ✅ ロジックに依存
    return &service.RoomReservationInput{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

func (c RoomReservationClient) Cancel(reservationID string) error {
    // 割愛
}

上記で図の中の定義は終わりですが、動かすための組み立てが必要です。
サーバーの初期時に組み立てる例を示します。

server (サーバーの初期化処理)
package server

import (
    // 割愛
)

func server() {
    var conf Config
    err := envconfig.Process("", &conf)
    if err != nil {
        log.Fatal("環境変数読み込み失敗")
    }

    var accessor service.RoomReservationAccessor
    if conf.Env == "Prod" {
        // プロダクションコードは実際のDB(MySQL)に繋ぎに行く
        dsn := fmt.Sprintf(
            "%s:%s@tcp(%s:%d)/%s", 
            conf.DBUser, conf.DBPassword, conf.DBHost, conf.DBPort, conf.DBName,
        )
        db, _ := sql.Open(conf.DBDriver, dsn)
        accessor = mysql.NewRoomReservationClient(db) // ✅ accessorを隠れ蓑にMySQLのクライアントがusecaseに渡る
    } else {
        accessor = mock.MockRoomReservationClient() // ✅ accessorを隠れ蓑にMockのクライアントがusecaseに渡る
    }

    // 組み立ての流れ
    roomReservationUsecase := usecase.NewRoomReservation(accessor) // ✅ usecaseを変更せずに、与えたクライアントの実装によりaccessorの挙動を切り替える
    roomReservationController := controller.NewRoomReservation(roomReservationUsecase)
    // APIハンドラーへcontrollerメソッドを登録してサーバーを起動
}

どうなるか

✅ 技術選択の変更がロジックに影響しない

DIP実践後の room/usecase packageの例を見ていただけるとDBクライアントの詳細な記述が一切ありません。
これはusecaseというロジックが技術選択に一切依存しておらず、影響を受けないということです。
仮に、DBがPostgreSQLになったらDBクライアントを追加するだけです。

room/repo/postgresql
package postgresql

import (
    "database/sql"
    "encoding/json"

    "github.com/lib/pq"

    "room/usecase"
    "room/usecase/service"
)

type RoomReservation struct {
	ID     string `db:"id"`
	Status string `db:"status"`
}

type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

// ✅ I/F によりデータ取得の仕様は変わらない
func (c RoomReservationClient) FindByID(
    reservationID string,
) (*service.RoomReservationInput, error) {
    // ✅ プレースホルダー「$1」はpostgreSQL特有
    query := `SELECT id, status FROM room_reservations WHERE id = $1`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            return nil, NotFoundRoomReservation{
                Message: err.Error(), Key: reservationID,
            }
        }
        // ✅ PostgreSQL独自のエラー番号でハンドリング
        if pqErr, ok := err.(*pq.Error); ok {
            if pqErr.Code == "23505" {
                return nil, InternalServerErr{
                    Message: "Lost connection to PostgreSQL server",
                    Key: reservationID,
                }
            }
        }
    }

    return &service.RoomReservationInput{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

変更が試しやすくなる

このようなモジュールの追加はDB自体の切り替え以外でも有効です。

たとえば、有益なサードパーティライブラリを見つけて、使ってみたい場合などです。
既存のコードを書き換えるのではなく、新しいモジュールを定義して組み替えれるようになります。

✅ 最小限の変更で機能を切り替えられる

クライアントを先ほどのPostgreSQLへの切り替えはどのようにするのか?
答えは簡単で、server を変えればいいだけです。ロジックには一切手を入れません。

server (room/repo/postgresql 使用)

必要な箇所のみ抜粋

    var accessor service.RoomReservationAccessor
    if conf.Env == "Prod" {
        dsn := fmt.Sprintf(
            "%s:%s@tcp(%s:%d)/%s",
            conf.DBUser, conf.DBPassword, conf.DBHost, conf.DBPort, conf.DBName,
        )
        db, _ := sql.Open(conf.DBDriver, dsn)
        accessor = postgresql.NewRoomReservationClient(db) // ✅ ここさえ変えれば、DB変更に対応できる
    } else {
        accessor = mock.MockRoomReservationClient()
    }

    roomReservationUsecase := usecase.NewRoomReservation(accessor)

DIPが重要な理由

これは、SOLID原則の1つ オープンクローズドの原則(Open Closed Principle, OCP) と同じことを言っています。
DIPを正しく達成できれば、コードのあらゆる場所に手を入れずとも変更が容易になり、OCPが達成できます。

ちょっとした疑問

依存の方向性はどう決めるべきか

以下のような疑問が湧いたことはないでしょうか?

よくある依存の方向性(usecase -> entityとか)なら判断に問題はないが、独特なモジュール間の依存がある場合、どのように方向性はどう考えればよいか?

個人的には以下の考えに則ります。

依存関係には2つの存在があると言いました。

  • 依存するもの
  • 依存されるもの

usecaseとentityなら、依存するもの(usecase)と依存されるもの(entity)です。
ソフトウェアにとって、どちらが本質的な役割でしょうか?
ワークフローを記述するusecaseでしょうか?それとも、ビジネスルールを記述するentityでしょうか?
この関係の中で、より本質的なのはentityでしょう。

そのため、依存の方向性はusecase -> entityになるわけです。
技術実装のrepoやcontrollerがusecaseに依存するのも同様です。(repo/controller -> usecase)

もし開発中に依存の方向性に悩んだ際には以下に着目してみて下さい。

そして逆転する必要があるかを判断していただければと考えています。

結論

DIPによりロジックから技術的な依存を分離することで、以下のメリットを受けられます。

  • ✅ 技術選択の変更がロジックに影響しない
  • ✅ 変更の影響範囲が限定される
  • ✅ 最小限の変更で機能を切り替えられる(OCPも関わる)

総じてDIPは以下のように言えます。

脚注
  1. Usecase はアプリケーションロジックとも呼ばれ、ロジックの一種 ↩︎

  2. entityはClean Architectureでのビジネスロジックを表すレイヤーで、DDDではdomainと呼ばれます。 ↩︎

Discussion