原則の衝突と限界 — どちらを取るかを決める
ここまで 5 原則を個別に見てきました。1 つずつ読むと、どれももっともに見えます。困るのは同時に適用しようとしたときです。
分割を進めれば 1 つの変更で触るファイルが増えます。抽象を挟めば実行時の実体を追う手数が増えます。それぞれの原則は正しく、そして互いに逆を向いています。この章では、衝突したときに何を基準に決めるかを扱います。
5 原則を凝集度と結合度という 2 つの物差しで統一的に整理した解説は、PHPクラス設計ガイド — 凝集度・結合度と SOLID 総整理 にあります。本章はその地図を前提として、地図が答えない問題 — 凝集を上げる操作と結合を下げる操作が同じコードで衝突したとき、どちらを取るか — を扱います。
原則は他の望ましさと衝突する
衝突は例外ではありません。原典自身が、原則は互いに排他でありうると書いています。 パッケージ設計の凝集に関する 3 原則について、Martin の 2000 年の文書はこう述べます1。
These three principles are mutually exclusive. They cannot simultaneously be satisfied. That is because each principle benefits a different group of people. (これら 3 つの原則は互いに排他的である。同時に満たすことはできない。それぞれの原則が、異なる集団に利益をもたらすからである。)
続けて、時期によって優先順位が変わることも書いています。プロジェクトの初期は開発と保守がしやすい構成を優先し、アーキテクチャが安定してから外部の利用者向けに組み直す、という順序です。
「原則が衝突したらどちらかが間違っている」のではありません。 誰の利益を優先するかで答えが変わる、という構造です。
クラス設計の 5 原則では、対立の相手が別の集団ではなく、同じ開発者が払う別のコストになります。
| 原則 × 対立するもの | 内容 | 判断の手がかり |
|---|---|---|
| SRP × 変更の局所性 | 分割を進めるほど、1 つの変更で開くファイルが増える | 変更が 1 ファイルに閉じるか、常に複数に散るか |
| OCP × YAGNI | 拡張点を先に用意すると、予測が外れたときのコストが分岐より高い | 拡張の方向が実測済みか、想像か |
| DIP × 追いやすさ | 抽象を挟むほど、実行時に何が動くのかを追う手数が増える | 差し替えが実際に起きているか |
| ISP × 型の総数 | 細かく切るほど、型と implements の数が増える | クライアントの使い方が本当に分かれているか |
| LSP × 継承という手段 | 置き換えが要らない関係に継承を使うと、契約の制約だけが残る | 置き換えが必要か。必要でないなら継承をやめて委譲にする |
LSP だけは、程度を選べるトレードオフではありません。第 4 章で見たとおり、選択肢は「LSP を諦める」ではなく「継承をやめる」か「契約を設計し直す」かです。
前章までで見たとおり、5 原則は並列でもありません。原典は LSP 違反を潜在的な OCP 違反とし、DIP を OCP の実現手段と位置づけていました。原典が OCP に紐づけているのは、この LSP と DIP の 2 つです。 SRP と ISP について同種の位置づけは原典にありません。
過剰適用の兆候
原則を守りすぎた状態には、共通する見た目があります。どれも「原則には従っているのに読みにくい」という形で現れます。
実装が 1 つしかない抽象の比率が高い。 インターフェースの数と実装クラスの数がほぼ同じなら、抽象が予測で作られている可能性があります。第 3 章の予測的抽象化です。
1 つの処理を追うのに開くファイルが多い。 「注文が確定するまでに何が起きるか」を知るために 8 個のファイルを行き来するなら、分割が変更の単位と合っていません。
抽象の名前が具象の名前に接尾辞を足しただけ。 LendingRecordStore に対する LendingRecordStoreImpl のような組は、抽象が概念を表していない印です。名前を付けられないなら、そこに分ける境界が無かったということです。
型やインターフェースの数が、扱っている概念の数より多い。 語彙が増えても、読み手が覚えるべき概念が増えただけなら、理解の助けになっていません。
これらはいずれも原則違反ではありません。SRP も ISP も DIP も満たしています。満たしているのに読みにくいなら、適用しすぎているということです。
原則ではなく性質で考える
SOLID そのものへの批判もあります。最もまとまった形で提示されているのは、Dan North が 2022 年に書いた CUPID です2。
North は、原則という枠組み自体を問題にします。
Principles are like rules: you are either compliant or you are not. This gives rise to "bounded sets" of rule-followers and rule-enforcers rather than "centred sets" of people with shared values. (原則は規則のようなものだ。従っているか、従っていないかのどちらかである。これは、価値観を共有する人々の「中心のある集合」ではなく、規則に従う者と規則を強制する者からなる「境界のある集合」を生む。)
代わりに彼が置くのが性質 (properties) です。
Properties define a goal or centre to move towards. Your code is only closer to or further from the centre, and there is always a clear direction of travel. (性質は、向かうべき目標あるいは中心を定める。コードは中心に近いか遠いかでしかなく、進むべき方向は常に明確である。)
違反か遵守かの二値ではなく、中心にどれだけ近いかで見る。 これが North の提案の核です。彼が挙げる 5 つの性質は Composable / Unix philosophy / Predictable / Idiomatic / Domain-based で、いずれも程度を持ちます。
CUPID の各項目の詳細は原典に譲ります。本連載にとって重要なのは、判断の形式が変わるという点です。「この設計は SRP に違反しているか」という問いは答えが二値になり、答えても次の行動が決まりません。「このコードは予測しやすい方向に動いているか」という問いなら、程度で答えられて、次に何をするかも見えます。
North は SOLID 全体を誤りだと断じているわけではありません。記事は「もし SOLID が今日あまり有用でないと考えるなら、何に置き換えるのか」「どんな原則の集合であれ、すべてのソフトウェアに当てはまりうるのか」という自問から始まります。ただし SRP については脚注で、反証は容易であり「しばしば時期尚早な分離を招く恣意的な制約だ」と明確に退けています。
それでも 5 原則が残る理由
批判を紹介して終わりにはしません。5 原則が指している問題は、消えていないからです。
| 原則 | 指している問題 | 今も起きるか |
|---|---|---|
| SRP | 無関係な要求が同じコードに着地し、巻き添え変更が起きる | 起きる |
| OCP | 追加のたびに複数箇所を直し、直し忘れる | 起きる |
| LSP | 置き換えたつもりが、呼び出し側の期待を裏切る | 起きる |
| ISP | 使わないものへの依存が、影響範囲を広げる | 起きる |
| DIP | 変わりやすいものへの依存が、上位を引きずる | 起きる |
変わったのは問題ではなく、原則の位置づけです。 「常に適用すべき規則」から「その問題が実際に起きているときに使う道具」へ移りました。
道具として使うなら、順序があります。まず問題を特定し、次にどの原則が効くかを選びます。逆順 — 原則を先に選んで、当てはまる場所を探す — は過剰適用の入口です。
判断の順序
衝突したときの判断を、手順にまとめます。
1. 何が痛いかを先に言う。 「SRP に違反している」ではなく「経理の要求で直したら、現場の画面が変わって問い合わせが来た」と言えるかを確認します。痛みが言えないなら、直す理由がありません。
2. その痛みが実際に起きた回数を数える。 1 回も起きていないなら、予測です。第 3 章で見たとおり、予測で払うコストは毎日かかります。
3. 原則を道具として選ぶ。 巻き添え変更なら SRP、直し忘れなら OCP、置き換えの裏切りなら LSP、というように、痛みから逆に引きます。
4. 適用したあと、別の痛みが増えていないか見る。 分割したら追う手数が増えていないか、抽象を挟んだら実体が見えなくなっていないか。衝突は適用したあとに現れます。
5. 取り消しやすい方から試す。 分けない判断は後から分ければ済みますが、分けたものを戻すのは呼び出し元が散っている分だけ高くつきます。抽象も同じで、後から挟む方が、間違った抽象を剥がすより安く済みます。
この順序に共通するのは、原則を出発点にしないことです。原則は判断の道具であって、判断そのものではありません。
まとめ
- 原典自身が、パッケージ設計の凝集 3 原則について「互いに排他的で、同時には満たせない」と書いている。クラス設計の 5 原則についてそう書かれてはいないが、原則と他の望ましさのトレードオフは実務で起きる。衝突は例外ではない
- 5 原則は並列ではない。原典が OCP に紐づけているのは LSP と DIP の 2 つで、LSP はその前提、DIP はその手段。SRP と ISP について同種の位置づけは原典にない
- 過剰適用には見た目がある。実装が 1 つの抽象、追うのに開くファイルの多さ、
Impl接尾辞、概念より多い型 - Dan North は原則という枠組み自体を批判し、二値の遵守判定ではなく程度で見る性質を提案した
- 5 原則が指す問題は今も起きる。変わったのは「常に守る規則」から「痛みに応じて選ぶ道具」への位置づけ
- 判断は痛みから始める。原則から始めて当てはまる場所を探すのが、過剰適用の入口になる
次に読む
- SOLID 原則の深掘り — 章立て — どの原則の章に戻るかを選ぶ
- OCP と予測的抽象化 — 本章の「OCP × YAGNI」の判断を、型で追加漏れを捕まえる書き方まで含めて扱う章
- クリーンコードガイド — SOLID 原則 — 5 原則の定義と、違反例から改善例への書き換え
- PHPクラス設計ガイド — 凝集度・結合度と SOLID 総整理 — 5 原則を 2 つの物差しで統一的に整理した章
- クリーンコードガイド — クラスとインターフェース — クラス設計の基本に戻って確認したいとき
練習問題
次の設計は 2 つの原則が衝突しています。どちらを優先し、その判断をどう説明しますか
図書館システムの延滞通知に、次の要求が来ました。
通知の文面を、会員種別ごとに変えたい。今は一般・学生・高齢者の 3 種別だが、来年度から教職員が加わる予定がある。
現在の実装は 1 つの関数に分岐が入っています。
type MemberTier = "general" | "student" | "senior";
function buildOverdueMessage(tier: MemberTier, days: number, fine: number): string {
if (tier === "student") {
return `返却期限を ${days} 日過ぎています。学生証を持参してください。延滞料金は ${fine} 円です。`;
}
if (tier === "senior") {
return `返却期限を ${days} 日過ぎています。ご不明な点はカウンターまで。延滞料金は ${fine} 円です。`;
}
return `返却期限を ${days} 日過ぎています。延滞料金は ${fine} 円です。`;
}
選択肢 A: 会員種別ごとの通知生成をインターフェースに切り出し、種別ごとの実装クラスを作る (OCP を優先)
選択肢 B: 今のまま分岐を残し、Record<MemberTier, ...> で網羅性だけ型に保証させる (YAGNI を優先)
解答例
選択肢 B を推奨します。 ただし判断の根拠は「工数が小さいから」ではありません。
痛みから始めます。 現在起きている問題は「教職員を足すとき、この関数を直す必要がある」ことです。直す必要があること自体は痛みではありません。痛みになるのは直し忘れることです。
現在のコードは、最後の return が既定値になっているため、教職員を足しても何も起きずに一般会員と同じ文面が出ます。これが本当の問題です。 抽象を導入しなくても、この問題は解決できます。
const messageTemplates: Record<MemberTier, (days: number, fine: number) => string> = {
general: (days, fine) => `返却期限を ${days} 日過ぎています。延滞料金は ${fine} 円です。`,
student: (days, fine) =>
`返却期限を ${days} 日過ぎています。学生証を持参してください。延滞料金は ${fine} 円です。`,
senior: (days, fine) =>
`返却期限を ${days} 日過ぎています。ご不明な点はカウンターまで。延滞料金は ${fine} 円です。`,
};
function buildOverdueMessage(tier: MemberTier, days: number, fine: number): string {
return messageTemplates[tier](days, fine);
}
MemberTier に "faculty" を足すと、messageTemplates の型が合わずコンパイルエラーになります。直し忘れが起きません。
選択肢 A を選ばない理由は、抽象が解決する問題がここに無いことです。インターフェースと実装クラスを導入すると、種別ごとにファイルが増え、文面を 1 つ直すために該当クラスを探すことになります。得られるのは「新しい種別を足すとき既存ファイルを開かなくてよい」という利点ですが、種別が増えるのは年に 1 回あるかどうかです。文面を読み比べる作業の方が頻繁に起きます。
判断の説明の仕方: 「OCP に従うと A ですが、拡張の頻度が年 1 回程度なのに対し、文面の一覧性は日常的に必要です。B なら追加漏れは型が捕まえるので、OCP が防ぎたかった事故は起きません。種別ごとの文面が複雑になり、条件分岐やデータ取得が必要になった時点で A に移します。」
判断が変わる条件も添えておきます。種別ごとの通知に「学生には保証人にも通知する」のような処理の違いが入るなら、文面のテーブルでは表現しきれません。そのときは A が適切になります。