💡gntk.dev

依存性逆転の原則 TypeScriptでSOLID原則

SOLID原則のL「依存性逆転の原則」について自分が理解している内容をまとめました。

post-cover

D … Dependency Inversion 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の実装に依存しているといえる。

dip-1

依存性逆転の原則では、関数BのインターフェイスIを用意し、関数Aは関数Bの実装を参照するのではなくインターフェイスIを参照し、関数BはインターフェイスIを実装する形にすることが望ましいとされている。

dip-2

依存性逆転の原則に違反してはいけない理由

モジュールAがモジュールBの実装を参照していた場合、モジュールBの変更がモジュールAに影響を及ぼす可能性がある。そのため、モジュールBの改修を行う際は、モジュールBの実装に依存しているモジュールAに影響がないかなどの調査を行わねばならず、そのぶん工数がかかってしまう。

モジュールAとモジュールBの間にインターフェイスIを挟むことで、(インターフェイスIを正しく実装していさえすれば)モジュールAを意識することなくモジュールBを改修することができるようになる。

原則に違反している例

TypeScriptのサンプルコードで、依存性逆転の原則に違反している例を紹介する。

User型のユーザー情報をAPIから取得してユーザー名を返す関数getUserNameについて。

export type User = {
  id: string;
  name: string;
  // etc...
};
import { fetchUser } from "path/to/fetch-user";

const getUserName = async () => {
  const response = await fetchUser();
  // fetchの`response.json()`の型がPromise<any>であるため、自前のwrapperで型をつける
  const user: User = await validateType<User>(response.json());

  return user.name;
};
export const fetchUser = async () => {
  try {
    const response = await fetch("/api/user");

    return response;
  } catch (error) {
    throw new Error(error);
  }
};

getUserName関数はデータの取得にfetchUser関数を使用しており、さらにgetUserName関数はfetchUserが内部でfetchを利用していることを知っている(response.json()を使用している)。これは、getUserNamefetchUserの実装に依存しているといえる。図で示すと、以下のような関係になっている。

dip-3

データの取得にfetchではなくライブラリaxiosを使いたくなった場合を考えてみる。fetchUser関数を以下のように修正する。

import axios from "axios";

export const fetchUser = async (): Promise<User> => {
  try {
    const response = await axios.get<User>("/api/user");
    const user = response.data;

    return user;
  } catch (error) {
    throw new Error(error);
  }
};

新しいfetchUserから返ってくる値はjson()メソッドを持っていないため、getUserName関数ではエラーが発生する。

const getUserName = async () => {
  const response = await fetchUser();
  // fetchの`response.json()`の型がPromise<any>であるため、自前のwrapperで型をつける
  const user: User = await validateType<User>(response.json());
  //                                                   ~~~~
  //                                                   ^Property 'json' does not exist on type 'User'.

  return user.name;
};

getUserName以外にもfetchUserを使っている関数などがあった場合、それらもすべて修正する必要があり、工数がかかる上にバグを埋め込んでしまう可能性も高い。

解決策

これを避けるために、getUserNameが抽象的なインターフェイスに依存するような設計で実装してみる。

まず、fetchUser関数の型を表現したIFetchUserを用意する。

interface IFetchUser {
  fetchUser: () => Promise<User>;
}

次に、getUserName関数を、IFetchUserを利用する形で実装する。

const getUserName = async ({ fetchUser }: IFetchUser) => {
  const user: User = await fetchUser();

  return user.name;
};

このように、fetchUserimportではなく関数の引数として受け取ることで、インターフェイスに依存させることができる。

そして、fetchUserIFetchUserのインターフェイス通りに実装する。fetchを使う場合は以下のようになる。

export const fetchUser = async (): Promise<User> => {
  try {
    const response = await fetch("/api/user");
    // fetchの`response.json()`の型がPromise<any>であるため、自前のwrapperで型をつける
    const user = validateType<User>(response.json());

    return user;
  } catch (error) {
    throw new Error(error);
  }
};

図で示すと、以下のような関係になっている。

dip-4

この抽象と実装の関係ができていれば、fetchのかわりにaxiosを使いたくなった場合でも、IFetchUserを実装できていさえすればgetUserNameなどを気にせずfetchUserを変更することができるようになる。

© 2021 gntk.dev