Tsuzura

ソフトウェア設計の基本原則

SOLID、DRY、KISS、YAGNIを暗記せず、責務、重複、複雑さ、契約、依存関係から設計判断を選ぶ方法です。

コードは、必要な結果を返せれば完成とは限りません。変更するときに、どこを直すのか、ほかの処理へどこまで影響するのか、同じ判断が複数の場所へ散っていないかも確認する必要があります。コードが増えてから初めて考えると、変更理由と依存関係を追う作業そのものが難しくなります。

ソフトウェア設計の原則は、コードの責務、重複、複雑さ、契約、依存関係を検討するための判断基準です。特定のフォルダ構成やフレームワークを指定するものではありません。原則の名前を多く使うことではなく、現在の要件に対して、構造を維持するか、分けるか、まとめるか、抽象化するか、先送りするかを説明できることが目的です。

ここでいう責務は、そのコードが引き受ける判断と処理です。契約は、利用側が期待する振る舞いです。依存関係は、あるコードの変更が別のコードへ影響する関係です。抽象化は、特定の実装方法ではなく、利用側に必要な役割や操作を定義することです。SOLIDは、この記事で扱うSRP、OCP、LSP、ISP、DIPという五つの設計原則の総称です。

この記事は、AIにコードを生成・変更させながら、小規模なWebサービスや業務システムを作る人を対象にしています。読み終えると、一つの変更について、使う原則、採用する設計判断、採用しない判断、見直す条件を整理できます。

要件と受入条件がまだ決まっていない場合は、先に要件と受入条件の決め方を確認してください。MVC、レイヤード、クリーンアーキテクチャ、Vertical Sliceなど、コード全体の構成方式はコードアーキテクチャの種類と選び方で扱います。この記事では、デザインパターン、ドメイン駆動設計、特定言語の実装方法、ADRの詳しい書き方は扱いません。

なぜ設計原則が必要なのか

設計原則が必要なのは、コードの分け方や依存関係に、実装中も変更時も複数の選択肢があるからです。動作結果が同じでも、変更のたびに広い範囲を直す構造と、関係する範囲を限定できる構造では、確認する対象が異なります。

変更の影響を判断できるようにする

設計では、現在の要件を実現するだけでなく、次の問いへ答えられる状態を作ります。

  • この処理が担う責務は何か
  • その責務の変更を求める人、部門、接続先は誰か
  • 同じ業務上の知識を複数の場所で管理していないか
  • 利用側が期待する入力、出力、状態、失敗時の振る舞いは何か
  • 重要な方針が、データベースや外部サービス等の実装詳細へ直接依存していないか
  • 追加した抽象化や複雑さは、現在の要件に必要か

設計原則は、これらの問いを別々の観点から確認するために使います。一つの原則だけで、すべての問いに答えるものではありません。

周辺概念との役割を分ける

設計原則、コードアーキテクチャ、コーディング規則、ADRは役割が異なります。次の表は、本記事で判断を混同しないための整理です。外部標準で定められた分類ではありません。

項目 答える問い 主な記録
要件 何が必要で、どうなれば受け入れられるか 必要な結果、制約、受入条件
設計原則 何を分け、まとめ、隠し、どの依存を許すか 判断に使った観点と適用条件
コードアーキテクチャ コード全体をどの単位で構成し、依存をどう置くか 採用する構成方式と境界
コーディング規則 コードをどの表記と形式でそろえるか 命名、書式、言語別の規則
ADR(Architecture Decision Record、設計判断の記録) 重要な設計判断をなぜ選び、いつ見直すか 選択、理由、影響、見直し条件

コードアーキテクチャを選んでも、各責務をどう分けるか、どこを抽象化するかは自動では決まりません。反対に、設計原則を知っていても、システム全体の構成方式は別に選ぶ必要があります。

原則は常に適用する法則ではない

