【設計入門2026 #13】SOLID 原則

さて、ここまでに適用してきた色々な考え方は、実は SOLID 原則としてよく知られている。

これはソフトウェア構築時に従うべき5つのガイドラインで、ソフトウェアの拡張性や保守性を高めるためのもの。

ここで細かくは話さないが、今までの流れを振り返りながら一つずつ紹介していく。

S:SRP [Single Responsibility Principle]【単一責任の原則】

変更するための理由が、一つのクラスに対して一つ以上あってはならない。

サブスク管理システムでは、Aさんは値引きなどを全部ひっくるめて Subscription クラスに実装してしまっていた

これを申込、割引、固定値値引きのそれぞれを別のクラスに分離することで、見通しをよくした

見通しが良いと、今後のコード変更による影響範囲を最小化し、把握しやすくすることができる。

単一責任の原則を軽んじていると、すべての処理が一つのクラスに収まっているいわゆる「神クラス」というものができてしまう。

処理同士の依存関係が複雑化し、それを把握しきれなくなり、コード修正にともなって思いもよらぬ場所を破壊してしまうことがある……。

ただし、 (余裕がある人向け)

でも全ての関数を別々のクラスに分ければ良いのかと言うと、これもまた良くない。共通の意味を持っているメソッドが散逸している状態 (低凝集) は修正ミスを誘ったり、認知不可の向上につながったりする。

要はバランスである。

O:OCP [Open-Closed Principle]【オープン・クローズドの原則】

ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれているべきであり、修正に対して閉じていなければならない。

サブスク管理システムでは、Aさんは条件分岐によってキャンペーンを適用していたため、新しいキャンペーンが追加されたら Subscription クラスを変更しなければならなかった

一方、継承によって整理したことにより、新たなキャンペーンを追加したときも対応する子クラスを追加するだけでよい状態になった。

ただし、 (余裕がある人向け)

今後想定しうるすべての修正に耐える構造というのは存在しないので、抽象化は容量・用法を守って行う必要がある。例えば今のキャンペーンクラスでは金額以外に作用することはできない (例えば一定期間は上位プランの機能が使えるキャンペーンとか) 。必要以上の抽象化でいたずらに複雑性を上げることも、オーバーエンジニアリングあるいは YAGNI と呼ばれ、注意されている。

要はバランスである。

L:LSP [Liskov Substitution Principle]【リスコフの置換原則】

ある基底クラスへのポインタないし参照を扱っている関数群は、その派生クラスのオブジェクトの詳細を知らなくても扱えるようにしなければならない。

サブスク管理システムでは、AdditionalOptionServiceBilling という悪い継承の例を出しながら、継承では is-a 関係、意味上の共通部分が大切であることを説明した。

このようにキャンペーンではない想定外の継承を行うと、たとえば割引額が負になるなど想定外の状況が起こってしまい、思わぬバグやクラッシュの原因となってしまう。

親クラスを求めている場所に子クラスを渡しても問題なく動作するような正しい継承をしよう。

ただし、 (余裕がある人向け)

子クラスでのオーバーライドを通して間接的に親クラスの動作を変更できるという特性上、「親クラスがどのように使われても正しい状態を保つ」ということは不可能に近い面もある。継承を利用するときは、意識的か無意識的か、親クラスや子クラスの使われ方になんらかの仮定を置くことになる。その仮定が大きくなりすぎるとその仮定を破ってしまう可能性が上がってしまい、壊れやすいプログラムになる。

例になっているかわからないけど

例えば、固定値値引き500円のキャンペーンがあるとしよう。これを100円の価格に対して適用すると、現在の実装では値引き後の金額は -400 円となる。これを考慮していないシステムでは普通にバグが起こり得る。

→ 固定値値引きの 500 円引きを実装するときに、親クラスで 500 円未満の金額が与えられないことを暗黙的に仮定してしまったケース

あるいは、それを意識していて、値引き後の金額を最低でも 0 円に抑えるよう修正したとする。すると今度は compute_discounted_price() が必ずしも 500 を返さなくなる。上のように100 円が渡されたケースだと、100 円が 0 円になるので、compute_discounted_price() は 100 を返すようになる。

ここで、compute_discounted_price() を使って「割引のお得度」を比較するコードがあったりすると、本当は 500 円引きの効果を持つのに 100 円引きの効果として過小に認識されてしまうことになる。

→ お得度を比較するコードを実装したとき、子クラスのオーバーライド方法によらず compute_discounted_price() が必ず値引きの本来の特性を教えてくれると暗黙的に仮定してしまったケース

