Help us understand the problem. What is going on with this article?

clean architectureから単一責任の原則を学びなおす

More than 1 year has passed since last update.

はじめに

aptpod advent calendar トップバッターとして投稿させていただきました。
トップバッターをやったのは、私がadvent calendarに参加してみようの言い出しっぺだからです。

弊社初のadvent calendarで枠が埋まらなそうですが(来年に期待)
中にどんな人いるんだとか、そもそもaptpodっていう会社を知ってもらえるきっかけになれば幸いです。

本題

今回は、最近読んだclean architectureから単一責任の原則を
goで書いて復習したいと思います。(弊社のバックエンドでは主にgoを使ってます)

というのも、普段コード量を少なくするため、保守性をあげるために同じ処理をまとめるのは
みなさんやっているとは思うのですがどこまでまとめるかって難しくないですか?
まとめすぎると後々変更するときに実はいろんなとこに影響出てしまって
テストで落ちればいいものの、テストなかったらバグとして問題が上がってしまいますよね。
そんな迷えるプログラマのコードを書く指針として、単一責任の原則は僕が抱えていたモヤモヤを言語化してくれていました。

単一責任の原則

モジュールはたったひとつのアクターに対して責務を追うべきである

アクター
モジュールの変更を望む人たちの総称

モジュール
いくつかの関数やデータをまとめた、何らかの振る舞いをもつもの
その振る舞いは、ひとつのアクターに対する責務を追うべき

モジュールがただひとつのことを行うべきではないということに注意

ここまでではピンとこないと思うので、違反している例を追っていく

違反例 想定外の重複

給与システムを例にEmployeeクラスを見ていく

スクリーンショット 2018-11-29 23.36.06.png

スクリーンショット 2018-11-29 23.36.13.png

上記のEmployee クラスは単一責任の原則に違反している
なぜなら、3つのメソッドそれぞれが別々のアクター(CTO, CFO, COO)に対する責務を負っているから

コードにするとこんな風でしょうか1

package main

import "time"

type Employee struct{}

func (e *Employee) CalculatePay() int {
    return 0
}

func (e *Employee) ReportHours() time.Duration {
    return time.Duration(0)
}

func (e *Employee) Save(any interface{}) error {
    return nil
}

//
// Actors...
//

// CTO
type CTO struct {
    Employee *Employee
}

func (a *CTO) Exec(any interface{}) error {
    return a.Employee.Save(any)
}

// COO
type COO struct {
    Employee *Employee
}

func (a *COO) Exec() error {
    _ = a.Employee.ReportHours()
    // do something...
    return nil
}

// CFO
type CFO struct {
    Employee *Employee
}

func (a *CFO) Exec() error {
    _ = a.Employee.CalculatePay()
    // do something...
    return nil
}

上記であればまだ問題は起こらないように見えるが、それぞれのアクターがEmployeeによって結合してしまっている
これによってどんな問題が起こりうるか見てみる

example
例えば、 CalculatePay()ReportHours() の両方でロジック内に所定労働時間算出が必要だとする。
所定労働時間の算出方法は同じなので、その算出方法を regularHours() として切り出し、

それぞれのメソッドで使うようにした。
ここまではよくあるパターンのように思われる。

スクリーンショット 2018-11-30 22.35.28.png

スクリーンショット 2018-11-30 22.35.35.png

type Employee struct {
    WorkingType string
}

func (e *Employee) CalculatePay() int {
    _ = e.regularHours()
    // do something...
    return 0
}

func (e *Employee) ReportHours() time.Duration {
    _ = e.regularHours()
    // do something...
    return time.Duration(0)
}

func (e *Employee) Save(any interface{}) error {
    return nil
}

func (e *Employee) regularHours() time.Duration {
    switch e.WorkingType {
    // 時短勤務
    case "ShortTime":
        return 6 * time.Hour
    }
    // FullTime
    return 8 * time.Hour
}

ここでCFOが使う所定労働時間の算出方法ロジックに変更が必要になったとする。
しかし、COOには関係がない。(別の目的で所定労働時間を使っている)

スクリーンショット 2018-11-29 23.36.13.png

詳細な要求として、今までフルタイムとしてきたインターン従業員の所定労働時間を6時間にしたいとする
以下のようにコードを修正したとしよう

type Employee struct {
    WorkingType string
}

func (e *Employee) CalcuratePay() int {
    _ = e.regularHours()
    // do something...
    return 0
}

func (e *Employee) ReportHours() time.Duration {
    _ = e.regularHours()
    // do something...
    return time.Duration(0)
}

func (e *Employee) Save(any interface{}) error {
    return nil
}

func (e *Employee) regularHours() time.Duration {
    switch e.WorkingType {
    // 時短勤務
    case "ShortTime", "Intern": // "Intern" を新規追加
        return 6 * time.Hour
    }
    // FullTime
    return 8 * time.Hour
}