SOLIDを広めたRobert C. Martin自身も、設計原則を法則や完全な真理ではなく、状況に応じた判断を必要とする指針として説明しています(Getting a SOLID Start)。

原則を適用した結果、クラス、インターフェース、変換処理、設定が増え、現在の変更が理解しにくくなる場合もあります。原則名に合う形を先に作るのではなく、要件、実際の変更理由、利用側の期待、必要な確認を先に決めます。

この記事では、原則を使った判断を次の形で残します。

  • 対象にした問題
  • 適用した原則
  • 選んだ構造
  • 今回は採用しなかった構造と理由
  • 見直す条件

責務を決める

責務は、そのコードが引き受ける判断と処理の範囲です。ファイルやクラスの数ではなく、何について責任を持ち、何が変わると修正が必要になるかで考えます。

関心の分離:観点を分けて考える

関心の分離は、複数の観点があることを認識したまま、一度に一つの観点を整合的に検討する考え方です。Dijkstraの原稿では、ほかの観点を無視するのではなく、一時的に分けて考える方法として説明されています(On the role of scientific thought)。

したがって、関心ごとに必ず別のファイルやクラスを作る、という規則ではありません。まず、業務上の判断、入力の検証、データ保存、表示、外部通信等の観点を混ぜずに確認します。そのうえで、別々に変わる観点が同じ境界へ入っているなら、分割を検討します。

情報隠蔽:変わる判断を隠す

情報隠蔽(Information Hiding)は、難しい、または変更されそうな設計判断を一つのモジュール内へ収め、利用側へ不要な内部事情を公開しない考え方です。ここでいうモジュールは、特定のファイルやクラスではなく、設計上の責務を割り当てる単位です。Parnasは、処理の実行順ではなく、隠す設計判断を基準にシステムを分割する方法を示しました(On the Criteria To Be Used in Decomposing Systems into Modules)。

情報隠蔽は、機密情報を暗号化することや、変数へprivateを付けることと同義ではありません。利用側が内部形式や実装手順を直接知っているなら、修飾子だけでは変更の影響を閉じられません。何を隠すかと、利用側へ何を公開するかの両方を決めます。

SRP:同じ変更理由でまとめる

SRP(Single Responsibility Principle、単一責任の原則)は、同じ理由で変わるものをまとめ、異なる理由で変わるものを分ける原則です。Robert C. Martinの説明では、変更理由は、モジュールの変更を求める一人の利用者、一つの部門、または密接に同じ判断をする集団に結び付きます(The Single Responsibility Principle)。

SRPは、一つのクラスに一つのメソッドだけを置く規則ではありません。複数の処理が同じ理由で一緒に変わるなら、同じ責務に含められます。反対に、見た目が短いコードでも、異なる人や規則から変更を求められるなら、責務を分ける候補です。

関心の分離は検討する観点を整理し、情報隠蔽は変わる判断を境界内へ収め、SRPは変更理由から責務のまとまりを決めます。三つは排他的な選択肢ではありません。

重複と複雑さを判断する

DRY、KISS、YAGNIは、どれもコードを短くするための原則ではありません。DRYは知識の重複、KISSは現在必要な設計の複雑さ、YAGNIはまだ要求されていない将来機能を対象にします。

DRY:同じ知識の正本を一つにする

DRY(Don’t Repeat Yourself)は、一つの知識や意図に、一つの明確な正本を持たせる原則です。正本とは、その知識を変更するときに正式な更新先とする一つの定義です。提唱者による説明は、コピーされたコード行だけでなく、同じ知識を異なる場所や形式へ手作業で反映する状態を対象にしています(The Pragmatic Programmer — DRY excerpt)。

形が似ているコードでも、異なる理由で変更されるなら、同じ知識とは限りません。早く共通化すると、片方だけを変えたいときに不要な結合が生まれます。共通化する前に、変更理由、利用側、受入条件が本当に同じかを確認します。

KISS:必要な条件を満たして単純にする

