🐭

SOLIDの原則を心で理解する - 単一責任の原則

2024/06/10に公開

React(TypeScript)でSOLIDの原則を心で理解する

一度記事にアウトプットしたものの完全に理解するためにそれぞれの原則を別の記事で再度深掘りしていきます。

原則の5つのすべてを一度に説明しており、十分に理解が乏しく複雑さを詳しく説明できておりませんでした。

今回も TypeScript の例を付属してますが、以下の内容はオブジェクト指向をサポートするプログラミング言語に適用されることと理解しています。

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

意味

ここでのモジュールとは、機能的な責任をカプセル化するソフトウェアコンポーネントを指します。
最も一般的な例はクラスですが、関数やメソッドなどにも適用できます。
より高いレベルでは、この原則はソフトウェアアーキテクチャ、例えばマイクロサービスアーキテクチャを設計するときにも適用されます。この場合、各サービスはモジュールと表します。

  • 変化する理由はなんでしょうか?
  • 責任・責務の概念はなんでしょうか?

自分は「単一責任の原則」の責務はモジュールが1つのことだけを実行することと認識してましたが、これは間違っています。本当の教えは、モジュールが変更する理由は1つだけであるべきだということです。

モジュールが1つのことだけを実行することも当然可能ですが、本当に重要なのは、変更する理由が1つだけであるということです。

変える理由はただ一つ

プロダクトのニーズは定期的かつ常時、進化・変更していきます。ユーザーや市場の要求に適応するか、サービスが接続するサードパーティサービスの技術的変更を考慮するかなどです。仕様は常に進化・変更するため、多くの場合、製品の動作の変更が必要になります。

これは正常であり、健全なことであり、まさに機敏性の象徴です。

ただ、変更する理由がいくつかあるモジュール内にある場合、動作を変更するのは困難であり、危険ですらあります。これが開発者あるいは意思決定者でさえ、「単一責任の原則」を尊重しないレガシーコードの変更に消極的になる理由です。

上記のようにして、チームもしくはメンバーは、そのようなモジュールの変更を避けるという単純な目的で、歪んでコストのかかるアーキテクチャ上に実装することが度々発生しがちです。

TypeScriptによる例

既存のアプリケーションを扱うことになり、そのアプリケーションはユーザーが猫の名前を提案したり、その名前に投票したりすることができるものです(そんなものがあったらかなりの冒険家ですが...)。

管理するロジックは次のクラスにあります。

import fs from 'fs';
import path from 'path';

interface Cache {
  [index: number]: string;
}

export default class FileStore {
  constructor(
    private readonly directory: string,
    private readonly cache: Cache = {},
  ) {}

  public async save(id: number, catName: string) {
    console.log(`ファイル ${id} を保存します。`);
    const fileFullName = this.getFile(id);
    try {
      fs.writeFileSync(fileFullName, catName);
      this.cache[id] = catName;
      console.log(`ファイル ${id} が保存されました。`);
    } catch (error) {
      console.log(`ファイル ${id} の保存中にエラーが発生しました。`);
    }
  }

  public read(id: number): string {
    console.log(`ファイル ${id} をストアから読み込みます。`);
    const fileFullName = this.getFile(id);
    const exists = fs.existsSync(fileFullName);
    if (!exists) {
      console.log(`ファイル ${id} が見つかりません。`);
      return '';
    }
    if (!this.cache.hasOwnProperty(id)) {
      console.info(`ファイルID ${id} はキャッシュにありません。`);
      const data = fs.readFileSync(fileFullName, 'utf8');
      this.cache[id] = data;
    }
    const message = this.cache[id];
    console.log(`ファイル ${id} を返します。`);
    return message;
  }

  private getFile(id: number) {
    return path.join(__dirname, this.directory, `cat-${id}.txt`);
  }
}

上記のクラスは比較的単純と思いますが、実際にはいくつかの責任を持っており、それに伴って変更する理由もいくつかあります。

具体的には、このクラスは以下のような責任を持っています。

  • ロギングの管理
  • キャッシュの管理
  • ファイルシステムとのやり取り
  • オーケストレーション(調整、統合)

ここで問題となるのは、これらのメカニズムのいずれかを変更すると、このクラスを変更せざるを得なくなる点です。本来このクラスの唯一の責任は、ファイルとのやり取りであるべきです。

