メインコンテンツまでスキップ

単一責任の原則 — 「変更の理由」とは誰のことか

SRP の定義は短く、覚えやすく、そしてそのままでは使えません。「クラスが変更される理由は 1 つであるべき」と言われたとき、何を「1 つの理由」と数えるかが決まらないからです。

同じクラスを見て、ある人は「これは注文に関する処理だから 1 つの責任だ」と言い、別の人は「計算と保存と通知で 3 つの責任がある」と言います。どちらも定義には反していません。定義が粒度を決めていないのです。

この章では、Martin 自身がこの弱点にどう答えたかを原典で確認し、そのうえで分割しすぎたときに何を失うかを扱います。

原典は何を言ったか — この原則は人についてのもの

最も広く引用される定式化はこれです。

each software module should have one and only one reason to change. (ソフトウェアモジュールは、変更する理由をただ 1 つだけ持つべきである。)

出典は Martin が 2014 年に書いた記事です1。同じ記事で、彼はこう言い換えています。

Gather together the things that change for the same reasons. Separate those things that change for different reasons. (同じ理由で変更されるものは集めよ。異なる理由で変更されるものは分けよ。)

ここまでは「理由」という語のままです。しかし記事の中ほどで、Martin は「理由」が何を指すのかを明示します。

This principle is about people. (この原則は人についてのものである。)

そして、変更の要求がどこから来るかで責任を数えます。

when changes are requested, those changes can only originate from a single person, or rather, a single tightly coupled group of people representing a single narrowly defined business function (変更が要求されるとき、その要求は 1 人の人物、より正確には 1 つの狭く定義された業務機能を代表する、密に結びついた 1 つの人々の集団からのみ生じるようにしたい)

これが定義の転換点です。 「理由」はコードの中にある性質ではなく、コードの外にいる人を指していた、という宣言です。責任を数えるとき見るべきなのはクラスの中身ではなく、そのクラスに変更を要求してくる相手が何種類いるかです。

Martin が挙げる例は、給与計算・勤怠報告・保存の 3 メソッドを持つ Employee クラスです。それぞれの変更を要求するのは財務・業務・技術の担当役員で、3 人の別々の人物です。そして彼はこう書きます。

we don't want to get the COO fired because we made a change requested by the CTO. (技術担当役員から要求された変更のせいで、業務担当役員が職を失うようなことは避けたい。)

やや誇張のある言い方ですが、SRP が下げようとしているコストがここに出ています。別々の人が別々の目的で出した要求が、同じコードに着地することの危険です。

この「変更を要求する人や集団」を アクター と呼ぶ定式化は、Martin が 2017 年の著書『Clean Architecture』で示したものとして知られています。ただし本連載は同書の現物を確認していないため、アクターという語の帰属は二次情報として扱います。2014 年の記事に "This principle is about people." がある以上、考え方そのものは書籍以前から一次ソースで追えるので、以下は記事の記述に沿って進めます。

なぜ守るのか — 巻き添え変更を止める

SRP が下げているのは行数や複雑さではありません。意図しない巻き添え変更のコストです。

図書館の職員記録を例にします。次のクラスは、給与の計算・勤務時間の集計・記録の保存を 1 つにまとめています。

❌ Bad: 3 種類の要求元が同じクラスに着地する
class StaffMember {
constructor(
private readonly name: string,
private readonly hourlyWage: number,
private readonly workedHours: number[],
) {}

// 2 つのメソッドが共有する合計時間の計算
private totalHours(): number {
return this.workedHours.reduce((sum, h) => sum + h, 0);
}

// 経理からの要求で変わる
calculatePay(): number {
return this.totalHours() * this.hourlyWage;
}

// 現場責任者からの要求で変わる
summarizeHours(): string {
return `${this.name}: ${this.totalHours()} 時間`;
}

// 情報システム担当からの要求で変わる
save(): void {
// 記録を永続化する
}
}

3 つのメソッドは同じ workedHours を読んでいるので、一見すると関連が強く見えます。実際に危ないのはそこです。

経理から「残業時間は 1.25 倍で計算してほしい」という要求が来たとします。calculatePay を直すために合計時間の求め方に手を入れると、同じ計算を使っている summarizeHours の出力も変わります。現場責任者は何も頼んでいないのに、見ている数字が変わるのです。

分けるとこうなります。

✅ Good: 要求元ごとに分ける
class StaffMember {
constructor(
readonly name: string,
readonly hourlyWage: number,
readonly workedHours: readonly number[],
) {}
}

