🦁

ユースケース図とSRP(単一責任の原則)

2021/07/18に公開

モチベーション

クリーンアーキテクチャに則り作られた自分の個人開発のプロダクトが単一責任の原則(SRP)を満たしているのか不安になり、ユースケース図を書くことにしました。その際クリーンアーキテクチャ用のユースケース図を思いついたので共有いたします。
以降このクリーンアーキテクチャ用のユースケース図をアクター図と呼ぶことにします。
おそらく他のアーキテクチャ等でも流用可能だと思うので、気に入ったら是非使ってみてください。

単一責任の原則(SRP)とは

単一責任の原則とは何か?
色々な言われ方をします。
一つの関数はたった一つのことを行うべき
クラスが担う責任は、たった一つに限定すべき

これらを言い換えると、以下のように言えます。
モジュールを変更する理由はたった一つであるべき
ここで毎度おなじみのボブおじさんが以下のように補足します。
「システムの変更ってのは、ユーザーやステークホルダーを満足させるためのものでしょ?それってつまり、ユーザーやステークホルダー自体が SRP の指し示すモジュールを変更する理由なんじゃない?」
「ユーザーやステークホルダーのグループをアクターと呼称したときにこれは以下のように言い換えられるんじゃない?」

モジュールはたった一つのアクターに対して責務を負うべきである。
なるほど。完全に合っているかはわかりませんが、一理ありそうですね。

以下この記事では単一責任原則の定義を
モジュールはたった一つのアクターに対して責務を負うべきである。
として進めていきます。

単一責任の原則を破っている状態

単一責任の原則を破っている状態とその問題点を示したいと思います。
例のごとく go のサンプルコードと今回はユースケース図を交えて解説していきます。

ちょっと複雑な例で紹介します。コードは以下です。

bad.go
package main

import "fmt"

var (
	space = "space"
	water = "water"
)

/*******************************
	utility
*******************************/

type MobileSuit struct {
	weight int
	engine int
	name   string
}

type transfer interface {
	transferGtype(string, int) int
	transferZtype(string, int) int
}

type Gtype struct {
	MobileSuit
	transfer
}

type Ztype struct {
	MobileSuit
	transfer
}

// 速度算出
func (m *MobileSuit) calculateSpeedIndex() float32 {
	return float32(m.engine) / float32(m.weight)
}

// Ztype 移動距離算出
func (z *Ztype) transferZtype(field string, seconds int) int {
	var transfer int
	switch field {
	case space:
		index := z.calculateSpeedIndex()
		transfer = int(index * 1.2 * float32(seconds))
	case water:
		index := z.calculateSpeedIndex()
		transfer = int(index * .4 * float32(seconds))
	default:
		index := z.calculateSpeedIndex()
		transfer = int(index * float32(seconds))
	}
	return transfer
}

// Gtype 移動距離算出
func (g *Gtype) transferGtype(field string, seconds int) int {
	var transfer int
	switch field {
	case space:
		index := g.calculateSpeedIndex()
		transfer = int(index * 1.2 * float32(seconds))
	case water:
		index := g.calculateSpeedIndex()
		transfer = int(index * .7 * float32(seconds))
	default:
		index := g.calculateSpeedIndex()
		transfer = int(index * float32(seconds))
	}
	return transfer
}

/*******************************
	instance
*******************************/

type red struct {
	Ztype
}

type green struct {
	Ztype
}

type white struct {
	Gtype
}

/*******************************
	functions
*******************************/

func main() {
	field := space
	seconds := 200

	white := &white{
		Gtype: Gtype{
			MobileSuit: MobileSuit{
				weight: 43,
				engine: 55,
				name:   "白い悪魔",
			},
		},
	}

	green := &green{
		Ztype: Ztype{
			MobileSuit: MobileSuit{
				weight: 58,
				engine: 43,
				name:   "緑の脇役",
			},
		},
	}

	red := &red{
		Ztype: Ztype{
			MobileSuit: MobileSuit{
				weight: 58,
				engine: 43,
				name:   "赤いの",
			},
		},
	}

	transferWhite := white.transferGtype(field, seconds)
	transferGreen := green.transferZtype(field, seconds)
	transferRed := red.transferZtype(field, seconds)
	fmt.Printf(
		"%s: %v, %s: %v, %s: %v",
		white.name, transferWhite, green.name, transferGreen, red.name, transferRed,
	)
}

どうでしょうか。今のところインターフェースもちゃんと分離されていて問題なさそうですね。

悪い例に変更を加える

それでは変更を加えていきましょう。

以下のようなお触れが出ると仮定します。
赤い彗星キャンペーンを実施することになりました。
「赤いの」の速さを通常の 3 倍にします。

それを実装する際にその担当となった新人 A 氏は transferZaku 関数が移動距離の算出に関わっていることに気づきます。

そこでその新人 A 氏はreturn transferの部分を
return transfer * 3
とした。

そうするとどうなるでしょう。
なんと緑の脇役も 3 倍の移動距離(速さ)になってしまいました。

これでは
通常の 3 倍ではなく通常も 3 倍です。

そこで A 氏は transferZtyp 関数を if 文で場合分けしてやろうと考えるのです。

bad1_2.go
// Ztype 移動距離算出
func (z *Ztype) transferZtype(field string, seconds int) int {
	var transfer int
	switch field {
	case space:
		index := z.calculateSpeedIndex()
		transfer = int(index * 1.2 * float32(seconds))
	case water:
		index := z.calculateSpeedIndex()
		transfer = int(index * .4 * float32(seconds))
	default:
		index := z.calculateSpeedIndex()
		transfer = int(index * float32(seconds))
	}
	if z.name == "赤いの" {
		return transfer * 3
	}
	return transfer
}

