💡gntk.dev

単一責任の原則 TypeScriptでSOLID原則

SOLID原則のS「単一責任の原則」について自分が理解している内容をまとめました。

post-cover

S … Single Responsibility Principle: 単一責任の原則

オブジェクト指向の考え方を用いることによって、関連のあるデータ構造とそれを操作する手続きを結びつけて「オブジェクト」とし、それらオブジェクトを組み合わせてソフトウェアを設計・開発することができる。

関連のあるデータ構造とそれを操作する手続きの結びつけ方、オブジェクト同士の組み合わせ方について適切な取り決めを設け、よりよいソフトウェアを設計・開発するために考案された原則として「SOLID原則」が知られている。

SOLID原則は、ソフトウェアエンジニアRobert C. Martinに提唱された多くの設計原則を5つにまとめたものの頭文字をとって命名された。

  • S … Single Responsibility Principle: 単一責任の原則
  • O … Open-Closed Principle: 開放閉鎖の原則
  • L … Liskov Substitution Principle: リスコフの置換原則
  • I … Interface Segregation Principle: インターフェイス分離の原則
  • D … Dependency Inversion Principle: 依存性逆転の原則

SOLID原則に基づいて設計・開発されたソフトウェアは、以下のような特徴を持つと言われている。

  • 変更しやすい
  • 理解しやすい
  • オブジェクトが再利用しやすい

逆に、SOLID原則に違反した設計・開発を行ってしまうと、

  • 新しい機能を追加するために既存のコードに大量の修正を加える必要がある
  • 既存の機能を変更することでバグを生んでしまう可能性が高い
  • 既存のコードを理解するために多くの時間を費やしてしまい、機能の追加や修正に時間がかかりがちになる
  • 機能の追加時に既存のコードを再利用できないため、開発効率が悪くなる

などの不都合が生まれる。

本記事では、SOLID原則のひとつ「単一責任の原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介する。

概要

個々のモジュール(クラスや関数など)が責任を負う対象は、たったひとつににするべきであるという原則。

ソフトウェアの既存の機能に変更を加えたり、新たな機能を追加する理由は、そのソフトウェアのユーザーやステークホルダーを満足させるためである。この「ソフトウェアのユーザーやステークホルダー」が、単一責任の原則における「モジュールが責任を負う対象」である。

原則に違反した場合どうなるか

たとえば対象Aと対象Bに関して責任を負ってしまっている部品があった場合、それはAの動作のためのコードとBの動作のためのコードが一体になってしまっていることになる。そうすると、Aの動作に関する改修をしたつもりが、その影響がBにも及んでしまいバグが生まれてしまう可能性がある。

複数の対象に対して責任を負っている部品がある場合、その部品を、責任を負う対象の数に分割してあげることで、アンチパターンを回避することができる。

単一責任の原則に違反している例

TypeScriptのサンプルコードで、単一責任の原則に違反している例を紹介する。

たとえば、以下のようなEmployeeクラスは単一責任の原則に違反しているといえる。

class Employee {
  public name: string;
  public department: string;
  // etc...

  public constructor(...) {...};

  /**
   * 給与計算のメソッド
   * 経理部門に対して責任を負っている
   */
  public calculatePay = (): Money => {...};

  /**
   * 労働時間レポートを出力するメソッド
   * 人事部門に対して責任を負っている
   */
  public reportHours = (): string => {...};

  /**
   * 従業員情報をDBに保存するメソッド
   * データベース管理者に対して責任を負っている
   */
  public save = (): void => {...};

  /**
   * 所定労働時間を算出するメソッド
   * `calculatePay`と`reportHours`の両方で必要な処理のため、メソッドに切り出して共通化している
   */
  private getRegularHours = (): number => {...};
}

違反している理由

このクラスは、経理部門、人事部門、データベース管理者の3つの対象について責任を負っている。共通の処理をregularHoursメソッドとして切り出しており、うまくコードの重複を回避しているように見えるが、これが、バグの原因になることも考えられる。

たとえば、経理部門から、所定労働時間の算出方法を変更したい依頼があったとする。改修担当者は、calculatePayからgetRegularHoursを呼んで所定労働時間を変更していることを確認し、getRegularHoursに変更を加える。ユニットテストをパスし、経理部門の担当者に動作確認もしてもらい問題なかったため、この変更は本番環境にデプロイされる。

ここで問題なのは、改修担当者はgetRegularHoursreportHoursからも呼ばれていることを確認していないことである。もし、経理部門が扱う所定労働時間と人事部門が扱う所定労働時間の算出方法が異なるものだった場合、人事部門は所定労働時間の算出方法が誤ったものに変更されていることに気づかないまま、getRegularHoursが算出した値を使い続けることになる。

解決策

「メソッドに変更を加える際は、そのメソッドがどこから呼ばれているかしっかり確認する」では、ケアレスミスを防ぐことが出来ず、根本的な解決策にはならない。

たとえば、EmployeeクラスをPayCalculatorクラス・HourReporterクラス・EmployeeSaverクラス・EmployeeDataクラスに分割することで、上記のようなミスを防ぐことができる。

/**
 * 従業員に関するデータのみをプロパティとして持つ
 * メソッドは持たない
 */
class EmployeeData {
  public name: string;
  public department: string;
  // etc...

  public constructor(...) {...};
}

class PayCalculator {
  public constructor (
    private readonly employeeData: EmployeeData,
  ) {};

  public execute = (): Money => {...};

  private getRegularHours = (): number => {...};
}

class HourReporter {
  public constructor (
    private readonly employeeData: EmployeeData,
  ) {};

  public execute = (): string => {...};

  private getRegularHours = (): number => {...};
}

class EmployeeSaver {
  public constructor (
    private readonly employeeData: EmployeeData,
  ) {};

  public execute = (): void => {...};
}

Employeeクラスを「責任を負う対象」の数に分割している。この場合、経理部門から所定労働時間の算出方法を変更したい依頼があった場合、PayCalculatorgetRegularHoursを変更しても他の部門に影響がないことは明らかである。

© 2021 gntk.dev