Zenn
Open5

読書メモ : Clean Architecture 達人に学ぶソフトウェアの構造と設計

oknokn

SOLID原則

SRP(Single Responsibility Principle) 単一責任の法則

「モジュールを変更する理由はたったひとつだけであるべきである」

「モジュールは一つのことだけを行うべき」と原則名から捉えてしまいがちだが、本質は「モジュールはたった一つのアクター(変更を望む人たちのグループ)に対して責務を負うべき」であるということ。

ex) 給与システムにおけるEmployeeクラスがあるとする。下記の3つのメソッドを持つ。

  • 経理部門が規定するcalculatePayメソッド
  • 人事部門が規定するreportHoursメソッド
  • データベース管理者が規定するsaveメソッド

これらのメソッドを全てEmployeeクラスに入れてしまうと全てのアクターを結合してしまうこととなり、単一責任の原則に反する。

  • 別チームの操作が他チームに影響を及ぼしてしまう。

    • ex) calculatePay、reportHoursで使用している労働時間の計算ロジックが同じでここを一つのメソッドに切り出した場合など

    解決策

facadeを使用してデータを関数から切り離す

  • EmpoloyeeOperationsEmployeeDataに変更を加えてもEmployeeはインターフェースを持っているだけなので影響がない
  • 異なる責任が異なる場所に分けられて各機能が独立していることで、将来的に新しい機能を追加したり、既存機能を変更するのが容易になる。また、各メソッドのテストもしやすくなる。
package main

import (
    "fmt"
)

// EmployeeData 構造体(データのみを保持)
type EmployeeData struct {
    ID     int
    Name   string
    Hours  int
    Salary float64
}

// EmployeeService インターフェース(操作を定義)
type EmployeeService interface {
    CalculatePay(hourlyRate float64) float64
    ReportHours() int
    Save()
}

// EmployeeOperations 構造体(実際の操作を実装)
type EmployeeOperations struct {
    data *EmployeeData
}

// CalculatePay メソッド(給与を計算)
func (eo *EmployeeOperations) CalculatePay(hourlyRate float64) float64 {
    eo.data.Salary = float64(eo.data.Hours) * hourlyRate
    return eo.data.Salary
}

// ReportHours メソッド(勤務時間を報告)
func (eo *EmployeeOperations) ReportHours() int {
    return eo.data.Hours
}

// Save メソッド(データを保存)
func (eo *EmployeeOperations) Save() {
    fmt.Printf("Employee saved: ID=%d, Name=%s, Hours=%d, Salary=%.2f\n",
        eo.data.ID, eo.data.Name, eo.data.Hours, eo.data.Salary)
}

// Employee 構造体(Facadeとして操作を委譲)
type Employee struct {
    service EmployeeService
}

// NewEmployee コンストラクタ
func NewEmployee(id int, name string, hours int) *Employee {
    data := &EmployeeData{ID: id, Name: name, Hours: hours}
    operations := &EmployeeOperations{data: data}
    return &Employee{service: operations}
}

// Facadeメソッド群
func (e *Employee) CalculatePay(hourlyRate float64) float64 {
    return e.service.CalculatePay(hourlyRate)
}

func (e *Employee) ReportHours() int {
    return e.service.ReportHours()
}

func (e *Employee) Save() {
    e.service.Save()
}

// メイン関数
func main() {
    emp := NewEmployee(1, "John Doe", 40)

    salary := emp.CalculatePay(20.5)
    fmt.Printf("Calculated Salary: %.2f\n", salary)

    hours := emp.ReportHours()
    fmt.Printf("Reported Hours: %d\n", hours)

    emp.Save()
}
oknokn

SOLID原則

OCP(Open Closed Principle) オープン・クローズドの法則

「ソフトウェアの構成要素は拡張に対しては開いていて、修正に対しては閉じていなければならない」

  • ソフトウェアの振る舞いは既存の成果物を変更せずに拡張できる様にするべき
    このためにシステムをコンポーネントに分解して依存関係を階層構造にする。そして、上位レベルのコンポーネントが下位レベルのコンポーネントの変更の影響を受けない様にするべきなのである」

  • インターフェースを用いることで上位と下位の依存関係を逆転させることができる

