依存性逆転の原則 — 抽象は誰のものか
DIP は 3 つの語が混同されやすい原則です。依存性逆転 (DIP)、依存性注入 (DI)、制御の反転 (IoC)。頭文字が似ていて、同じ文脈で登場し、そして別のものを指しています。
混同すると、具体的な失敗が起きます。コンストラクタで依存を受け取る形にして「DIP を満たした」と考えたのに、依存の向きが何も変わっていない、という状態です。この章では何が逆転するのかを原典で確認し、3 つの語を切り分けます。
原典は何を言ったか — DIP は OCP の実現手段
Martin の 2000 年の文書は、DIP をこう定義します1。
Depend upon Abstractions. Do not depend upon concretions. (抽象に依存せよ。具象に依存するな。)
そして前章までの原則との関係を明示します。
If the OCP states the goal of OO architecture, the DIP states the primary mechanism. (OCP がオブジェクト指向アーキテクチャの目標を述べているとすれば、DIP はその主要な手段を述べている。)
DIP は独立した目標ではなく、OCP を実現するための手段という位置づけです。第 3 章で見た「抽象こそが OCP の鍵である」という記述と対応しています。
何が「逆転」するのか
原典は、手続き型の設計と対比して逆転の中身を説明します。
Procedural designs exhibit a particular kind of dependency structure. ... this structure starts at the top and points down towards details. High level modules depend upon lower level modules, which depend upon yet lower level modules (手続き型の設計は特有の依存構造を示す。その構造は上から始まり、下の詳細に向かって伸びる。上位のモジュールが下位のモジュールに依存し、それがさらに下位のモジュールに依存する)
これに対して、オブジェクト指向の構造はこうなります。
the modules that contain detailed implementation are no longer depended upon, rather they depend themselves upon abstractions. Thus the dependency upon them has been inverted. (詳細な実装を含むモジュールはもはや依存される側ではなく、自らが抽象に依存する。こうしてそれらへの依存は逆転された。)
逆転しているのは、上位と下位のあいだの依存の矢印です。 上位が下位を指していたものが、下位が抽象を指す形に変わります。
原典が添える図の構造は、この向きを示しています。上位の方針が抽象インターフェースを指し、詳細な実装もまた抽象インターフェースを指す。矢印が抽象に向かって集まる形です。
DIP・DI・IoC の用語整理
3 つの語を切り分けます。
| 用語 | 何のレベルの話か | 満たしても他方を満たすとは限らない |
|---|---|---|
| IoC (制御の反転) | 制御の主導権を呼び出される側から枠組み側へ渡す、一般的な様式 | IoC は DI より広い。コールバックもテンプレートメソッドも IoC |
| DI (依存性注入) | 依存するオブジェクトを外から渡す実装技法 | 具象クラスを注入しても DI ではある |
| DIP (依存性逆転の原則) | ソースコード上の依存の向きについての設計原則 | 抽象が上位側に属していなければ逆転していない |
Martin Fowler は、この区別を明確にするために「依存性注入」という語を選んだと書いています2。
Inversion of control is a common characteristic of frameworks, so saying that these lightweight containers are special because they use inversion of control is like saying my car is special because it has wheels. (制御の反転は枠組みに共通する特徴である。したがって、これらの軽量コンテナが制御の反転を使うから特別だと言うのは、私の車はタイヤが付いているから特別だと言うようなものである。)
Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection. (制御の反転という語は一般的すぎて、人々は混乱する。そのため多くの議論を経て、私たちは依存性注入という名前に落ち着いた。)
「IoC を使っているから優れている」は何も言っていないに等しい、というのが Fowler の指摘です。枠組みを使えばたいてい制御は反転しています。
なぜ守るのか — 変わるものに引きずられない
DIP が下げているのは、変わりやすいものへの依存が上位に波及するコストです。
業務のルールは、保存先や通知手段より長生きします。「貸出は 1 人 5 冊まで」という規則は、データベースが入れ替わっても通知の送り方が変わっても同じです。それなのに上位が下位を直接呼んでいると、下位が変わるたびに上位を開くことになります。
抽象を挟むと、上位が見るのは「保存できる」という契約だけになります。契約が変わらない限り、下位が何度作り変わっても上位のコードは動きません。
ここで効いているのは抽象という形そのものではなく、契約が下位より変わりにくいことです。原典が変更頻度を基準に置いている理由もそこにあります。したがって守るべきかどうかは、依存先が実際に変わるかで決まります。この判断は後段の「いつ破っていいのか」で扱います。
抽象は誰のものか
ここが DIP の核心であり、DI との違いが最もはっきり出る場所です。
依存を外から渡しても、抽象がどちらのモジュールに属しているかによって、逆転が起きているかどうかが変わります。
以降のコード例では、貸出記録を次の形で扱います。
// 第 4 章の LendingRecord とは別の形。本章では永続化の対象として扱う
type LendingRecord = {
readonly id: string;
readonly borrowerId: string;
readonly itemId: string;
readonly lentAt: Date;
};
// インフラ層のファイル (storage/lending-record-store.ts)
export interface LendingRecordStore {
save(record: LendingRecord): void;
findByBorrower(borrowerId: string): readonly LendingRecord[];
}
export class SqlLendingRecordStore implements LendingRecordStore {
save(record: LendingRecord): void {}
findByBorrower(borrowerId: string): readonly LendingRecord[] {
return [];
}
}
// 業務ロジック層のファイル (usecase/lend-item.ts)
import type { LendingRecordStore } from "../storage/lending-record-store";
export class LendItem {
constructor(private readonly store: LendingRecordStore) {}
// ^^^^^^^^^^^^^^^^^^
// インターフェースだが、定義されているのはインフラ層。
// 業務ロジックがインフラ層を import している = 依存の向きは元のまま
}
LendItem はインターフェースに依存しており、実装はコンストラクタで注入されます。DI としては正しく、DIP としては何も達成していません。 import の矢印が業務ロジックからインフラ層へ向いたままだからです。インフラ層の都合でインターフェースが変われば、業務ロジックが引きずられます。
抽象を上位側に移すと、向きが変わります。
// 業務ロジック層のファイル (usecase/lend-item.ts)
export interface LendingRecordStore {
save(record: LendingRecord): void;
findByBorrower(borrowerId: string): readonly LendingRecord[];
}
export class LendItem {
constructor(private readonly store: LendingRecordStore) {}
}
// インフラ層のファイル (storage/sql-lending-record-store.ts)
import type { LendingRecordStore } from "../usecase/lend-item";
// ^^^^^^^^^^^^^^^^^^^^
// インフラ層が業務ロジック層を import している = 依存が逆転した
export class SqlLendingRecordStore implements LendingRecordStore {
save(record: LendingRecord): void {}
findByBorrower(borrowerId: string): readonly LendingRecord[] {
return [];
}
}
コードの見た目はほとんど変わりません。変わったのはインターフェースを置くファイルだけです。 それでも意味は逆になります。インフラ層が業務ロジックの決めた契約に従う形になり、実装の都合が上位に漏れなくなりました。
判定方法は単純です。import の矢印がどちらを向いているかを見ます。 上位が下位を import しているなら、逆転していません。
この置き場所について、原典はパッケージ間の循環を断ち切る手法を説明する箇所で明示しています。
Notice the placement of BY. It is placed in the package with the class that uses it. ... Interfaces are very often included in the package that uses them, rather than in the package that implements them. (BY の置き場所に注目してほしい。それはそれを使うクラスと同じパッケージに置かれている。インターフェースは、それを実装するパッケージではなく、それを使うパッケージに含まれることが非常に多い。)
インターフェースは実装側の持ち物ではなく、利用側の持ち物だというのが原典の立場です。
なお、この repo の PHPクラス設計ガイド — インターフェースと抽象への依存 は、逆転を「上位が下位に直接依存する形を、両方とも抽象に依存する形に置き換えること」と説明しています。本章はその先、その抽象がどちらのモジュールに属するかを扱っています。両方が抽象に依存する形になっていても、抽象が下位側にあれば依存の向きは変わりません。
どう誤用されるか
注入していれば逆転していると考える。 上で見たとおりです。DI コンテナに登録することも同じで、登録は組み立ての話であって、依存の向きとは関係ありません。
実装が 1 つしかない抽象を機械的に作る。 第 3 章の予測的抽象化と同じ失敗です。DIP は「すべての依存を抽象にせよ」と読めます。原典もそれを理想として掲げていますが、同時に厳しすぎることを認め、緩和すべき事情があると書いています。
Clearly such a restriction is draconian, and there are mitigating circumstatnces that we will explore momentarily. But, as much as is feasible, the principle should be followed. (明らかにこの制限は厳しすぎるし、緩和すべき事情もある。しかし可能な範囲で、この原則は守られるべきである。綴りは原文ママ。)
原典は続けて、守るべき理由を変更頻度で説明します。
The reason is simple, concrete things change alot, abstract things change much less frequently. (理由は単純である。具象はよく変わり、抽象ははるかに変わりにくい。)
基準は「具象かどうか」ではなく「変わるかどうか」です。 変わらない具象に抽象をかぶせても、守るべきものがありません。
いつ破っていいのか
依存先が安定しているとき。 原典はこの例外を「緩和すべき事情」として節を設けて認めています。
the
string.hstandard C library is very concrete, but is not at all volatile. Depending upon it in an ANSI string environment is not harmful. Likewise, if you have tried and true modules that are concrete, but not volatile, depending upon them is not so bad. (標準 C ライブラリのstring.hは非常に具象だが、まったく揮発性がない。ANSI 文字列環境でこれに依存することは有害ではない。同様に、具象でありながら揮発性のない、試されて確かなモジュールがあるなら、それに依存することはさほど悪くない。)
ただし原典は釘も刺しています。
Non-volatility is not a replacement for the substitutability of an abstract interface. (揮発性がないことは、抽象インターフェースが与える置き換え可能性の代わりにはならない。)
差し替えたい理由がテスト以外にもあるなら、安定していることは抽象を省く理由になりません。
差し替えが実際に起きていないとき。 テストのために差し替える必要が本当にあるか確認します。日付やランダム値のように、テストで制御したいものは差し替えの実績がある典型例です。一方、差し替えたことが一度も無い依存に抽象を挟んでいるなら、その抽象は cost of carry を払っているだけです。
モジュール境界が無いとき。 DIP が守っているのは、モジュールをまたぐ依存の向きです。1 つのファイル、1 つの小さなモジュールの中で完結する依存には、逆転すべき境界がありません。
TypeScript での現在地
TypeScript では、抽象を表現する手段がインターフェースだけではありません。
// 業務ロジック層が必要な形だけを宣言する
type SaveLendingRecord = (record: LendingRecord) => void;
type Now = () => Date;
export function lendItem(
save: SaveLendingRecord,
now: Now,
record: LendingRecord,
): void {
save({ ...record, lentAt: now() });
}
必要な操作が 1 つなら、インターフェースもクラスも要りません。関数型で契約を書けば、依存の向きは同じように制御できます。テストでは関数を渡すだけで差し替えられます。
前章の ISP と同じ発想です。クライアント側が必要な形を宣言し、実装側がそれに合わせる。 名前付きの抽象を作るかどうかは、契約に名前を付けたいかどうかで決めます。
型を import type で読むと、実行時の依存が生まれないという性質もあります。ただしこれは DIP の代わりにはなりません。 実行時の依存が無くても、ソースコード上の依存の向きは変わらないからです。DIP が扱っているのはソースコードの構造です。
まとめ
- Martin は DIP を OCP を実現する主要な手段と位置づけた。独立した目標ではない
- 逆転するのは上位と下位のあいだの依存の向き。詳細を持つ側が抽象に依存する形に変わる
- IoC・DI・DIP は別の概念。 Fowler は IoC が一般的すぎるとして依存性注入という語を選んだ
- **依存を注入しても、抽象が下位側にあれば逆転していない。**判定は
importの矢印の向き - 原典自身が「すべてを抽象に」は厳しすぎると認めている。基準は具象かどうかではなく変わるかどうか
- TypeScript では関数型でも抽象を表現できる。名前付きインターフェースは契約に名前を付けたいときに選ぶ
次に読む
最終章では原則同士の衝突と SOLID の限界を扱います。ここまでの 5 原則が互いに引っ張り合う場面と、どちらを取るかの判断を扱います。
練習問題
次のコードは DI を使っています。DIP を満たしているでしょうか。抽象の所有権を追って答えてください
ファイル構成は次のとおりです。
// infra/system-clock.ts
export interface Clock {
now(): Date;
}
export class SystemClock implements Clock {
now(): Date {
return new Date();
}
}
// infra/id-generator.ts
export interface IdGenerator {
next(): string;
}
// usecase/register-loan.ts
import type { Clock } from "../infra/system-clock";
import type { IdGenerator } from "../infra/id-generator";
export class RegisterLoan {
constructor(
private readonly clock: Clock,
private readonly idGenerator: IdGenerator,
) {}
execute(borrowerId: string, itemId: string): LendingRecord {
return {
id: this.idGenerator.next(),
borrowerId,
itemId,
lentAt: this.clock.now(),
};
}
}
解答例
DI は満たしていますが、DIP は満たしていません。
RegisterLoan は具象クラスを直接生成せず、Clock と IdGenerator をコンストラクタで受け取ります。これは依存性注入です。
しかし import の矢印を見ると、業務ロジック層 (usecase/) がインフラ層 (infra/) を指しています。抽象がインフラ層に置かれているからです。依存の向きは手続き型の設計と同じままで、逆転していません。
具体的な不都合として、infra/system-clock.ts の Clock に「タイムゾーンを返す」といったインフラ都合のメソッドが足されると、業務ロジック側がその変更に引きずられます。抽象の形をインフラ層が決めている状態です。
修正はインターフェースの置き場所を変えるだけです。 Clock と IdGenerator の宣言を usecase/ 側へ移し、infra/ の実装クラスが usecase/ を import する形にします。クラスの中身は 1 行も変わりません。
なお TypeScript では、この 2 つの抽象はインターフェースにする必要すらありません。
// usecase/register-loan.ts
type Now = () => Date;
type NextId = () => string;
export function registerLoan(
now: Now,
nextId: NextId,
borrowerId: string,
itemId: string,
): LendingRecord {
return { id: nextId(), borrowerId, itemId, lentAt: now() };
}
必要な操作が 1 つずつなら関数型で足ります。テストでは関数を渡すだけで差し替えられ、infra/ への import も消えます。
判定の手順をまとめると次のようになります。 (1) 抽象がどのファイルにあるかを見る (2) そのファイルを import しているのが上位か下位かを見る (3) 上位が下位を import しているなら、注入していても逆転していない。