そこで、これらの責任を分離し、リファクタリングを行います。まずは、ロギングのプロセスを専用のクラスに抽出します。

export default class Logger {
  public log(message: string): void {
    console.log(message)
  }

  public error(message: string): void {
    console.error(message)
  }

  public warn(message: string): void {
    console.warn(message)
  }

  public info(message: string): void {
    console.info(message)
  }
}

このクラスは一つの責任(ログの記録)と一つの変更理由(ログの記録方法)しか持ちません。

次に、メッセージの保存を管理するMessageStoreに注目します。これも専用のクラスに抽出します。このストアにはシンプルなキャッシュシステムがあり、必要に応じてアプリケーションの他の部分に影響を与えることなく置き換えることができます。

import Logger from './Logger'

interface Cache {
  [index: number]: string
}

export default class MessageStore {
  constructor(
    private readonly logger: Logger,
    private readonly cache: Cache = {},
  ) {}

  public addOrUpdate(id: number, message: string): void {
    this.cache[id] = message
  }

  public getOrAdd(id: number, message?: string): string {
    this.logger.log(`キャッシュからファイル ${id} を読み込みます。`)
    if (!this.exists(id)) {
      this.logger.info(`ファイルID ${id} はキャッシュにありません。`)
      this.addOrUpdate(id, message || '')
    }
    return this.cache[id]
  }

  public exists(id: number): boolean {
    return this.cache.hasOwnProperty(id)
  }
}

また、このクラスも一つの責任と一つの変更理由しか持ちません。

このクラスはロガーを呼び出していますが、ログの記録処理をロガーに委譲しているだけで、その責任を持っているわけではありません。

これら二つの責任から解放されたことで、FileStoreクラスは「単一責任の原則」をより良く遵守するようになり、副作用が発生する理由は劇的に減少しました。

しかし、まだ一つ余分な責任があります。それは「オーケストレーション(調整、統合)」の責任です。

実際のところ、ファイルがキャッシュから返されるか、ファイルシステムから返されるかを判断する責任を持つべきではありません。その役割はファイルとの相互作用に限定されるべきです。

この問題に対処するために、オーケストレーションクラスを導入します。このクラスはアプリケーションのエントリーポイントとなり、FileStoreを呼び出します。

import FileStore from './FileStore'
import MessageStore from './MessageStore'

export default class Orchestrator {
  constructor(
    private readonly messageStore: MessageStore,
    private readonly fileStore: FileStore,
  ) {}

  public async save(id: number, message: string) {
    await this.fileStore.save(id, message)
    this.messageStore.addOrUpdate(id, message)
  }

  public read(id: number): string {
    if (!this.messageStore.exists(id)) {
      const message = this.fileStore.read(id)
      this.messageStore.getOrAdd(id, message)
    }
    return this.messageStore.getOrAdd(id)
  }
}

クラスFileStoreはこれでファイル管理という単一の責任のみを持つようになりました。

import fs from 'fs'
import path from 'path'
import Logger from './Logger'

export default class FileStore {
  constructor(
    private readonly directory: string,
    private readonly logger: Logger,
  ) {}

  public async save(id: number, catName: string): Promise<void> {
    this.logger.log(`ファイル ${id} を保存します。`)
    const fileFullName = this.getFile(id)

    try {
      fs.writeFileSync(fileFullName, catName)
      this.logger.log(`ファイル ${id} が保存されました。`)
    } catch (error) {
      this.logger.error(`ファイル ${id} の保存中にエラーが発生しました。`)
    }
  }

  public read(id: number): string {
    this.logger.log(`ファイル ${id} をストアから読み込みます。`)
    const fileFullName = this.getFile(id)
    if (!fs.existsSync(fileFullName)) {
      this.logger.warn(`ファイル ${id} が見つかりません。`)
      return ''
    }
    return fs.readFileSync(fileFullName, 'utf-8')
  }

  private getFile(id: number) {
    return path.join(__dirname, this.directory, `cat-${id}.txt`)
  }
}

上記のようなリファクタリングにより、同じ理由で変更される要素を同じモジュールにまとめることで、コードの凝集度が高まりました。
同時に、異なる責任を持つモジュール間の結合度を減らすことができました。

そのため、これらの独立したメカニズムの一つが変更される場合でも、影響を受けるのはそのモジュールだけであり、アプリケーションの他の部分には影響がないことに安心できます!

Discussion