class PayrollCalculator {
// 経理の要求だけがここに来る
calculate(staff: StaffMember): number {
const total = staff.workedHours.reduce((sum, h) => sum + h, 0);
return total * staff.hourlyWage;
}
}

class TimesheetReport {
// 現場責任者の要求だけがここに来る
summarize(staff: StaffMember): string {
const total = staff.workedHours.reduce((sum, h) => sum + h, 0);
return `${staff.name}: ${total} 時間`;
}
}

class StaffRecordStore {
// 情報システム担当の要求だけがここに来る
save(staff: StaffMember): void {
// 記録を永続化する
}
}

注意してほしいのは、合計時間を求める処理が 2 か所に重複することになったという点です。重複を避けて共通化すると、消したはずの結合が戻ります。ここでは重複を受け入れることが正しい判断です。

「同じコードだから 1 つにまとめる」と「同じ理由で変わるからまとめる」は別の基準です。SRP が採るのは後者です。見た目が同じでも、変更の要求元が違うなら分けます。

どう誤用されるか — 分割は無料ではない

SRP の誤用は「守らない」ではなく「守りすぎる」方向に出ます。責任という語が主観的なので、分ける理由はいくらでも見つかるからです。

典型的な行き着く先は、メソッドを 1 つだけ持つクラスが並ぶ状態です。

❌ Bad: 分割の基準が「処理が違うから」になっている
class WorkedHoursSummer {
sum(hours: readonly number[]): number {
return hours.reduce((total, h) => total + h, 0);
}
}

class OvertimeExtractor {
extract(hours: readonly number[]): readonly number[] {
return hours.filter((h) => h > 8);
}
}

class WageMultiplier {
multiply(hours: number, wage: number): number {
return hours * wage;
}
}

class PayrollCalculator {
constructor(
private readonly summer: WorkedHoursSummer,
private readonly extractor: OvertimeExtractor,
private readonly multiplier: WageMultiplier,
) {}

calculate(staff: StaffMember): number {
// 3 つのクラスを順に呼ぶだけ
const total = this.summer.sum(staff.workedHours);
const overtime = this.summer.sum(this.extractor.extract(staff.workedHours));
return this.multiplier.multiply(total + overtime * 0.25, staff.hourlyWage);
}
}

このクラス群を要求している人は誰でしょうか。全員、経理です。 残業の判定基準が変われば OvertimeExtractor が変わり、時給の扱いが変われば WageMultiplier が変わりますが、要求を出しているのは同じ相手です。アクターが 1 つなら、SRP は分割を要求しません。

分割によって失うものは 2 つあります。

1 つめは、変更が 1 か所に閉じなくなることです。 「残業は 7 時間超から、割増は 30% へ」という 1 つの要求を通すために、OvertimeExtractor と、割増率を持つ PayrollCalculator の両方を開くことになります。分割前なら 1 つのメソッドの中で済んだ変更です。

2 つめは、処理の流れが読めなくなることです。 給与がどう計算されるかを知るために、4 つのクラス定義を行き来する必要があります。名前は増えましたが、名前が増えたこと自体は理解を助けません。

Martin 自身も、原則を過剰に適用することへの警告を書いています。インターフェース分離の原則についてですが、同じ構図です。

As with all principles, care must be taken not to overdo it. (すべての原則がそうであるように、やりすぎないよう注意しなければならない。)

出典は 2000 年の文書です2「すべての原則がそうであるように」と書いている点が重要です。 原則には守りすぎという状態が存在する、というのが提唱者側の認識でした。

いつ破っていいのか

SRP を適用しない判断が妥当になるのは、次の条件が揃うときです。

アクターが 1 つしかいないとき。 変更を要求してくるのが常に同じ相手なら、分けなくても巻き添えは起きません。分けたことで得られるものが無く、間接参照だけが増えます。

変更頻度がゼロに近いとき。 SRP が防ぐのは変更時の事故です。何年も変わっていない領域では、防ぐべき事故が起きません。「いつか変わるかもしれない」で分けるのは、次章で扱う予測的抽象化と同じ失敗です。

分割の間接コストが巻き添えコストを上回るとき。 規模が小さいうちは、1 つのファイルを読めば全体が分かることの価値が勝ちます。判断材料は「変更が 1 ファイルに閉じているか、常に複数ファイルに散るか」です。

いずれの場合も、分けない判断は後から取り消せます。 アクターが増えたときに分ければよいのです。逆に、分けすぎたものを統合するのは、呼び出し元がすべて散っている分だけ手間がかかります。迷ったときは分けない側に倒す方が、取り消しの費用が安く済みます。