KISSには、ソフトウェア設計全体で共通する単一の正式定義を確認できません。この記事では、現在の要件と安全上の制約を満たす選択肢の中で、価値を生まない構成要素や間接処理を増やさない、という運用上の判断に限定します。

MicrosoftのReliability design principlesも、業務要件とセキュリティ等の制約を先に明確にし、過剰設計を避ける一方、一つの故障だけでサービス全体が止まる単一障害点を、単純化によって作らないよう説明しています。これはAzureの信頼性設計に関する公式指針であり、KISSの歴史的な原典ではありません。

単純にするために、必要な認証、権限、入力検証、テスト、監視、復旧を削ることはできません。必須条件を満たした後で、不要な層、抽象、設定、例外経路がないかを確認します。

YAGNI:未要求の将来機能を先回りしない

YAGNI(You Aren’t Gonna Need It)は、現在は使わない将来機能を、必要になるという予測だけで実装しない考え方です。初期の公開説明でも、実際に必要になった時点と、将来必要になると予測しただけの時点を分けています(You’re NOT gonna need it!)。

YAGNIは、将来を検討しないという意味ではありません。Martin Fowlerは、将来機能だけのために能力や抽象を追加することと、リファクタリング、自己テスト、継続的な配信、コードを変更可能な状態に保つことを区別しています(Yagni)。

現在の要件に含まれるセキュリティ、プライバシー、法令、復旧、互換性を「将来の話」として省きません。未要求の機能は作らず、必要になると判断する条件だけを残します。

三つを使い分ける

次の表は、DRY、単純さに関するMicrosoftの指針、YAGNIの対象を、本記事の判断用に整理したものです。

確認する状態 使う観点 主な判断
同じ知識を複数箇所で更新している DRY 正本を一つにまとめるか
現在の要件に不要な構成や間接処理がある KISS 必須条件を保ったまま減らすか
将来必要になるという予測だけの機能がある YAGNI 実装を先送りするか
形は似ているが変更理由が異なる SRPとDRY 無理に共通化せず分けて保つか

DRYによる共通化が構造を複雑にする場合は、同じ知識かどうかを先に確認します。現在必要な同じ知識なら、単純さだけを理由に重複を残すとは限りません。将来の予測だけなら、共通化や抽象化を急がず、YAGNIを優先できます。

契約と依存を守る

SOLIDのうち、OCP、LSP、ISP、DIPは、利用側との契約と、コード同士の依存関係を検討する原則です。四つを理解する前に、用語をそろえます。

契約・型・抽象・依存

以下は、本記事で設計判断に使う操作上の定義です。組織やコード一式に既存の定義がある場合は、先にその記録と実際の利用箇所を確認します。

この記事でいう契約は、法的な契約書ではありません。利用側がそのコードへ期待する入力、出力、状態の変化、失敗時の振る舞いです。

型は、扱える値と操作を区別する定義です。継承は、既存の定義を引き継いで新しい定義を作る言語機能です。インターフェースは、利用側へ公開する操作の接点です。

抽象は、特定の実装方法ではなく、利用側が必要とする役割や操作を表した定義です。インターフェースや抽象クラスだけを指すとは限りません。

一つの抽象化について、隠す詳細と利用側へ残す契約を具体的に判断する手順は、コードの抽象化とは何かで扱います。

依存は、あるコードが別のコードの名前、型、振る舞い、データ形式等を知り、それが変わると影響を受ける関係です。ソースコード上の依存関係は、どちらの定義を参照してコードを書いているかを示します。

この記事でいう高水準の方針は、サービスが守る業務上の判断や利用場面です。低水準の実装詳細は、データベース、外部通信、画面、フレームワーク等、その方針を実現する技術です。

OCP:変更する軸を選ぶ

