仕組みから学ぶ Web
応用読了目安 15#SOLID#設計原則#オブジェクト指向#依存性逆転

SOLID — 5つの原則が守ろうとしている1つのこと

暗記する対象ではなく、判断の道具として使う。それぞれが「どういう変更に備えているか」から理解する。

この記事の進み方

What — 5つの原則と、備えている変更

FigureSOLID の5原則
S 単一責任変更理由は1つにSingle Responsibility
O 開放閉鎖追加で拡張、修正は不要にOpen-Closed
L リスコフ置換派生型は基底型として振る舞えるLiskov Substitution
I インターフェース分離使わないメソッドに依存させないInterface Segregation
D 依存性逆転詳細ではなく抽象に依存するDependency Inversion
1/5
S 単一責任1つのモジュールが変わる理由は1つであるべき。「1つのことをする」ではなく「1つの理由でだけ変わる」。凝集度の話をクラス単位で言い直したもの。

5つとも「変更に対してどう構えるか」を扱っている。暗記ではなく、どの変更に備えるかで覚える。

開放閉鎖原則:冒頭の事故を直す

Compare決済手段を追加するとき何が起きるか

新しい種類が増えるたび、すべての分岐を探して追加する。

function calcFee(method, amount) {
if (method === 'credit') return amount * 0.036;
if (method === 'paypay') return amount * 0.028;
if (method === 'bank')   return 330;
// ← コンビニ払いを足すには、ここを書き換える
}

// 同じ形の分岐が、アプリ内に 12 箇所
追加時に触る箇所
分岐の数だけ
漏れのリスク
高い(探す必要がある)
既存のテスト
全部再実行が必要
初期の書きやすさ
簡単
  • 種類が2〜3で、増える見込みがないなら、この形で十分。原則を機械的に適用すると過剰設計になる。
  • 「増えることが分かっている」ものに対してだけ、次の形に移す。

分岐を増やす形と、実装を追加する形。前者は追加のたびに既存を修正し、後者は既存に触らない。

依存性逆転:何を逆転させているのか

これが5つの中で最も誤解されます。**逆転しているのは「依存の向き」**です。

素朴な構造(上位が下位を知っている)

  OrderService  ──────▶  MySQLOrderRepository
  (業務ロジック)           (DB の詳細)

  → DB を変えると OrderService も変わる
  → テストに実 DB が必要
逆転後(両者が抽象を知っている)

  OrderService  ──────▶  OrderRepository(インターフェース)
                              ▲
                              │ implements
                    MySQLOrderRepository

  → 矢印が下から上を向いた = 逆転
  → OrderService は MySQL を知らない
  → テストではメモリ実装を差し込める

インターフェースを、上位(業務ロジック)側に置くのが要点です。下位のパッケージに置くと、結局上位が下位を参照することになり、逆転していません。

確認 — ここまで読めたか

決済手段の分岐が12箇所に散っています。どの原則に反していますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ5つに分かれているのか

SOLID は、変更の種類ごとに対策を並べたものです。

原則備えている変更
S無関係な変更が同じ場所に来ること
O新しい種類が増えること
L型を差し替えたときに壊れること
I相手の変更が、使っていない部分でも波及すること
D技術的な詳細(DB・API)が変わること

だから「全部守るべき」ではありません。その変更が来ないなら、その原則は要らないのです。

決済手段が今後絶対に増えないなら、if の分岐で構いません。DB を変える予定がまったくないなら、リポジトリインターフェースは過剰かもしれません。

リスコフ置換原則が壊れる典型

class Bird {
  fly() { ... }
}
class Penguin extends Bird {
  fly() { throw new Error('ペンギンは飛べません'); }  // ← 違反
}

Bird として扱っているコードが Penguin で落ちます。使う側は「もしペンギンだったら」という確認を書く羽目になり、継承したのに型で扱えないという本末転倒な状態になります。

実務でよく見る形はこちらです。

class ReadOnlyRepository extends Repository {
  save() { throw new Error('読み取り専用です'); }
}

解決は、そもそも継承しないことReadableWritable を分ければ(= インターフェース分離)、型で正しく表現できます。

インターフェース分離が効く場面

// 分離されていない
interface Repository {
  find(id): Entity;
  save(e): void;
  delete(id): void;
  bulkImport(file): void;
  exportCsv(): string;
}

// 読むだけの機能も、全部を実装させられる
// 分離した
interface Readable  { find(id): Entity; }
interface Writable  { save(e): void; delete(id): void; }
interface Importable { bulkImport(file): void; }

// 必要なものだけを実装し、必要なものだけを要求できる
function showDetail(repo: Readable, id) { ... }

showDetailReadable だけを要求すれば、この関数が書き込みをしないことが型で保証されます。読む人にも分かるし、テストのモックも小さくて済みます。

確認 — ここまで読めたか

依存性逆転で「逆転」しているのは何ですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この設計に、どの原則をどこまで適用しますか?

与えられた条件
  • 社内の通知システム。現在はメールのみを送信している
  • NotificationService.send(userId, message) が、直接 SMTP ライブラリを呼んでいる
  • 来期に Slack 通知を追加する計画がある。その後 SMS も検討中
  • 通知の本文生成ロジック(テンプレート差し込み、多言語化)も同じクラスの中にある
  • テストを書くには SMTP サーバーのモックが必要で、CI で不安定
  • チームは3人、コードベースは1年目でまだ小さい
この軸で考える
  • · どの変更が実際に来ると分かっているか
  • · 来ない変更にまで備えると、何を失うか
  • · 投資対効果が最も高い原則はどれか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「SOLID を全部守れているか不安」という後輩に、原則の使い方を説明してください

与えられた条件
  • 相手は5つの原則を暗記している
  • 相手はレビューで毎回すべてを確認しようとしている
  • 判断の道具として使えるようにしたい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。