SRP(Single Responsibility Principle/単一責任の原則)は、SOLID原則の「S」にあたる設計原則で、「モジュールを変更する理由は1つだけにする」という考え方です。提唱者のRobert C. Martinは、のちに書籍『Clean Architecture』で「モジュールはただ1人のアクター(変更を求める利害関係者のまとまり)に対して責任を負うべきである」と言い直しました。よく言われる「1つのクラスは1つのことだけをする」は、Martin自身がSRPとは別物だと書いています。
この記事では、定義の変遷を一次情報で確認したうえで、給与計算クラスのSRP違反をJavaで再現し、ある部署の依頼による変更が別の部署の帳票を壊す様子を実行結果で示します。後半では、git履歴でSRP違反を見分ける手順と、やりすぎ(過剰分割)を避ける判断基準を扱います。
まとめ:SRPの要点と判断の基準
SRPは「分ける原則」ではなく「変更の出どころでまとめる原則」です。判断に使う基準を先に並べます。
| 論点 | 結論 |
|---|---|
| 定義 | 変更する理由が同じものは集め、違うものは分ける |
| 判断の単位 | 機能の数ではなく、変更を求めるアクターの数 |
| 違反のサイン | 独立した変更要求が同じクラスに集まる |
| やりすぎの境目 | 同じアクターの都合で一緒に変わる処理まで分ける |
| 見分け方 | git履歴で同時に変わるファイルと変更理由を数える |
| 静的解析 | PMDの4ルールは給与計算例を検出しなかった |
迷ったときは「このクラスに変更を求めてくるのは、どの業務機能か」を書き出します。互いに独立した変更要求が2つ以上挙がるなら分割を検討します。行数やメソッド数だけでは、SRP違反かどうかは決まりません。
単一責任の原則の定義:「変更理由は1つ」から「1人のアクター」へ
SRPの読み方は「エスアールピー」です。日本語では単一責任の原則のほか、単一責務の原則とも訳されます。英語の responsibility の訳し分けで、指す内容は同じです。
当初の定義「クラスを変更する理由は1つ」
Martinが2002年10月刊の著書『Agile Software Development, Principles, Patterns, and Practices』(第8章がSRP。邦訳は『アジャイルソフトウェア開発の奥義』)で示した定義は「THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE.」(クラスを変更する理由が2つ以上あってはならない)です。SRPの章の原稿(PDFでは第9章表記)はObject MentorがPDFで公開しており、冒頭でこの原則はTom DeMarcoとMeilir Page-Jonesの著作で「凝集度(cohesion)」と呼ばれていたものだと説明しています。
2014年5月8日のブログ記事「The Single Responsibility Principle」では、バグ修正やリファクタリングも「変更理由」に数えるのかという疑問が出たことに触れ、それらはプログラマの責任であってプログラムの責任ではない、と答えています。同じ記事は言い換えとして「Gather together the things that change for the same reasons. Separate those things that change for different reasons.」(同じ理由で変わるものを集め、違う理由で変わるものを分ける)を示し、これは凝集度と結合度の別の定義にすぎないと述べています。
『Clean Architecture』の定義「ただ1人のアクターに責任を負う」
「変更理由」が何を指すかについて、同じブログ記事の答えは「This principle is about people.」です。変更を依頼してくるのは人であり、モジュールは1つの狭い業務機能を担う人々のまとまりだけに応えるように作る、という意味です。『Clean Architecture』(原著2017年9月刊)ではこれを「モジュールはただ1人のアクターに対して責任を負うべきである」という定義にまとめました。アクターは、同じ変更を望む1人以上の利害関係者のまとまりを指します。
ここから実務の基準が1つ導けます。「このクラスは何をするか」ではなく「このクラスの仕様を誰が決めるか」で境界を引く、という基準です。
「1つのことだけをする」とSRPの違い
SRPを「1つのクラス(関数)は1つのことだけをする」と説明する記事は今も多く見られます。Martinは『Clean Architecture』のSRPの章で、その原則自体は存在し、大きな関数を小さな関数に分けるときに使うものだが、SOLIDのSRPではないと明言しています。前者は関数の粒度の話で、後者は変更の出どころの話です。
この違いは結論を変えます。「1つのことだけ」で考えると、メソッドが2つあるだけで違反に見え、クラスを際限なく刻むことになります。アクターで考えると、メソッドが20個あっても、変更要求がすべて給与計算という1つの業務機能から来るなら、SRPの観点では分ける理由になりません。
| 粒度 | 当てはまる原則 | 問い |
|---|---|---|
| 関数 | 1つのことだけをする | 処理を1段の抽象度で書けているか |
| クラス・モジュール | SRP | 仕様を決めるアクターは1つか |
| コンポーネント | 閉鎖性共通の原則(CCP) | 同じ理由・同じ時期に変わるか |
| アーキテクチャ | 変更の軸 | 境界をどこに引くか |
『Clean Architecture』は第7章の結びで、SRPがコンポーネントの粒度では閉鎖性共通の原則(CCP)に、アーキテクチャの粒度ではアーキテクチャの境界を生む「変更の軸」になると説明しています。SRPは関数を細かくする技法ではなく、変更の出どころで境界を引く考え方が粒度ごとに形を変えたものです。
SRPの由来:Parnasの1972年論文から1990年代後半まで
SRPという名前は1990年代後半に生まれましたが、考え方はもっと古くからあります。Martinの2014年の記事が挙げる系譜は次のとおりです。
- 1972年:David L. Parnasが「On the Criteria To Be Used in Decomposing Systems into Modules」をCommunications of the ACM 第15巻第12号に発表。フローチャートではなく「変わりそうな設計判断」を単位にモジュールを分け、その判断を他から隠すべきだと結論づけた。
- 1974年:Edsger Dijkstraが論文「On the role of scientific thought」で「関心の分離(Separation of Concerns)」という語を導入。
- 1970〜80年代:Larry Constantineが結合度と凝集度の概念を導入し、Tom DeMarcoやMeilir Page-Jonesらが広めた。
- 1990年代後半:Martinがこれらを1つの原則にまとめ、Single Responsibility Principleと名付けた。
Martinは同じ記事で、この名前はBertrand Meyerから借りた気がするが確認できていない、とも書いています。SRPは凝集度・結合度・関心の分離を「誰が変更を求めるか」という問いに置き直したもの、と捉えると位置づけがはっきりします。
SRP違反の具体例:Javaによる給与計算クラスの再現
Martinが繰り返し使う例が、calculatePay()・reportHours()・save()を持つ Employee クラスです。2014年の記事では、給与の計算規則は財務を担うCFO、監査人が使う勤務時間レポートは業務を担うCOO、データベースへの保存は技術基盤を担うCTOの組織が仕様を決める、と説明しています(『Clean Architecture』第7章では、報告の仕様を決めるのをCOO配下の人事部門としています)。3人の役員が1つのクラスに同居している状態です。
この例の要点は、calculatePay() と reportHours() が内部で処理を共有したときに起きる事故です。save() はSQL文字列を返すだけのダミーで、実際の保存は行いません。以下はEclipse Temurin 25.0.4.1(OpenJDK)の単一ファイル実行(java Employee.java)で動かしたコードです。
違反コード:3部署の仕様が同居するEmployeeクラス
所定内労働時間を求める regularHours() を、給与計算と勤務時間レポートが共有しています。
import java.util.List;
public class Employee {
private final String name;
private final List<Integer> dailyHours; // 1日ごとの勤務時間
private final int trainingHours; // うち研修時間
private final int hourlyWage;
public Employee(String name, List<Integer> dailyHours, int trainingHours, int hourlyWage) {
this.name = name; this.dailyHours = dailyHours;
this.trainingHours = trainingHours; this.hourlyWage = hourlyWage;
}
// 経理(CFO配下)が仕様を決める
public int calculatePay() {
int overtime = dailyHours.stream().mapToInt(h -> Math.max(h - 8, 0)).sum();
return regularHours() * hourlyWage + overtime * hourlyWage * 125 / 100;
}
// 人事(COO配下)が仕様を決める
public String reportHours() {
return name + ": 所定内 " + regularHours() + "時間";
}
// DBA(CTO配下)が仕様を決める
public String save() {
return "INSERT INTO employee VALUES ('" + name + "', ...)";
}
// 共有アルゴリズム:1日8時間までを所定内とする
private int regularHours() {
return dailyHours.stream().mapToInt(h -> Math.min(h, 8)).sum();
}
public static void main(String[] args) {
Employee e = new Employee("佐藤", List.of(8, 10, 9, 8, 8), 6, 2000);
System.out.println(e.reportHours());
System.out.println("支給額 " + e.calculatePay() + "円");
}
}
実行結果は「佐藤: 所定内 40時間」「支給額 87500円」です。1日8時間までを所定内(40時間×2,000円)、超えた3時間を1.25倍(3時間×2,500円)で計算しています。
経理部門の変更による勤務時間レポートへの影響
ここで経理部門から「研修時間は別の手当で払うので、時給計算の所定内から除く」という依頼が来たとします。給与計算の担当者は、共有されている regularHours() を1行だけ直します。
private int regularHours() {
- return dailyHours.stream().mapToInt(h -> Math.min(h, 8)).sum();
+ return dailyHours.stream().mapToInt(h -> Math.min(h, 8)).sum() - trainingHours; // 経理の依頼:研修は別手当
}
入力例の研修6時間は所定内の時間に含まれている前提です。実行すると時給分の支給額は75500円になり(研修の別手当は計算に含めていません)、経理の意図どおりです。ところが、勤務時間レポートも「所定内 34時間」に変わります。レポートの仕様は誰も変えていないのに、実際に働いた所定内の40時間が34時間と報告されるようになりました。コンパイルは通ります。給与計算のテストしか書いていなければテストも通るため、給与の確認だけではこの変化を見逃します。
『Clean Architecture』はこれを、別のアクターのコードが近くに置かれていたために起きる事故として扱い、もう1つの症状として、別々のアクターの変更が同じファイルで衝突するマージの問題を挙げています。
分割後のコード:データと処理の分離・Facadeによる集約
対処は、データを業務ロジックを持たない EmployeeData(Javaのrecord)に移し、アクターごとの処理をクラスに分けることです。呼び出し側が3つのクラスを意識しなくて済むように、窓口として EmployeeFacade を置きます。次のコードは動作確認のため全クラスを1つの Main.java に置き、java Main.java で実行しました。実務ではアクターごとのクラスを別ファイルに置くことで、別々の部署の変更が同じファイルで衝突しにくくなります。
import java.util.List;
record EmployeeData(String name, List<Integer> dailyHours, int trainingHours, int hourlyWage) {}
// 経理(CFO配下)の変更だけを受ける
class PayCalculator {
int calculatePay(EmployeeData e) {
int overtime = e.dailyHours().stream().mapToInt(h -> Math.max(h - 8, 0)).sum();
return regularHours(e) * e.hourlyWage() + overtime * e.hourlyWage() * 125 / 100;
}
private int regularHours(EmployeeData e) { // 研修は別手当なので除外
return e.dailyHours().stream().mapToInt(h -> Math.min(h, 8)).sum() - e.trainingHours();
}
}
// 人事(COO配下)の変更だけを受ける
class HourReporter {
String reportHours(EmployeeData e) {
return e.name() + ": 所定内 " + regularHours(e) + "時間";
}
private int regularHours(EmployeeData e) { // 勤怠報告は研修も含める
return e.dailyHours().stream().mapToInt(h -> Math.min(h, 8)).sum();
}
}
// DBA(CTO配下)の変更だけを受ける
class EmployeeSaver {
String save(EmployeeData e) { return "INSERT INTO employee VALUES ('" + e.name() + "', ...)"; }
}
// 呼び出し側の窓口(ロジックは持たない)
class EmployeeFacade {
private final PayCalculator pay = new PayCalculator();
private final HourReporter reporter = new HourReporter();
private final EmployeeSaver saver = new EmployeeSaver();
int calculatePay(EmployeeData e) { return pay.calculatePay(e); }
String reportHours(EmployeeData e) { return reporter.reportHours(e); }
String save(EmployeeData e) { return saver.save(e); }
}
public class Main {
public static void main(String[] args) {
EmployeeData e = new EmployeeData("佐藤", List.of(8, 10, 9, 8, 8), 6, 2000);
EmployeeFacade f = new EmployeeFacade();
System.out.println(f.reportHours(e));
System.out.println("支給額 " + f.calculatePay(e) + "円");
}
}
実行結果は「佐藤: 所定内 40時間」「支給額 75500円」で、経理の変更は給与計算にだけ反映され、レポートは元の値のままです。regularHours() が2か所に分かれたのは意図した重複です。分割前は同じ式でしたが、経理の依頼で給与側だけが研修時間を引く式になり、レポート側は元の式のままです。変更を求める業務機能が違うので、このように別々に変わります。見た目が同じ処理を共通化してよいかの判断はDRY原則とは?重複してよいコードの見分け方と誤った共通化の避け方で詳しく扱っています。窓口クラスの作り方はFacadeパターンとは?窓口クラスの実装とLaravel Facadeとの違いを解説を参照してください。
SRP違反を見分けるサイン
コードを読んでSRP違反を疑う手がかりは、行数より「誰の都合で変わるか」に表れます。
- 仕様を決める部署や役割が2つ以上挙がる:「経理と人事」「業務ルールとDB設計」のように出どころが分かれ、それぞれが独立に変更を求めてくるなら、責任が混ざっています。
- private メソッドを複数のアクターの処理が共有している:上の
regularHours()と同じ構造です。共有部分の変更が片方の都合によるものなら、頼んでいない側の結果まで変わることがあります。 - 別々の案件のブランチが同じファイルで衝突する:調べる価値のある手がかりです。衝突だけでは判定できないので、2つの案件の依頼元が独立した業務機能かを確認します。
- 真偽値の引数で呼び出し元ごとに振る舞いを切り替えている:
calculate(isSummer)のようなフラグは、別の概念を1つのメソッドに相乗りさせた跡であることがあります。夏季割引と通常割引の規則を決める担当が別なら、分割を検討します。 - 役割を1文で言うと「〜と〜」になる:接続詞でつないだそれぞれの処理に、別々の変更要求が来るかを確かめるきっかけになります。
逆に、メソッドやフィールドが多いことは調べる対象を絞る手がかりにはなりますが、量だけでSRP違反とは判定できません。リスト操作を包むコレクションクラスのように、1つの関心事を守るためにメソッドが10個以上あるクラスは珍しくありません。
SRP違反を測る:git履歴と静的解析の使い分け
「誰が変更を求めるか」は、コードだけを眺めても判断しにくい情報です。まずgit履歴で変更の多いファイルと組を絞り込み、そのコミットに紐づくチケットや仕様書で依頼元を確かめる、という2段階で調べます。
git logによる同時変更の集計と変更理由の分類
次のコマンドは、今いるブランチ(HEAD)から辿れる直近12か月の履歴について、各ファイルが何回変更されたかと、同じコミットで一緒に変更されたファイルの組を数えます。git log は既定でマージコミットの差分を表示しないため、数えているのは通常のコミットだけです。src は対象ディレクトリに置き換えてください。
# ファイルごとの変更コミット数
git log --since="12 months ago" --format= --name-only -- src | sort | uniq -c | sort -rn | head -20
# 同じコミットで一緒に変更されたファイルの組
git log --since="12 months ago" --format='@@%h' --name-only -- src \
| awk '/^@@/{if(n>1)for(i=1;i<=n;i++)for(j=i+1;j<=n;j++)print f[i]" + "f[j];n=0;next}
NF{f[++n]=$0}
END{if(n>1)for(i=1;i<=n;i++)for(j=i+1;j<=n;j++)print f[i]" + "f[j]}' \
| sort | uniq -c | sort -rn | head -20
変更回数が多いファイルを見つけたら、git log --format=%s -- src/Employee.java でコミットの件名を並べ、変更理由を「給与規則」「帳票」「DB」のように分類します。分類が2種類以上に分かれるファイルが分割の候補です。逆に、別々のクラスに分けたのに毎回同じコミットで変わっている組は、分けすぎを疑う材料になります。
給与計算例へのPMD適用結果(実測)
Java向け静的解析ツールPMDには、肥大したクラスを検出するルールがあります。PMD 7.27.0のDesignルールによると、GodClassはLanzaとMarinescuの『Object-Oriented Metrics in Practice』(2006年)の検出戦略に基づき、複雑度(WMC)が高い、外部データへのアクセス(ATFD)が多い、凝集度(TCC)が低い、の3条件を満たすクラスを報告します。TooManyMethodsはメソッドが既定値の10個を超えると報告し、本体が1文以下のget・set・isで始まるメソッドは数えません。TooManyFieldsは非final・非staticのフィールド宣言が既定値の15を超えると報告します。
上の違反コード Employee.java に、GodClass・TooManyMethods・TooManyFields・CouplingBetweenObjectsの4ルールを適用して、次のコマンドを実行しました。
pmd check -d Employee.java -R category/java/design.xml/GodClass,category/java/design.xml/TooManyMethods,category/java/design.xml/TooManyFields,category/java/design.xml/CouplingBetweenObjects -f text --no-cache
結果は「Found no violations.」で、経理の変更が勤務時間レポートを壊す構造があっても、4ルールのどれにも該当しませんでした。これらのルールが測るのは肥大や複雑さ・結合の量で、変更要求の出どころではないためです。静的解析は肥大したクラスを探す網として使い、SRPの判断そのものはアクターと変更履歴で行う、という分担になります。品質指標をCIに組み込む方法はコード品質とは?測る指標と品質ゲートでCIに組み込む手順にまとめています。
やりすぎ(過剰分割)を避ける判断基準
SRPを「とにかく細かく分ける」と理解すると、1メソッドしか持たないクラスが大量にでき、1つの機能を追うのに何枚もファイルを開くことになります。Martinの2014年の記事は、原則の説明を「the reasons for change are people」(変更の理由は人である)という念押しで締めています。分割の根拠は人(アクター)の違いであり、処理の数ではありません。
- 行数だけを分割の根拠にしない:給与規則だけを扱う計算クラスが300行あっても、それだけではSRP違反になりません。読みやすさや複雑度を理由に分けるのは別の判断で、SRPを根拠にする必要はありません。
- git履歴で毎回一緒に変わる組は、統合を検討する:分けた結果、毎回2〜3ファイルを同時に直しているなら、その変更が同じ業務知識の修正かどうかを確かめます。同じ規則を複数の場所に書き直しているなら、分けすぎです。実装とテストのように役割が違うファイルが一緒に変わるのは正常です。
- まだ決まっていない機能のために複雑さを足さない:「将来ほかの部署も使うかもしれない」という想定だけでクラスや抽象を増やすのは、KISS原則(KISSの法則)とは?意味・由来とシンプルな設計の実践やYAGNI(今必要でないものは作らない)と衝突します。Martin FowlerはYagniの解説で、YAGNIが対象にするのは想定上の機能のための作り込みで、変更しやすくするための手入れは対象外だと書いています。今あるコードを読みやすく分けるリファクタリングまで控える必要はありません。
- ほとんど変わらない処理の同居は許容する:定数の読み込みや小さな整形処理を厳密に切り出しても、得るものはわずかです。
よくある失敗は、単独で再利用する予定も無いのに、検証処理を UserNameValidator・UserEmailValidator・UserAgeValidator のように項目ごとに刻む分け方です。各Validatorが他の画面で単独に再利用されず、変更履歴でも毎回同じ案件で一緒に直されているなら、UserValidator 1つにまとめたほうが変更は1か所で済みます。項目ごとに別の画面やAPIから使われ、規則が個別に変わるなら、分けたままにします。
他のSOLID原則との関係
SOLIDは5つの原則の頭文字です。SRPで責任の境界が決まっていると、残りの原則を当てはめる場所もはっきりします。
| 原則 | 要点 | SRPとのつながり |
|---|---|---|
| S:単一責任 | 変更理由を1つにする | 境界を決める出発点 |
| O:開放閉鎖 | 拡張に開き修正に閉じる | 拡張点を責任ごとに置ける |
| L:リスコフの置換 | 派生型は基底型と置換可能 | 継承の責任範囲を絞る |
| I:インターフェース分離 | 使わないメソッドに依存させない | 利用者側から見たSRP |
| D:依存性逆転 | 抽象に依存させる | 分けたクラスを疎に結ぶ |
SRPで分けたクラス同士は、まだ具体的なクラスに直接依存しています。差し替えられる形にするには、依存性逆転の原則で抽象を挟みます(依存性の注入(DI)と依存性逆転の原則(DIP)の違い|コード例で使い分け)。機能追加のたびに既存クラスを書き換えずに済む形は、オープンクローズドの原則(OCP)とは?メイヤー版とマーチン版の違いで扱っています。継承で責任を引き継ぐときの注意は継承とは?オブジェクト指向での使いどころ・委譲との使い分け・アンチパターンを参照してください。
よくある質問
ITやプログラミングで「SRP」とは何の略ですか?
ソフトウェア設計の文脈では、Single Responsibility Principle(単一責任の原則)の略で、SOLID原則の1つ目を指します。ほかの分野にも同じ略語があり、認証方式のSecure Remote Password(RFC 2945)や、流通業の「棚にそのまま陳列できる包装」を指すShelf Ready Packagingなどがあります。クラス設計やSOLIDの話題で出てくるSRPは単一責任の原則です。
単一責任の原則と単一責務の原則は違うものですか?
同じ原則です。responsibility を「責任」と訳すか「責務」と訳すかの違いで、書籍や記事によって表記が分かれています。意味はどちらも「モジュールを変更する理由を1つにする」です。
SRPはやりすぎると問題ですか?
問題になります。同じアクターの都合で一緒に変わる処理まで分けると、1つの変更で複数のファイルを直すことになり、SRPが防ぎたかった「変更の波及」をかえって増やします。分割の根拠は独立した変更要求があるかどうかに置きます。git履歴で毎回一緒に変わる組を見つけたら、同じ業務知識を複数の場所で直していないかを確かめ、そうなら統合を検討します。
関数の単一責任とクラスのSRPは同じですか?
別のものです。関数については「1つのことだけをする」という原則があり、大きな関数を分けるときに使います。MartinはこれをSRPとは区別しており、SRPはクラスやモジュールが「誰の変更要求に応えるか」を問う原則です。Python・C#・TypeScriptなど言語を問わず、この区別は変わりません。
SRPとDRY原則がぶつかるときはどちらを優先しますか?
変更を求めるアクターが違うなら、見た目が同じ処理でも共通化しません。給与計算と勤務時間レポートの regularHours() のように、同じ式でも別々の部署の都合で変わる処理を1つにすると、片方の変更がもう片方を壊します。DRYが対象にするのは同じ知識の重複で、たまたま同じ形のコードではありません。