単一責任の原則
第1章で見たOrderクラスは、注文明細の管理、合計金額の計算、在庫確認、確認メールの送信、データベースへの保存、請求書PDFの生成という6つの役割を持つgod classでした。 この章では、実際にOrderクラスを分割していきます。
god classの兆候
分割の前に、god classをどう見分けるかを整理します。 次のような特徴があれば、god classを疑ってよい兆候です。
- コンストラクタやメソッドの引数が多い
- クラス名が「Manager」「Helper」のような汎用的な言葉になっている、あるいは複数の名詞を含んでいる
- クラスの中で扱っている関心事の種類が多い(データの永続化、通知、金額計算、外部フォーマット生成など)
- そのクラスを変更する理由を、一言で説明できない
第1章のOrderクラスは、最後の項目に当てはまっていました。 「注文明細が変わったから」「メール文面が変わったから」「在庫テーブルの構造が変わったから」「請求書のフォーマットが変わったから」と、変更理由が4つも5つも考えられる状態だったのです。
単一責任の原則とは
クラスが変更される理由は1つだけであるべきだ、という考え方を単一責任の原則(Single Responsibility Principle、SRP)と呼びます1。 Robert C. Martinが提唱した原則です。
ここで言う「理由」とは、「誰が、どんな都合でそのクラスを変更するか」という視点です。 Orderクラスの場合、次のように変更理由を担当領域ごとに分けられます。
- 注文明細の持たせ方が変わる: 注文そのものの仕様変更
- メール文面が変わる: マーケティングやカスタマーサポートの都合
- 在庫テーブルの構造が変わる: 在庫管理システムの都合
- 請求書のフォーマットが変わる: 会計・経理の都合
これらは互いに無関係な理由です。 1つのクラスに同居していると、ある理由での変更が、無関係な他の処理にまで影響するリスクを持ち続けます。
Orderクラスを分割する
第1章の予告どおり、責務ごとにクラスを分割します。
class Order
{
private array $items = [];
public function __construct(
private readonly string $customerEmail,
) {
}
public function addItem(string $name, int $price, int $quantity): void
{
$this->items[] = [
'name' => $name,
'price' => $price,
'quantity' => $quantity,
];
}
public function calculateTotal(): int
{
$total = 0;
foreach ($this->items as $item) {
$total += $item['price'] * $item['quantity'];
}
return $total;
}
public function getItems(): array
{
return $this->items;
}
public function getCustomerEmail(): string
{
return $this->customerEmail;
}
}
class StockChecker
{
public function hasEnoughStock(Order $order): bool
{
foreach ($order->getItems() as $item) {
$stock = Database::query(
'SELECT quantity FROM stocks WHERE name = ?',
[$item['name']]
);
if ($stock < $item['quantity']) {
return false;
}
}
return true;
}
}
class OrderMailer
{
public function sendConfirmation(Order $order): void
{
$body = "ご注文ありがとうございます。合計金額は{$order->calculateTotal()}円です。";
mail($order->getCustomerEmail(), '注文確認', $body);
}
}
class InvoicePdfGenerator
{
public function generate(Order $order): string
{
$total = $order->calculateTotal();
// PDF生成処理(省略、$totalを請求書の合計欄に反映)
return "invoice-{$order->getCustomerEmail()}.pdf";
}
}
Orderクラスには、注文明細の保持と合計金額の計算という、注文そのものの責務だけが残りました。 在庫確認・メール送信・PDF生成は、それぞれ別のクラスに移っています。 データベースへの保存はこの章では扱いません。 永続化の設計は、Laravelの実践編である第2部で改めて取り上げます。
分割してわかること
1つ、正直に確認しておきたいことがあります。
この分割によって、calculateTotalへの依存箇所そのものが減ったわけではありません。
OrderMailerもInvoicePdfGeneratorも、それぞれ$order->calculateTotal()を呼んでいます。
送料計算のロジックを変更すれば、この2箇所を確認する必要があります。
分割によってクラスは整理されましたが、calculateTotalへの依存という結合そのものは残ったままです。
分割によって変わったのは、それぞれのクラスが担う関心事が1つに絞られたことです。 在庫確認のロジックを変えたいときは、StockCheckerだけを読めば済みます。 メール文面を変えたいときは、OrderMailerだけを見ればよく、在庫確認やPDF生成のコードを読み飛ばす必要がありません。 StockCheckerを他の画面でも使い回したいときも、Orderクラス全体を持ち出さずに、StockChecker単体を呼び出せます。
calculateTotalのような、複数のクラスから参照される値そのものへの依存を減らす設計は、この先の章で扱います。
まとめ
- god classは、コンストラクタ・メソッドの引数の多さや、変更理由の説明しにくさから見分けられる
- 単一責任の原則は、クラスが変更される理由を1つに絞るという考え方
- 責務ごとにクラスを分割すると、各クラスが担う関心事が1つに絞られ、変更時に読むべき範囲が狭まる
- 分割は依存関係そのものを減らすわけではない。値への依存を減らす設計は別の技法が必要
次に読む
次章では、継承とコンポジションを扱います。 is-a・has-aの関係と、なぜ継承よりコンポジションが優先されるかを見ていきます。
練習問題
次のReportGeneratorクラスが抱えている、変更理由を3つ挙げてください
class ReportGenerator
{
public function generate(array $salesData): void
{
// 集計処理
$total = array_sum(array_column($salesData, 'amount'));
// CSV形式に変換
$csv = "date,amount\n";
foreach ($salesData as $row) {
$csv .= "{$row['date']},{$row['amount']}\n";
}
// ファイルに保存
file_put_contents('/tmp/report.csv', $csv);
// Slackに通知
$this->postToSlack("レポートを生成しました。合計: {$total}円");
}
private function postToSlack(string $message): void
{
// Slack API呼び出し(省略)
}
}
解答例
少なくとも、次の3つの変更理由が考えられます。
- 集計ロジックが変わる(合計以外の指標を追加したいなど、集計担当の都合)
- 出力フォーマットが変わる(CSVからJSONに変えたいなど、データ連携先の都合)
- 通知方法が変わる(SlackからEmailに変えたいなど、通知インフラの都合)
これらは、集計・フォーマット変換・保存・通知という4つの処理が1つのメソッドに同居している状態です。 Orderクラスと同じように、それぞれ別のクラスに分割できます。