TypeScript での現在地 — クラスに限らない

Martin の定式化は「software module」であって「class」ではありません。2014 年の記事でも、彼はモジュールという語を使っています。

TypeScript では、責任の単位がクラスとは限りません。

モジュール (ファイル) の単位で適用できます。 関数だけを export するファイルでも、そこに集まった関数の要求元が複数あれば、SRP の対象になります。判断基準は同じで、「このファイルに変更を要求してくるのは何種類の相手か」を見ます。

型定義にも同じ問いが立ちます。 1 つの型が API のレスポンス形状と画面の表示項目と保存時の入力を兼ねているとき、3 つの要求元が同じ型に着地しています。API 側の都合で項目が増えると、画面と保存の型も動きます。

❌ Bad: 1 つの型が 3 つの要求元を持つ
type StaffRecord = {
id: string;
name: string;
hourlyWage: number; // 経理が使う
workedHours: number[]; // 現場責任者が使う
updatedAt: string; // 情報システム担当が使う
};

型を分けるか、Pick で用途ごとの視点を切り出すかは、次の判断になります。ここでは「クラスだけの話ではない」という点を確認するにとどめ、型の切り出しはインターフェース分離の章で扱います。

まとめ

  • SRP の「変更の理由」は、コードの性質ではなく変更を要求してくる人を指す。Martin は 2014 年の記事で "This principle is about people." と明示している
  • したがって責任を数えるときは、クラスの中身ではなく要求元が何種類あるかを見る
  • SRP が下げるのは行数ではなく、別々の要求が同じコードに着地することによる巻き添え変更のコスト
  • 見た目が同じコードでも、要求元が違えば重複を残す。「同じコードだからまとめる」は SRP の基準ではない
  • 誤用は守りすぎの方向に出る。アクターが 1 つなら SRP は分割を要求しない
  • 分けない判断は後から取り消せるが、分けすぎたものの統合は高くつく

次に読む

次章では開放閉鎖の原則を扱います。この章で触れた「いつか変わるかもしれない」という予測が、OCP の文脈でどう失敗するかを見ていきます。

練習問題

次の分割は SRP の適用でしょうか、それとも過剰分割でしょうか。アクターを特定して判断し、判断が変わる条件も述べてください

図書館の貸出記録を扱うコードが、次の 2 クラスに分かれています。

class LoanDurationCalculator {
// 貸出期間を計算する
calculate(borrowedAt: Date, returnedAt: Date): number {
const ms = returnedAt.getTime() - borrowedAt.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
}

class OverdueChecker {
constructor(private readonly calculator: LoanDurationCalculator) {}

// 延滞しているかを判定する
isOverdue(borrowedAt: Date, returnedAt: Date, limitDays: number): boolean {
return this.calculator.calculate(borrowedAt, returnedAt) > limitDays;
}
}

解答例

現状の情報だけでは、どちらとも言えません。 判断はアクターの数で決まり、それはコードからは読み取れないからです。

過剰分割と判断される場合: 貸出期間の数え方と延滞の判定基準が、どちらも同じ相手 (たとえば図書館の運用担当) の要求で変わるなら、アクターは 1 つです。この場合 SRP は分割を要求しません。分けたことで、「貸出期間は返却日を含めない、かつ延滞は 1 日目から」という 1 つの要求のために 2 つのクラスを開くことになります。

適用と判断される場合: 貸出期間の数え方が会計年度や統計の都合で決まり (経理や統計担当の要求)、延滞の判定が利用者向けの運用ルールで決まる (運用担当の要求) なら、アクターは 2 つです。この場合は分けたままにします。片方の要求で他方の挙動が変わることを防げます。

判断に必要な追加情報: 「過去 1 年で、この 2 つのどちらかを変更したとき、要求を出したのは誰か」です。変更履歴があるなら、要求元が分かれているかを実際に確認できます。履歴が無いなら、分けない側に倒しておき、要求元が分かれた時点で分ける方が取り消しの費用が安く済みます。


Footnotes

  1. 出典: The Single Responsibility Principle (Robert C. Martin, 2014-05-08)。本文中の引用はすべてこの記事からのものです。記事は給与計算・勤怠報告・保存の 3 メソッドを持つ Employee クラスを例に使っており、本章のコード例はその構造を図書館の職員記録に置き換えたものです。

  2. 出典: Robert C. Martin, "Design Principles and Design Patterns" (2000)、インターフェース分離の原則の節。参照した PDF については第 1 章の脚注を参照してください。