その仕事を行うエンジニアは、CalculatePay()からregularHours()が呼ばれていることを確認したのだが
ReportHours()から呼び出されていることには気づかなかった。
けれど、regularHours()に手を加えて要件を満たし、CFOの合意もとれ、システムがリリースされた。

この変更によって、ReportHours() を使っているCOOは問題が発覚するまで間違った値を使い続けてしまうだろう。
原因は最初に提示した通り、別々のアクターのコードを一つにまとめてしまったことにある

ではどうするか、解決策をみていく

解決策

関数を別のクラスに移動する

クラスが持つデータを関数から切り離し、アクターに応じたクラスを作成し、その中にメソッドを定義する

スクリーンショット 2018-12-01 1.41.55.png

このようにすれば、アクターに応じたクラスは他のクラスについて知ることがなく
想定外の重複は避けられる

スクリーンショット 2018-12-01 1.46.43.png

コードも修正する

package main

import "time"

type EmployeeData struct {
    WorkingType string
}

type EmployeeSaver struct{}

func (e *EmployeeSaver) SaveEmployee(data EmployeeData) error {
    return nil
}

type PayCalculator struct{}

func (e *PayCalculator) CalculatePay(data EmployeeData) int {
    // do something...
    return 0
}

type HourReporter struct{}

func (e *HourReporter) ReportHours(data EmployeeData) time.Duration {
    // do something...
    return time.Duration(0)
}

//
// Actors...
//

// CTO
type CTO struct {
    Logic *EmployeeSaver
}

func (a *CTO) Exec(data EmployeeData) error {
    return a.Logic.SaveEmployee(data)
}

// COO
type COO struct {
    Logic *HourReporter
}

func (a *COO) Exec(data EmployeeData) error {
    _ = a.Logic.ReportHours(data)
    // do something...
    return nil
}

// CFO
type CFO struct {
    Logic *PayCalculator
}

func (a *CFO) Exec(data EmployeeData) error {
    _ = a.Logic.CalculatePay(data)
    // do something...
    return nil
}

ただ、これでは使う側は都度クラスをインスタンス化する必要がある
今回の例のような計算ロジックはFacadeパターンを使うことで上記は解決できる
イメージとコードを下記に載せる

スクリーンショット 2018-12-01 1.42.06.png

package main

import "time"

type EmployeeData struct {
    WorkingType string
}

type EmployeeFacade struct {
    saver      EmployeeSaver
    reporter   HourReporter
    calculator PayCalculator
}

func NewEmployeeFacade() *EmployeeFacade {
    return &EmployeeFacade{
        saver:      EmployeeSaver{},
        reporter:   HourReporter{},
        calculator: PayCalculator{},
    }
}

func (e *EmployeeFacade) Save(data EmployeeData) error {
    return e.saver.saveEmployee(data)
}

func (e *EmployeeFacade) CalculatePay(data EmployeeData) int {
    // do something...
    return e.calculator.calculatePay(data)
}

func (e *EmployeeFacade) ReportHours(data EmployeeData) time.Duration {
    // do something...
    return e.reporter.reportHours(data)
}

type EmployeeSaver struct{}

func (e *EmployeeSaver) saveEmployee(data EmployeeData) error {
    return nil
}

type PayCalculator struct{}

func (e *PayCalculator) calculatePay(data EmployeeData) int {
    // do something...
    return 0
}

type HourReporter struct{}

func (e *HourReporter) reportHours(data EmployeeData) time.Duration {
    // do something...
    return time.Duration(0)
}

//
// Actors...
//

// CTO
type CTO struct {
    Logic *EmployeeFacade
}

func (a *CTO) Exec(data EmployeeData) error {
    return a.Logic.Save(data)
}

// COO
type COO struct {
    Logic *EmployeeFacade
}

func (a *COO) Exec(data EmployeeData) error {
    _ = a.Logic.ReportHours(data)
    // do something...
    return nil
}

// CFO
type CFO struct {
    Logic *EmployeeFacade
}

func (a *CFO) Exec(data EmployeeData) error {
    _ = a.Logic.CalculatePay(data)
    // do something...
    return nil
}

まとめ

違反例と解決策を見て単一責任の原則を学んだが、まだ上記のコードには問題がある
それは、EmployeeDataに変更があったときには依存するすべてのクラスに影響が及んでしまう。
これは依存関係逆転の原則を使って回避することが可能だが、それはまたの機会に。

違反例に関しては、単体テストで振る舞いを保証していれば検知できるようにも思えるので
単体テストを書いてモジュールの振る舞いを厳密に定義しとくのは大事。

プログラムの最小単位である関数やクラス設計の基本を身に着けて
内部アーキテクチャや複数サービス間の連携も考えられるようになっていきたく思う。


  1. goではメソッドの先頭を大文字にすると public、小文字にすると private になります 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away