OCP(Open-Closed Principle、開放閉鎖の原則)は、振る舞いを拡張できる一方、既存の安定した部分の変更を避けられる構造を目指します。Robert C. Martinは、Bertrand Meyerによる定義を紹介したうえで、依存関係を管理し、拡張部分を一定の接点で追加・交換できるプラグイン構造を適用例として説明しています(The Open Closed Principle)。

すべての将来変更へ事前に対応することはできません。変更軸とは、ほかの変更とは別に起こり得る変更の種類です。どの変更が現在必要か、または繰り返し発生しており、どの部分を安定させたいかを選びます。変更軸が未確認なら、インターフェースやプラグインを先に増やす根拠にはなりません。

LSP:置き換えても期待を壊さない

LSP(Liskov Substitution Principle、リスコフの置換原則)は、ある型を前提に書かれた利用側で、その型を別の型へ置き換えても、利用側が観測する振る舞いを壊さないことを求めます。Barbara Liskovの原資料は、型の階層と実装上の継承階層を別の概念として説明しています(Data Abstraction and Hierarchy)。

メソッド名や型の形が一致するだけでは十分ではありません。許される入力を狭める、期待される結果を返さない、既存の状態条件を壊す等、利用側の判断を変える振る舞いがないかを確認します。継承を使ったかどうかではなく、置換後も契約を守れるかが判断対象です。

ISP:不要な依存を持たせない

ISP(Interface Segregation Principle、インターフェース分離の原則)は、利用側が使わない操作へ依存しないよう、公開する接点を分ける原則です。Martinの説明では、小さいインターフェースを勧める一方、分割が望ましくない場合もあるとされています(Solid Relevance)。

メソッド数の固定上限や、利用者ごとに必ず別のインターフェースを作る規則ではありません。利用側が不要な変更の影響を受けているか、分割後の接点を理解・管理できるかを確認します。

DIP:方針を実装詳細から守る

DIP(Dependency Inversion Principle、依存関係逆転の原則)は、高水準の方針が、データベース、外部通信、画面、フレームワーク等の低水準の実装詳細へ直接依存しないよう、ソースコード上の依存関係を抽象の方向へ向ける原則です。Martinは同じ資料で、高水準のモジュールから抽象へ依存を向けると説明しています(Solid Relevance)。

DIPは、Dependency Injection(依存性注入)の道具やコンテナを導入することと同義ではありません。すべての具体的なクラスにインターフェースを作る規則でもありません。重要な方針が、変更されやすい実装詳細の型やデータ形式を直接知っている場合に、境界と依存方向を見直します。

原則 確認する対象 避ける解釈
OCP 実在する変更軸と、安定させる部分 すべての将来変更を先回りする
LSP 置換後も利用側の契約を守れるか 型の形だけを合わせる
ISP 利用側が不要な操作へ依存していないか 接点を無条件に細分化する
DIP 方針から実装詳細への依存方向 道具を導入すれば達成したとみなす

原則を選ぶ手順

複数の原則が異なる判断を促すときは、原則名の優先順位を固定しません。次の手順は、この記事で一つの変更を判断するための整理であり、外部標準ではありません。

  1. 要件と必須条件を固定する

    必要な結果、受入条件、セキュリティ、プライバシー、法令、互換性、復旧等の必須条件を確認します。ここで未決事項が結果を変えるなら、設計原則を選ぶ前に判断者へ戻します。

  2. 実際の変更理由を確認する

    誰が、何を、なぜ変えるのかを確認します。発生していない将来変更の予測と、現在または繰り返し発生する変更を分けます。変更理由が説明できなければ、分割や抽象化を急ぎません。

  3. 責務と知識の位置を確認する

    異なる理由で変わる責務が混ざっていれば、関心の分離、情報隠蔽、SRPで境界を検討します。同じ知識を複数箇所へ反映していれば、DRYで正本を確認します。形が似ているだけなら、共通化せず別々に保つ判断も残します。

  4. 契約と依存を確認する

    利用側が期待する振る舞い、不要な依存、重要な方針から実装詳細への依存を確認します。実在する変更軸があり、現在の構造では影響を閉じられない場合に、OCP、LSP、ISP、DIPを使って境界を見直します。

  5. 現在必要な最小の構造を選ぶ

    必須条件を守ったまま、現在必要のない複雑さを減らします。将来機能だけのための抽象化は先送りし、必要になったと判断する条件を残します。

