依存性注入(Dependency Injection、DI)は、classが必要なobjectを内部で作らず、外から受け取る設計です。
この記事では、注文を保存する小さなTypeScript codeを使い、newで固定された依存先をconstructor injectionへ変えます。最後にfakeを渡してtestし、applicationの入口で本物を組み立てます。
まず結論
次の2つを比べてください。
class OrderService {
private repository = new PostgresOrderRepository();
}
class OrderService {
constructor(
private readonly repository: OrderRepository,
) {}
}
上はOrderService自身がPostgreSQL用repositoryを作っています。下は必要なrepositoryを外から受け取ります。
この小さな違いにより、本番ではPostgreSQL実装、testではmemory上のfakeを渡せます。

「依存する」とは
ある処理が別のobjectなしでは仕事を完了できない時、そのobjectへ依存しています。
注文を登録するserviceなら、次へ依存するかもしれません。
- 注文を保存するrepository
- 完了mailを送るmailer
- 時刻を返すclock
依存があること自体は問題ではありません。問題は、業務処理が依存先の作り方まで知り、交換できなくなることです。
newで固定すると何が困るか
最初の実装を見ます。
type Order = {
id: string;
total: number;
};
class PostgresOrderRepository {
async save(order: Order): Promise<void> {
console.log("save to PostgreSQL", order);
}
}
class OrderService {
private readonly repository =
new PostgresOrderRepository();
async place(order: Order): Promise<void> {
if (order.total <= 0) {
throw new Error("total must be positive");
}
await this.repository.save(order);
}
}
place()の仕事は、注文を検証して保存することです。しかしclass内部のnewにより、次の責任まで持っています。
- どのrepository実装を使うか
- 依存先をいつ作るか
- 依存先の設定をどう渡すか
testでplace()だけを確かめたい時にも、PostgreSQL実装が付いてきます。
constructor injectionへ変える
まず、serviceが必要とする最小の操作をinterfaceにします。
type Order = {
id: string;
total: number;
};
interface OrderRepository {
save(order: Order): Promise<void>;
}
serviceはinterfaceをconstructorで受け取ります。
class OrderService {
constructor(
private readonly repository: OrderRepository,
) {}
async place(order: Order): Promise<void> {
if (order.total <= 0) {
throw new Error("total must be positive");
}
await this.repository.save(order);
}
}
OrderServiceは「save()できるobject」が必要だと宣言します。PostgreSQLへの接続方法は知りません。
constructor injectionには次の利点があります。
- 必要な依存がconstructorから分かる
- 依存なしの不完全なinstanceを作りにくい
- propertyを後から差し替えずに済む
- test用実装を渡しやすい
この記事では、依存が必須ならconstructor injectionを基本にします。
本番用repositoryを作る
interfaceを満たす実装を用意します。
class PostgresOrderRepository
implements OrderRepository
{
async save(order: Order): Promise<void> {
console.log("save to PostgreSQL", order);
}
}
TypeScriptのinterfaceは実行時には消えます。重要なのは、serviceと実装が同じmethod契約を共有することです。
実際のprojectでは、このclassがdatabase clientを受け取り、SQLやORMを呼びます。業務規則をrepositoryへ移す必要はありません。
fakeを使ってtestする
databaseへ接続せず、保存された注文をmemoryへ記録するfakeを作ります。
class FakeOrderRepository
implements OrderRepository
{
readonly saved: Order[] = [];
async save(order: Order): Promise<void> {
this.saved.push(order);
}
}
Node.js標準test runnerでserviceを確認します。
import assert from "node:assert/strict";
import test from "node:test";
test("有効な注文をrepositoryへ保存する", async () => {
const repository = new FakeOrderRepository();
const service = new OrderService(repository);
const order = { id: "order-1", total: 3000 };
await service.place(order);
assert.deepEqual(repository.saved, [order]);
});
test("0円の注文を保存しない", async () => {
const repository = new FakeOrderRepository();
const service = new OrderService(repository);
await assert.rejects(
() => service.place({ id: "order-2", total: 0 }),
/total must be positive/,
);
assert.deepEqual(repository.saved, []);
});
このtestが確認しているのはOrderServiceの規則です。database接続testはPostgresOrderRepository側へ分けられます。
fake、stub、mockの違い
test doubleには複数の呼び方があります。
| 名前 | 主な役割 |
|---|---|
| fake | 簡単に動く代替実装 |
| stub | 決めた値を返す |
| spy | 呼び出しを記録する |
| mock | 期待した呼び出しか検証する |
FakeOrderRepositoryはmemory上で保存動作を実装しているためfakeです。同時にstored配列で呼び出し結果も観測できます。
名前を暗記することより、何を置き換え、何を確認するかを明確にする方が重要です。
composition rootで組み立てる
依存先を外へ出すと、どこかでobjectを組み立てる必要があります。その場所をcomposition rootと呼びます。
async function main() {
const repository =
new PostgresOrderRepository();
const service =
new OrderService(repository);
await service.place({
id: "order-1",
total: 3000,
});
}
await main();
composition rootはapplicationの入口付近へ置きます。
- CLIなら
main.ts - Web serverなら起動file
- serverlessならhandlerを作るmodule
業務classの各所でばらばらに依存を組み立てると、交換箇所が再び増えます。
containerは必須ではない
DI containerは、objectの登録と組み立てを自動化するtoolです。DIそのものではありません。
小さなapplicationなら、先ほどのようにnewを数行書く方が分かりやすい場合があります。この方法はPure DIとも呼ばれます。
containerを導入する前に、次を確認します。
- 手作業の組み立てが本当に複雑か
- objectのlifetime管理が必要か
- teamがcontainerの規則を理解できるか
- error時に組み立てを追跡できるか
「DIを使うならcontainerが必要」という理解は誤りです。
DI、IoC、DIPを区別する
似た用語を短く整理します。
DI
必要な依存objectを外から渡す具体的な設計手法です。今回のconstructor injectionが該当します。
IoC
Inversion of Controlは、処理や制御の決定を自分のcodeだけで握らず、外部の仕組みへ移す広い考え方です。DIはIoCを実現する一つの方法です。
DIP
Dependency Inversion Principleは、上位の方針を下位の具体的実装へ直接結び付けず、両方を抽象へ依存させる設計原則です。
今回なら、業務規則を持つOrderServiceとPostgreSQL実装がOrderRepositoryという契約で分かれています。
用語の関係は次のように考えられます。
DIP: 依存方向を考える原則
DI : 依存を外から渡す手法
IoC: 制御を外へ移す広い考え方
三つは同じ意味ではありません。
interfaceを増やしすぎない
DIを学ぶと、すべてのclassへinterfaceを作りたくなることがあります。
次のような小さく安定した値計算まで、必ず抽象化する必要はありません。
function addTax(price: number): number {
return Math.floor(price * 1.1);
}
交換する必要がある境界、外部I/O、testで制御したい依存から検討します。
- database
- mail service
- external API
- 現在時刻
- random値
- file system
interfaceが1つの実装をそのまま写しただけで、交換もtestも不要なら、抽象化が早すぎる可能性があります。
よくある誤解
newはすべて禁止
newは必要です。問題は業務処理の内部に組み立てが散らばることです。composition rootでは具体objectをnewします。
fake testがあれば本番接続testは不要
fakeは業務規則を速く確認できますが、本物のdatabaseとの契約までは保証しません。repositoryのintegration testは別に必要です。
constructorが長いほどDIができている
依存が多すぎるconstructorは、classの責任が広すぎる合図かもしれません。containerで隠す前に責任を見直します。
service locatorも同じ
method内部からglobal containerを呼び出すservice locatorでは、必要な依存がconstructorから見えません。この記事のDIとは区別します。
導入するか判断する質問
次の質問へ複数当てはまるならDIが役立ちます。
- 外部serviceやdatabaseを交換したいか
- testで失敗、時刻、返り値を制御したいか
- 業務規則とI/Oを分けたいか
- environmentごとに実装を変えるか
- 依存先の構築設定が業務classへ漏れているか
単純なscriptで依存先が1つしかなく、testでも交換しないなら、直接呼び出す方が簡潔な場合もあります。
まとめ
- DIは依存objectを外から渡す手法
- constructor injectionは必須依存を明示しやすい
- fakeを渡すと業務規則を速くtestできる
- 具体実装はcomposition rootで組み立てる
- DI containerは任意であり、小規模なら不要
- DI、IoC、DIPは関係するが同じ意味ではない
参考リソース
- Martin Fowler: Inversion of Control Containers and the Dependency Injection pattern
- SOLID原則
- Repositoryパターン
- 単一責任の原則