🍓

SOLID原則#1 〜堅牢な「レンガ」が偉大な建築を作る(SRP編)〜

に公開

前回の振り返り(街づくりの視点)

前回まで、私たちは「パッケージ」という大きな単位での設計原則を学びました。

これは、いわば 「都市計画」 です。

「どの区画(パッケージ)に何を配置し、道路(依存関係)をどう繋ぐか」という、マクロな視点でのルールでした。

新たな課題(ズームイン)

しかし、いくら区画整理が完璧な美しい街でも、そこに建っている建物自体がボロボロだったり、柱が腐っていたりしたらどうでしょうか?

その街(アプリケーション)は、いずれ崩壊します。

パッケージの中身を構成しているのは 「クラス」や「関数」 という最小単位の素材です。

優れたアーキテクチャは、優れた区画整理(パッケージ原則)だけでなく、一つ一つの建物やレンガの品質(クラス設計)によって支えられています。

オブジェクト指向原則(SOLID原則) = 家造りの視点

そこで登場するのがSOLID原則です。

パッケージ原則が「アーキテクチャを俯瞰する人のためのルール」だとしたら SOLID原則は「現場でコードを書く私たち(プログラマ)が、毎日向き合うためのルール」 です。

「このクラスの役割は大きすぎないか?」

「この関数を変更しても、他に影響が出ないか?」

といった、より ミクロな「コードレベルの粒度」 での判断基準を与えてくれます。

SOLID原則とパッケージ原則の関係性

実は、SOLID原則とパッケージ原則は、まったく別物ではありません。

物理法則がリンゴにも地球のような巨大な物体にも同じように働くのと同じで、「高凝集・低結合」という本質は、クラス(小)でもパッケージ(大)でも変わりません。

つまり、SOLID原則を学ぶことは、パッケージ原則をより深く理解することにも繋がります。

【SRP】単一責任の原則 〜「変更の理由」は、ひとつだけ〜

SRP(Single Responsibility Principle): 「クラスはたった一つの変更の理由を持つべきである」

ここでいう「変更の理由」というのは、ズバリ責務の範囲をさします。

ここで、この責務の範囲を意識しない開発の場合、どのような災難が降りかかってくるのかを、Eコマースを例に具体的に2タイプのケースを見ていきましょう。

SRP違反がもたらす災難:責務を「まとめすぎた」ケース

「注文処理」、「請求書生成」や「配送処理」などがあります。

ここで、以下のようにまとめられていたとします(UML参照)

  • クラス名:OrderService
  • メソッド1:calculateTotals()
    • 役割:カート情報を受け取り、商品小計、割引、消費税、最終合計金額を計算する。
  • メソッド2:placeOrder()
    • 役割:注文をDBに保存→在庫の引き当て→注文ステータスを「処理中」にする→倉庫管理システムに「発送指示」を通知。

一見、「OrderService」としてまとまって見えますね。

どちらも「注文」というドメインの中核となるビジネスロジックです。
上記2つのメソッドが並んでいても、違和感をあまり感じません。
むしろ、これこそが「注文」の責務だとさえ思えます。

隠された「時限爆弾」:変更の理由が違う

ここでSRPの「変更の理由」という視点で、このクラスを分析してみましょう。

これを言い換えると、処理が誰の都合によって変更されうるのかを想定するのです。

このようなシステムの外部からシステムを利用する主体をアクターと呼びます。

calculateTotals を変更したいのは誰か?

  • 経理部: 「消費税の計算ロジックが変わりました」(例:消費税の減税)

placeOrder を変更したいのは誰か?

  • 物流・倉庫管理部: 「在庫引当のルールを変更したい」「倉庫の基幹システムが新しくなったので、通知(発送指示)の方法を変えたい」

上記の分析から、OrderServiceは「経理」と「物流」という全く異なるアクターが存在する可能性があることが分かります。

上記のアクターを加えた場合のUMLを示します。

ここで例えば、新しい税率への対応のためにcalculateTotalsを修正し、デプロイしました。

しかし、その変更がOrderServiceの共通ロジックに影響を与え、全く無関係なはずの「倉庫への発送方法を変える」という実装が止まってしまいました。

物流の機能を実装するためには、calculateTolatalsを含めた経理側が要求した機能の実装の変更内容についても全て理解し、肥大化した単体テストの実行や物流側の実装との統合などのプロセスを踏む必要があるからです。

つまり、コードの取り合いが発生します。

かなり無駄な時間が生まれることが分かりますね。

解決策(良い例)

上記のアクターの存在に基づいてクラスを分割するのが良いでしょう。

変更の理由(経理 vs. 物流)ごとにクラス(CalculateOperation vs. ShipmentService)とパッケージが分離されました。

このようにSRPに従うことにより、以下のような嬉しいことが起きます。

低結合度: BillingSystemLogisticsの存在を、ShipmentServiceAccountingの存在を知る必要がありません。

依存の分散: かつてOrderServiceに集中していた依存が、CalculateOperationShipmentServiceに分散しました。経理部の変更は、物流部に影響を与えません。

SRP違反がもたらす災難:責務を「分けすぎた」ケース

