💡gntk.dev

インターフェイス分離の原則 TypeScriptでSOLID原則

SOLID原則のL「インターフェイス分離の原則」について自分が理解している内容をまとめました。

post-cover

I … Interface Segregation 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のサンプルコードとともに紹介する。

概要

インターフェイス分離の原則とは、インターフェイスとクライアント(インターフェイスの利用者)があるとき、インターフェイスに用意されてある(利用するクライアントにとって)不必要なメソッドにクライアントが依存しなくてもよいように、分割できるインターフェイスは分割するべきであるという原則。

原則に違反してはいけない理由

インターフェイスに用意されている不必要なメソッドにクライアントが依存してしまうと、クライアントでは「例外を返すだけのメソッド」のような意味のないメソッドを用意しなければならない。また、そのようなクライアントが増えると、インターフェイスに変更を加えたときの影響範囲も増えてしまい、バグを生む可能性が高まる。

インターフェイス分離の原則に違反している例

TypeScriptのサンプルコードで、インターフェイス分離の原則に違反している例を紹介する。

タスク管理アプリケーションの「タスク」に関する情報を持つTaskオブジェクトと、「タスクのタイトル」を更新するupdateTaskTitle関数との関係について。ITaskRepositoryはDBなどの永続化層のインターフェイスを表しているが、この例ではあまり重要でないため説明は省略する。

export type Task = {
  id: string;
  title: string;
  details: string;
  status: "ToDo" | "InProgress" | "Done";
  createdAt: Date;
  updatedAt: Date;
};
const updateTaskTitle = async (
  task: Task,
  repository: ITaskRepository,
): Promise<void> => {
  await repository.updateTitle({ id: task.id, title: task.title });
};
export interface ITaskRepository {
  registerTask: (props: { title: string; details: string }) => Promise<void>;
  getTask: (props: { id: string }) => Promise<Task>;
  updateTitle: (props: { id: string; title: string }) => Promise<void>;
  updateDetails: (props: { id: string; details: string }) => Promise<void>;
  // etc...
}

updateTaskTitle関数の仮引数としてTask型のtaskオブジェクトを設定しているが、この部分がインターフェイス分離の原則に違反している可能性がある。

違反している理由

updateTaskTitle関数では、Task型のtaskオブジェクトを仮引数として設定しているが、関数内で実際に使われているのは、Task型のうちidtitleのみである。そのため、たとえば、Task型が以下のように変更されてしまった場合、バグが発生する。

type Task = {
  id: string;
  taskInfo: {
    title: string;
    details: string;
    status: "ToDo" | "InProgress" | "Done";
  },
  createdAt: Date;
  updatedAt: Date;
};

updateTaskTitle関数ではtask.titleundefinedになってしまうため、repository.updateTitleが必ず失敗してしまう。

const updateTaskTitle = async (
  task: Task,
  repository: ITaskRepository,
): Promise<void> => {
  await repository.updateTitle({ id: task.id, title: task.title });
  // => TS2339: Property 'title' does not exist on type 'Task'.
};

解決策

updateTaskTitle関数ではstring型のidとstring型のtitleしか利用しないため、仮引数をTaskまるごとではなくidtitleに分割することで、Taskの変更によるバグなどは防ぐことができるようになる。(updateTaskTitle関数を利用する側からtask.taskInfo.titleを渡せばよい)

const updateTaskTitle = async (
  task: {id: string, title: string},
  repository: ITaskRepository,
): Promise<void> => {
  await repository.updateTitle({ id: task.id, title: task.title });
};

Taskが、各プロパティの整合性を保つためのバリデーターメソッドなどを備えたクラス等であった場合、必ずしもインターフェイスを分割すべきであるとは言えないため、注意が必要。

© 2021 gntk.dev