要は。バランスである。

継承のこの面を嫌って、継承を避ける風潮も最近はある。

I:ISP [Interface Segregation Principle]【インターフェイス分離の原則】

汎用なインターフェースが一つあるよりも、各クライアントに特化したインターフェースがたくさんあった方がよい。

サブスク管理システムでは、メール送信をキャンセルするメソッドをインターフェースに入れてしまうと子クラスでの実装が難しくなるという例を挙げた。

一つのインターフェースに全てまとめるのではなく、小さなインターフェースに分けた方がいいというものである。

  • 大きいインターフェースは単一責任の原則に違反している可能性が高い
  • インターフェースを実装するときはすべてのメソッドを実装しないといけないので、「呼び出してはいけない」メソッドが発生しがち。リスコフの置換原則に反する
ただし、 (余裕がある人向け)

単一責任の原則と同じような注意が必要になる。1 メソッドごとに全部別のインターフェースにするなどの極端なことをすると、処理が分散してまとまりをつかみにくくなるという問題がある。

要はバランスである。

D:DIP [Dependency Inversion Principle]【依存関係逆転の原則】

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも具象ではなく、抽象(インターフェースなど)に依存するべきである。

わかりやすい例では、クラスを直接参照するのではなく、インターフェースに依存させる。

サブスク管理システムでは、申込処理の中でメール送信関数を直接利用するのではなくインターフェースを介して利用するように修正した

そうすることで処理同士の独立性がより高まり (疎結合) 、コードの再利用性が向上した。

結果、テストコードでだけ実装を差し替えることができるようになった

なぜこれが依存性逆転と呼ばれているのだろう?観点は2通りある。

  • 「実装クラスの依存関係」という観点から見た話

    普通にコードを書いた場合、依存の関係はシンプルに呼び出し順になる。

    Image in a image block

    インターフェースを挟むとこうなる。

    Image in a image block
    • ActualMailClient に向かう依存の矢印が、ActualMailClient から出る依存の矢印になっている
    • また、アーキテクチャのレイヤーについて、「ビジネスロジックの世界」と「外とやりとりする世界」の境界線上で依存方向が逆向きになっている
  • 「依存が発生している位置」という観点から見た話
    • 通常の実装では、subscribe() の内で MailClient を生成する
    • 逆転すると、subscribe() の外で生成された MailClient が渡されてくる
from abc import ABC, abstractmethod

# domain/interface/mail.py
# MailClient インターフェース。
class MailClient(ABC):
    @abstractmethod
    def send_mail_to(self, address: str, message: str):
        "メール送信処理"
        pass

# infra/repository/mail.py
# 本番環境用
class ActualMailClient(MailClient):
    @override
    def send_mail_to(self, address: str, message: str):
        # 本当はメール送信処理、例示のため print() で代用しておく
        print("actually send mail")
        pass

# テスト用
# tests/infra/repository/mail.py
class MockMailClient(MailClient):
    @override
    def send_mail_to(self, address: str, message: str):
        # テスト用なので、メール送信をしない
        pass

# 送信処理
# main.py
def subscribe(mail_client: MailClient, mail_address: str, plan_id: int):
    "申し込む"

    # ...なんやかんや申込処理...

    # メール送信
    message = f"{subscription.monthly_price()}円で購読ありがとうございます"
    mail_client.send_mail_to(mail_address, message)

------------------------------------------------------------------------

if os.getenv("ENV") == "test":
    client = MockMailClient()
else:
    client = ActualMailClient()

subscribe(client, "test@example.com", 12345)
ただし、 (余裕がある人向け)

任意のクラス呼び出しにすべてのインターフェースを挟むのが良いわけではない。それを始めると、ほとんどのクラスはインターフェースと 1:1 に対応するようになるだけで、修正工数と認知負荷を爆増させる以外の効果はない…。

要はバランスである。DDD などの設計手法では、そこにある程度の指針を与えてくれたりはする。

まとめ

オブジェクト指向には保守しやすくするための5つの原則がある
単一役割の原則:それぞれのクラスに単一の役割だけをもたせるようにする
オープン・クローズの原則:既存の成果物を変更せずに拡張できるようにする
リスコフの置換原則:親クラスは必ず子クラスで代用できるようにする
インターフェース分離の原則:変更の影響を最小限にするために、関心のないものに無理矢理依存させない
依存性逆転の原則:より変更可能性が低いものに依存させ、自分自身の変更可能性を最小限にする