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

SOLID原則
SRP(Single Responsibility Principle) 単一責任の法則
「モジュールを変更する理由はたったひとつだけであるべきである」
「モジュールは一つのことだけを行うべき」と原則名から捉えてしまいがちだが、本質は「モジュールはたった一つのアクター(変更を望む人たちのグループ)に対して責務を負うべき」であるということ。
ex) 給与システムにおけるEmployeeクラスがあるとする。下記の3つのメソッドを持つ。
- 経理部門が規定するcalculatePayメソッド
- 人事部門が規定するreportHoursメソッド
- データベース管理者が規定するsaveメソッド
これらのメソッドを全てEmployeeクラスに入れてしまうと全てのアクターを結合してしまうこととなり、単一責任の原則に反する。
-
別チームの操作が他チームに影響を及ぼしてしまう。
- ex) calculatePay、reportHoursで使用している労働時間の計算ロジックが同じでここを一つのメソッドに切り出した場合など
facadeを使用してデータを関数から切り離す
-
EmpoloyeeOperations
やEmployeeData
に変更を加えても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()
}

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
インターフェースを使い、割引のタイプごとに別々の構造体(FixedDiscount
、PercentageDiscount
)を作成。
これにより、新しい割引タイプを追加する際には、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))
}

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

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に依存している事となり、データベースの変更が難しくなったり、データベースのアップデートに備えて対応やシステムの改修が必要になる。

SOLID原則
DIP(Dependency Inversion Principle) 依存関係逆転の原則
「ソースコードの依存関係は(具象ではなく)抽象だけを参照するべきである。」
このルールを絶対のものとして守ることは現実的ではないが、依存先の具象を減らすことに努めるべきである。
抽象インターフェイスの変更は具象クラスの変更に繋がる。
対して、具象クラスの変更では抽象インターフェイスに影響はない、つまり抽象インターフェイスは変化しづらい。
設計を行う際には具象クラスへの依存を減らし、安定した抽象インターフェイスに依存させるべき。
変化しやすい具象クラスを参照しない
代わりに抽象インターフェイスを参照するべき
変化しやすい具象クラスを継承しない
継承は一種の依存関係である
具象関数をオーバーライドしない
具象関数はソースコードの依存を要求することが多く、関数をオーバーライドしてもその依存関係を排除することはできない。
元の関数を抽象関数にして、それに対する複数の実装を用意するしかない
依存関係逆転の流れ
DIPを適用すると、依存の向きが逆転する。
上位モジュール → 抽象(インターフェース) ← 下位モジュール
通常、上位モジュールが下位モジュールに直接依存すると、下位モジュールの変更が上位モジュールに影響する。
DIPを適用すると、上位モジュールは抽象(インターフェース)に依存し、下位モジュールも同じく抽象を実装するため、具体的なクラスが変わっても上位モジュールに影響を与えない。
これが「依存関係が逆転する」と言われる理由。
まさに、依存の流れが**「上位→下位」から「上位←抽象→下位」**へと逆転しているわけですね。