SOLID — 5つの原則が守ろうとしている1つのこと
暗記する対象ではなく、判断の道具として使う。それぞれが「どういう変更に備えているか」から理解する。
先に読んでおくとよい記事
この記事の進み方
What — 5つの原則と、備えている変更
5つとも「変更に対してどう構えるか」を扱っている。暗記ではなく、どの変更に備えるかで覚える。
開放閉鎖原則:冒頭の事故を直す
新しい種類が増えるたび、すべての分岐を探して追加する。
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箇所に散っています。どの原則に反していますか?
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('読み取り専用です'); }
}
解決は、そもそも継承しないこと。Readable と Writable を分ければ(= インターフェース分離)、型で正しく表現できます。
インターフェース分離が効く場面
// 分離されていない
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) { ... }
showDetail が Readable だけを要求すれば、この関数が書き込みをしないことが型で保証されます。読む人にも分かるし、テストのモックも小さくて済みます。
依存性逆転で「逆転」しているのは何ですか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この設計に、どの原則をどこまで適用しますか?
- 社内の通知システム。現在はメールのみを送信している
- NotificationService.send(userId, message) が、直接 SMTP ライブラリを呼んでいる
- 来期に Slack 通知を追加する計画がある。その後 SMS も検討中
- 通知の本文生成ロジック(テンプレート差し込み、多言語化)も同じクラスの中にある
- テストを書くには SMTP サーバーのモックが必要で、CI で不安定
- チームは3人、コードベースは1年目でまだ小さい
- · どの変更が実際に来ると分かっているか
- · 来ない変更にまで備えると、何を失うか
- · 投資対効果が最も高い原則はどれか
「SOLID を全部守れているか不安」という後輩に、原則の使い方を説明してください
- 相手は5つの原則を暗記している
- 相手はレビューで毎回すべてを確認しようとしている
- 判断の道具として使えるようにしたい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。