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の実装に依存しているといえる。
依存性逆転の原則では、関数BのインターフェイスIを用意し、関数Aは関数Bの実装を参照するのではなくインターフェイスIを参照し、関数BはインターフェイスIを実装する形にすることが望ましいとされている。
依存性逆転の原則に違反してはいけない理由
モジュール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()
を使用している)。これは、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;
};
このように、fetchUser
をimport
ではなく関数の引数として受け取ることで、インターフェイスに依存させることができる。
そして、fetchUser
をIFetchUser
のインターフェイス通りに実装する。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);
}
};
図で示すと、以下のような関係になっている。
この抽象と実装の関係ができていれば、fetch
のかわりにaxios
を使いたくなった場合でも、IFetchUser
を実装できていさえすればgetUserName
などを気にせずfetchUser
を変更することができるようになる。