以下のようなクラスがあるとします。

  • Orderクラス: シンプルなデータ入れ。Items(商品リスト)やTotalPrice(合計金額)のフィールドを持つが、ロジック(メソッド)は持たない
  • OrderActionクラス: Orderに商品を追加するロジック(AddItem)を持つ
  • OrderCalculatorクラス: Orderの合計金額を計算するロジック(CalculateTotal)を持つ

隠された「時限爆弾」:密結合

上記のクラス群を一見すると「ロジック」と「データ」が分離され、責務が分かれているように見えます。

しかし、よく考えてみると、商品を追加するロジックが実行された後は必ず合計金額を計算する必要がありますよね。

それにも関わらず、上記のようなクラス単位で分割されている場合、OrderCalculator.CalculateTotal()を呼び忘れた結果、Orderオブジェクトは商品が追加されたが、合計金額は古いままと言った事態が起きかねません。

このようなリスクを回避するために、開発者は不整合のチェック等の無駄な防衛コードを書く羽目になります。

例えば、以下の関数は請求書を作成するという責務ですが、引数のOrderオブジェクトがOrderCalculator.CalculateTotalが呼ばれたのかどうかが不明です。

なのでGenerateInvoice内でOrderCalculator.CalculateTotalを呼び出すことで、確実に正しい金額を求めています。

func GenerateInvoice(order Order) {
    
    // ★★★ここが「無駄な防衛コード」★★★
    // 「渡された order.TotalPrice は信用できない。
    //   万が一、不整合が起きていたら大問題だ。
    //   安全のために、ここで『必ず』再計算しよう」
    //
    recalculatedTotal := OrderCalculator.CalculateTotal(order.Items)
    
    // 信用できない古い金額は無視し、
    // 自分で計算した金額で請求書を作る
    invoice.Total = recalculatedTotal 
    invoice.SendToCustomer()
}

上記のコードをみると、請求書を作成するメソッドを持つクラスと合計金額を求めるクラスOrderCalculatorと実質的な密結合になっていることがわかります。

SRPによる解決

ここで、Orderクラスの責務は何だったかどうかを考えてみください。

Orderクラスは「商品リストを管理すること」でもなければ「合計金額を計算すること」でもありません。

真の単一責務は「注文の一貫性・整合性を守る」ことです。

上記の責務に従う場合、以下のようにOrderクラスを修正するのが適切になります。

  • AddItem()という1つの公開メソッドのみを持つ
  • OrderActionOrderCalculatorは存在しない
  • AddItem()メソッドの内部で、「①商品をリストに追加する処理②合計金額を再計算する処理の両方を不可分な1トランザクションとして実行します。

上記のことから、「商品の追加」と「合計金額の計算」は注文の一貫性を守るための密接不可分な処理だったことが分かります。

ケース2ではこの部分を無理に分離させたことで、密結合による時限爆弾を作ってしまっていたわけです。

SRPの結論:変更の「理由」だけを見よ

ここまで、「責務をまとめすぎた」ケースと「責務を分けすぎた」ケースの2つの「災難」を見てきました。

一見、これらは正反対の問題(「まとめすぎ」vs「分けすぎ」)に見えますが、根本的な原因は同じです。

それは 「変更の理由(アクター)」 という視点を持たずに、クラスの責務を決定してしまったことです。

「まとめすぎ」の災難(OrderServiceの例の再掲):

原因: 「経理部」と「物流部」という 2つの異なる「変更の理由(アクター)」 を1つのクラスに同居させた。

結果: 「コードの取り合い」や「時限爆弾」という災難を招いた。

「分けすぎ」の災難(Orderデータ/OrderCalculatorの例の再掲):

原因: 「注文の一貫性を守る」という、たった1つの責務を無理やり2つのクラスに分離した。

結果: データが不整合な「時限爆弾」となり、「無駄な防衛コード」を蔓延させた。

開発者が常に問い続けるべき、ただ一つの質問

SRPが本当に言いたいのは、「クラスを小さくしろ」という単純なルールではありません。

「プロダクトの文脈に応じて、責務の範囲を正しく判断する」 ことこそが、この原則の真髄です。

そのための、最も強力なプラクティスが、開発中に常にこの問いを投げかけることです。

「このクラスが変更される『理由』は、本当にたった一つか?」

「はい」と即答できないクラスを見つけた時こそ、あなたが立ち止まり、そのクラスの「責務の範囲」を再定義すべきサインなのです。

SRPとパッケージ原則:ミクロとマクロの凝集度

最後に、この「変更の理由」という視点に、見覚えはありませんか?

実は、SRPはパッケージ原則の CCP(共通閉鎖の原則)とCRP(共通再利用の原則) の考え方と本質的に全く同じです。

CCP/CRPは パッケージ(マクロ)レベル で「変更の理由」や「再利用の単位」をまとめようとしました。

https://zenn.dev/hashidev/articles/55ca5fa12d56f7

SRPはそのクラス(ミクロ)レベル版なのです。

このように、SOLID原則はパッケージ原則と密接に連携し、システムの「マクロな構造」と「ミクロな実装」の両面から、変更に強い堅牢な設計を実現するのです。

Discussion