ダメな例

discountTypeが増えるたびにCalculatePriceメソッドに改修が必要になる

// 割引を適用するクラス
type PriceCalculator struct{}

// 割引を適用するメソッド
func (p *PriceCalculator) CalculatePrice(price float64, discountType string) float64 {
    if discountType == "fixed" {
        return price - 10 // 固定割引
    } else if discountType == "percentage" {
        return price * 0.9 // パーセンテージ割引
    }
    return price // 割引なし
}

func main() {
    calculator := &PriceCalculator{}
    fmt.Println("Price after fixed discount:", calculator.CalculatePrice(100, "fixed"))
    fmt.Println("Price after percentage discount:", calculator.CalculatePrice(100, "percentage"))
}

良い例

Discountインターフェースを使い、割引のタイプごとに別々の構造体(FixedDiscountPercentageDiscount)を作成。
これにより、新しい割引タイプを追加する際には、PriceCalculatorのコードを変更することなく、Discountインターフェースを実装した新しい構造体を追加するだけで対応できる。

// 割引のインターフェース
type Discount interface {
    Apply(price float64) float64
}

// 固定割引
type FixedDiscount struct {
    Amount float64
}

func (d *FixedDiscount) Apply(price float64) float64 {
    return price - d.Amount
}

// パーセンテージ割引
type PercentageDiscount struct {
    Rate float64
}

func (d *PercentageDiscount) Apply(price float64) float64 {
    return price * (1 - d.Rate)
}

// 割引計算機
type PriceCalculator struct{}

func (p *PriceCalculator) CalculatePrice(price float64, discount Discount) float64 {
    return discount.Apply(price)
}

func main() {
    calculator := &PriceCalculator{}

    fixedDiscount := &FixedDiscount{Amount: 10}
    percentageDiscount := &PercentageDiscount{Rate: 0.1}

    fmt.Println("Price after fixed discount:", calculator.CalculatePrice(100, fixedDiscount))
    fmt.Println("Price after percentage discount:", calculator.CalculatePrice(100, percentageDiscount))
}
oknokn

SOLID原則

LSP(Liskov Substitution Principle) リスコフの置換原則

「S型のオブジェクトo1の各々に、対応するT型のオブジェクトO2が1つ存在し、Tを使って定義されたプログラムPに対してo2の代わりにo1を使ってもPの振る舞いが変わらない場合、SはTの派生系であると言える。」

原則に反する例

社員管理システムにおいて共通のインターフェースEmpoloyeeを持ち、給与計算メソッドcalculateSalaryを持つ。しかし、契約社員ContractEmployeeには給与計算が存在しないケースがあるためLSPに違反する

// 社員インターフェース
type Employee interface {
    CalculateSalary() (float64, error)
}

// 正社員
type FullTimeEmployee struct {
    BaseSalary float64
}

func (e *FullTimeEmployee) CalculateSalary() (float64, error) {
    return e.BaseSalary, nil
}

// 契約社員(給与が発生しない場合がある)
type ContractEmployee struct {
    IsPaid bool
}

func (e *ContractEmployee) CalculateSalary() (float64, error) {
    if !e.IsPaid {
        return 0, errors.New("契約社員には給与が発生しません")
    }
    return 50000, nil
}

// 給与を表示する関数
func PrintSalary(e Employee) {
    salary, err := e.CalculateSalary()
    if err != nil {
        fmt.Println("エラー:", err)
        return
    }
    fmt.Println("給与:", salary)
}

func main() {
    fullTime := &FullTimeEmployee{BaseSalary: 300000}
    contract := &ContractEmployee{IsPaid: false}

    // 正社員の給与計算
    PrintSalary(fullTime)

    // 契約社員の給与計算(LSP違反:期待しないエラー発生)
    PrintSalary(contract)
}

ここで起こるエラーから身を守るためには Employeeの持つPrintSalaryにおいて給与計算がないケースを考慮して検出する仕組みを用意することである。しかし、Employeeの振る舞いが型に依存することになるので、これらの型は置換可能ではない。