判断 選ぶ条件 見直す条件
現在の構造を維持する 責務、知識、契約、依存に確認済みの問題がない 変更理由や利用側が増えた
責務を分ける 異なる理由で変更される判断が混ざっている 分割後も同時に変更され続ける
知識をまとめる 同じ知識を複数箇所へ手動反映している 変更理由が別だと分かった
境界を抽象化する 実在する変更軸や外部境界の影響を閉じる必要がある 抽象が利用側の理解と変更を難しくした
実装を先送りする 将来必要になるという予測だけで、現在の要件に含まれない 要件、利用側、期限が確定した

判断結果を残す

判断後は、次の六点を一つの記録へ残します。

  • 対象にした変更
  • 確認した設計上の問題
  • 選んだ構造と使った原則
  • 採用しなかった構造と理由
  • 実行したテストまたは確認
  • 見直す条件

六点を、要件とコードの参照先を含めて説明できれば、この工程の判断結果を次の実装タスクへ渡せます。未確認の項目が結果を変える場合は、完了とせず判断者へ戻します。ADRの詳しい書式は別記事で扱います。

AI生成コードを確認する

AIへ「SOLIDにして」「DRYにして」とだけ依頼すると、現在の変更理由が分からないまま、クラス、インターフェース、共通処理が増える可能性があります。原則名ではなく、要件、既存の構成、変更理由、守る契約、確認方法を渡します。

AIへ確認させる項目

コードを変更する前に、AIへアクセスを許可した関連コードに加え、要件、プロジェクトの概要を記したREADME、ADR、直近の変更記録等、根拠にできる資料を渡します。まず変更せず、次の情報を参照箇所とともに整理させます。資料から確認できない項目は推測させず、「不明」として人が補います。秘密情報、個人情報、顧客情報を含む資料は、そのAIへ渡してよいと確認できるまで対象にしません。

  • 要件と受入条件に関係する処理
  • 現在の責務と、資料に記録された変更要求者・部門・接続先
  • 同じ知識を定義している場所
  • 利用側が依存する入力、出力、状態、失敗時の振る舞い
  • 高水準の方針から実装詳細への依存
  • 追加しようとする抽象化と、その必要性
  • 変更後に実行できるテストと確認手順

影響範囲が分からない場合は、変更させず、調査結果と設計案だけを先に出させます。実装へ進むときは、AIに渡す実装タスクの切り方を使い、一つの完了条件と確認方法に絞ります。

人が合否を決める

AIが「SRPに従った」「DIPを適用した」と説明しても、それだけでは採用理由になりません。GitHubのAI生成コードのレビュー指針も、テスト結果とは別に、生成コードが要求、意図、既存の設計に合い、正しい問題を解いているかを人が確認するよう示しています(Review AI-generated code)。

人は、次の項目を確認します。

  • 現在の要件と必須条件を満たしている
  • 責務の分割理由を、実際の変更理由で説明できる
  • 共通化した箇所が、本当に同じ知識を表している
  • 利用側の契約を変えていない、または変更が承認されている
  • 重要な方針が、不要に実装詳細へ依存していない
  • 現在使わない機能や抽象化を増やしていない
  • 追加・変更した振る舞いを確認するテストがある
  • 採用しなかった設計と、見直す条件を説明できる

上の項目を一件の変更について確認し、採否を記録する手順は、AI生成コードをレビューするで扱います。

テストの種類を選ぶ必要がある場合は、テストの種類と選び方へ進みます。テストが通っても、責務、依存関係、不要な抽象化が適切だとは自動的に判断できません。

出典