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

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

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原則のひとつ「依存性逆転の原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介します。

依存性逆転の原則とは?

概要

依存性逆転の原則とは、あるモジュールが別のモジュールを利用するとき、モジュールはお互いに直接依存すべきではなく、どちらのモジュールも、共有された抽象(インターフェイスや抽象クラスなど)に依存すべきであるという原則です。

たとえば、関数Aが処理の内部で関数Bを直接読み込んで利用している場合、関数Aは関数Bの実装に依存しているといえます。

関数Aは関数Bに依存している

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

関数AはインターフェイスIに依存していて、関数BはインターフェイスIを実装している

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

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

モジュールAとモジュールBの間にインターフェイスIを挟むことで、モジュールBの改修をモジュールAを意識することなく行うできるようになります。

依存性逆転の原則に違反している例

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の実装に依存しているといえます。図で示すと、以下のような関係になっています。

getUserNameはfetchUserに依存している

データの取得に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);
  }
};

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

getUserNameはIFetchUserに依存していて、fetchUserはIFetchUserを実装している

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

所感

最初「依存性逆転の原則」と聞いたときに『「依存性」?が「逆転」?意味不明だが…』と思いました。図を描いて考えれば、「ああ、たしかに逆転してるかも」と理解することができました。

依存性逆転の原則は、例で紹介したようなモジュールを変更しやすくするという利点の他にも、テストがしやすくなるという利点があります。たとえばデータベースを扱うモジュールをテストしたい場合を考えます。依存性逆転の原則を適用せずにそのままモジュールを使用するような形にしてしまった場合、テストをするときに毎回データベースを立ち上げて接続して、という操作をせねばならず、テストを書くのが大変になる & テストの実行が遅くなるなどの不都合があります。依存性逆転の原則を適用してあげて、テストのときはモックのデータベースを使うように工夫すれば、データベースの立ち上げも接続も不要で、かんたんにテストが書けるようになります。

個人的に、依存性逆転の原則はSOLID原則の中でもかなり重要な原則だと思っているのですが、なんでもかんでもインターフェイスを用意して依存性逆転すればよいというわけではないという点に注意が必要です。モジュールの依存性を逆転させるには、インターフェイスを用意する工数や、DIと呼ばれる仕組みを作るための工数が余計にかかる場合があります。また、素直に書いた場合と比べてコード上で処理が追いづらくなる場合もあります。そういった点を考慮して、依存性の逆転が必要そうなところで適切に依存性を逆転するようにしましょう(?)。

採用情報

メンバーズエッジで最高のチームで最高のプロダクトを作りませんか?

最高のプロダクトをつくる 最高のチームで働く

在宅でも、地方でも、首都圏でも。多様な働き方で最高のチームをつくり、お客様のプロダクトパートナーを目指します。アジャイル開発を通じ、開発現場の第一線で活躍し続けたいエンジニアを募集しています。