LSPはオブジェクト指向の継承の使い方の指針として考えられていたが、今ではインターフェイスと実装に関するアーキテクチャの原則になっている。

oknokn

SOLID原則

ISP(Interface Subsutitution Principle) インターフェイス分離の原則

必要最低限の単位で依存するインターフェイスを分離すべきってこと

よくない例

タイムカードが必要ない契約社員にも RecordTimeCardメソッドを実装しなくてはならない

// 大きすぎるインターフェース
type Employee interface {
    DisplayInfo()
    CalculateSalary()
    RecordTimeCard()
}

// 正社員
type FullTimeEmployee struct {
    Name   string
    Salary int
}

func (e *FullTimeEmployee) DisplayInfo() {
    fmt.Println("社員名:", e.Name)
}

func (e *FullTimeEmployee) CalculateSalary() {
    fmt.Println("給与:", e.Salary)
}

func (e *FullTimeEmployee) RecordTimeCard() {
    fmt.Println("タイムカードを記録しました")
}

// 契約社員(タイムカードは不要)
type ContractEmployee struct {
    Name   string
    Salary int
}

func (e *ContractEmployee) DisplayInfo() {
    fmt.Println("契約社員名:", e.Name)
}

func (e *ContractEmployee) CalculateSalary() {
    fmt.Println("契約給与:", e.Salary)
}

// 無理やり実装(本来不要)
func (e *ContractEmployee) RecordTimeCard() {
    fmt.Println("契約社員にはタイムカードがありません")
}

func main() {
    fullTime := &FullTimeEmployee{Name: "山田太郎", Salary: 300000}
    contract := &ContractEmployee{Name: "佐藤次郎", Salary: 200000}

    fullTime.DisplayInfo()
    fullTime.CalculateSalary()
    fullTime.RecordTimeCard()

    contract.DisplayInfo()
    contract.CalculateSalary()
    contract.RecordTimeCard() // 本来必要ない
}

アーキテクチャにおいても同じである

システムSを開発する際にあるフレームワークFを導入したいとする。そのフレームワークFは特定のデータベースDのために作られたものだとする。

この時点でシステムSはフレームワークFに依存していて、フレームワークFはデータベースDに依存している。
システムSもデータベースDに依存している事となり、データベースの変更が難しくなったり、データベースのアップデートに備えて対応やシステムの改修が必要になる。

oknokn

SOLID原則

DIP(Dependency Inversion Principle) 依存関係逆転の原則

「ソースコードの依存関係は(具象ではなく)抽象だけを参照するべきである。」
このルールを絶対のものとして守ることは現実的ではないが、依存先の具象を減らすことに努めるべきである。

抽象インターフェイスの変更は具象クラスの変更に繋がる。
対して、具象クラスの変更では抽象インターフェイスに影響はない、つまり抽象インターフェイスは変化しづらい。
設計を行う際には具象クラスへの依存を減らし、安定した抽象インターフェイスに依存させるべき。

変化しやすい具象クラスを参照しない

代わりに抽象インターフェイスを参照するべき

変化しやすい具象クラスを継承しない

継承は一種の依存関係である

具象関数をオーバーライドしない

具象関数はソースコードの依存を要求することが多く、関数をオーバーライドしてもその依存関係を排除することはできない。

元の関数を抽象関数にして、それに対する複数の実装を用意するしかない

依存関係逆転の流れ

DIPを適用すると、依存の向きが逆転する。

上位モジュール → 抽象(インターフェース) ← 下位モジュール

通常、上位モジュールが下位モジュールに直接依存すると、下位モジュールの変更が上位モジュールに影響する。

DIPを適用すると、上位モジュールは抽象(インターフェース)に依存し、下位モジュールも同じく抽象を実装するため、具体的なクラスが変わっても上位モジュールに影響を与えない。

これが「依存関係が逆転する」と言われる理由。
まさに、依存の流れが**「上位→下位」から「上位←抽象→下位」**へと逆転しているわけですね。

作成者以外のコメントは許可されていません