これで一応解決はしますね。無事赤いのが通常の 3 倍で動くことができます。

しかし、本当にこれでいいのでしょうか?もし今後も変更が出る度に分岐が増え、多重になっていくのは明白です。

そもそも、緑の脇役と赤いのとでは機体のスペックが同じだとしても作品内での役割が全く違うので、これでは困ります。

実際に現在の状態をユースケース図で表現するとこのようになります。

モジュールはたった一つのアクターに対して責務を負うべきである。
を思い出してください。
transferZtype が赤いのと緑の脇役の二つのアクターに対して責任を負ってしまっています。
モジュールを変更する理由が二つある状態になってしまっているのです。

SRP を適用する

インターフェースの分離を用いたファサードパターンに近いもので SRP を適用してあげます。

good.go
package main

import "fmt"

var (
	space = "space"
	water = "water"
)

/*******************************
	utility
*******************************/

type MobileSuit struct {
	weight int
	engine int
	name   string
}

type transfer interface {
	transferGtype(string, int) int
	transferZtype(string, int) int
}

type Gtype struct {
	MobileSuit
	transfer
}

type Ztype struct {
	MobileSuit
	transfer
}

// 速度算出
func (m *MobileSuit) calculateSpeedIndex() float32 {
	return float32(m.engine) / float32(m.weight)
}

// Ztype 移動距離算出
func (z *Ztype) transferZtype(field string, seconds int) int {
	var transfer int
	switch field {
	case space:
		index := z.calculateSpeedIndex()
		transfer = int(index * 1.2 * float32(seconds))
	case water:
		index := z.calculateSpeedIndex()
		transfer = int(index * .4 * float32(seconds))
	default:
		index := z.calculateSpeedIndex()
		transfer = int(index * float32(seconds))
	}
	return transfer
}

// Gtype 移動距離算出
func (g *Gtype) transferGtype(field string, seconds int) int {
	var transfer int
	switch field {
	case space:
		index := g.calculateSpeedIndex()
		transfer = int(index * 1.2 * float32(seconds))
	case water:
		index := g.calculateSpeedIndex()
		transfer = int(index * .7 * float32(seconds))
	default:
		index := g.calculateSpeedIndex()
		transfer = int(index * float32(seconds))
	}
	return transfer
}

/*******************************
	instance
*******************************/

type red struct {
	Ztype
}

type green struct {
	Ztype
}

type white struct {
	Gtype
}

// white 移動距離算出
func (w *white) transferWhite(field string, seconds int) int {
	return w.transferGtype(field, seconds)
}

// green 移動距離算出
func (g *green) transferGreen(field string, seconds int) int {
	return g.transferZtype(field, seconds)
}

// red 移動距離算出
func (r *red) transferRed(field string, seconds int) int {
	return r.transferZtype(field, seconds) * 3
}

/*******************************
	function
*******************************/

func main() {
	field := space
	white := &white{
		Gtype: Gtype{
			MobileSuit: MobileSuit{
				weight: 43,
				engine: 55,
				name:   "白い悪魔",
			},
		},
	}
	green := &green{
		Ztype: Ztype{
			MobileSuit: MobileSuit{
				weight: 58,
				engine: 43,
				name:   "緑の脇役",
			},
		},
	}
	red := &red{
		Ztype: Ztype{
			MobileSuit: MobileSuit{
				weight: 58,
				engine: 43,
				name:   "赤いの",
			},
		},
	}

	transferWhite := white.transferWhite(field, 200)
	transferGreen := green.transferGreen(field, 200)
	transferRed := red.transferRed(field, 200)
	fmt.Printf(
		"%s: %v, %s: %v, %s: %v",
		white.name, transferWhite, green.name, transferGreen, red.name, transferRed,
	)
}

こんな形で実装できます。
Ztype の機体の共通の transferZtype メソッドをそれぞれ赤いのと緑の脇役から別々のメソッドから呼び出すことで実装しています。

transferZtype メソッドは関数でなくて map(辞書) のような形で field とその時の補正を持った map にしてあげてそれを使って計算自体は transferRed や transferGreen で行うのでも勿論構いません。

重要なことは**モジュールはたった一つのアクターに対して責務を負うべきである。**という状態を満たしていることです。

これをユースケース図にしてみましょう。

こんな感じになります。ちゃんと単一責任の原則を保ててそうですね。

アクター図

ようやくアクター図の話に移れます。
まずは私のサービスの簡単なユースケース図を示します。こんな感じです。

これを書いてて気づいたのです。ちょっとアレンジを加えるだけでクリーンアーキテクチャの設計図になると。
アクター図(クリーンアーキテクチャの設計図)が以下です。

大きな四角が domain で、丸がリポジトリ層やユースケース層のメソッドです。
また Anime の大きな四角にかかる横棒は便宜的にリポジトリを分けていることを表現しています。ドメイン的にはどちらも Anime なので一緒の四角に囲ったうえでそれを線で分けています。
この分断線を含めた四角がリポジトリのインターフェースの単位となります。
色付きの四角がユースケースのインターフェースです。

私の場合リポジトリを大きくつくって、そこからインターフェースを分離しているのでこのような表現になります。

アクター図の悪い例

以下のようになってしまう場合、単一責任の原則が満たされていません。

その場合こうしてあげましょう
リポジトリで共通のメソッドを使用していてもファサードパターンのような形でユースケースでインターフェースを分離してあげれば問題ありません。

まとめ

説明が少し不足している気がしますが、クリーンアーキテクチャの設計図となりうるアクター図を紹介しました。
あまり直感的ではないクリーンアーキテクチャがかなり直感的に可視化されると個人的に思っているので、もしよければ使ってください。

drawio 最高!!

GitHubで編集を提案

Discussion