この記事の要点
• 依存性注入(DI)はオブジェクトが必要とする依存関係を外部から与える設計パターン
• コンストラクタ注入が最も推奨される注入方式
• テスト容易性・疎結合・交換可能性を実現し、SOLID原則のDIPを体現する
依存性注入(Dependency Injection、以下 DI)は、オブジェクトが必要とする依存関係を外部から与える設計パターンです。SOLID原則の「D(Dependency Inversion Principle)」を実現する代表的手段であり、テスト容易性・疎結合・交換可能性を実現します。本記事では、DIの基本概念から、各種注入方式、DIコンテナの仕組み、現代フレームワークでの実践までを体系的に解説します。
概要
依存性注入とは
オブジェクトがコラボレーター(依存先)を自分で作成するのではなく、外部から受け取る設計です。「依存関係を注入する(inject)」と表現されます。
// ❌ 依存性注入なし: 自分で作る = 密結合
class OrderService {
private repo = new PostgresOrderRepository(); // 具象に依存
private logger = new FileLogger("/var/log"); // 構築方法まで知る
}
// ✅ 依存性注入あり: 外から受け取る = 疎結合
class OrderService {
constructor(
private readonly repo: OrderRepository, // 抽象に依存
private readonly logger: Logger,
) {}
}
Inversion of Control との関係
DI は IoC(制御の反転)の一実装です。
flowchart TB
subgraph Traditional["伝統的な制御"]
App1["Application"]
Lib1["Library"]
App1 -->|呼び出す| Lib1
end
subgraph IoC["IoC"]
Framework["Framework"]
App2["Application"]
Framework -->|呼び出す| App2
App2 -. 依存の制御権を<br/>外に渡す .-> Framework
end
IoC は「制御の流れ」、DI は「依存の構築」に着目した考え方です。
原則・定義
定義(Martin Fowler)
Dependency Injection is a form of Inversion of Control where the implementation of IoC is concerned with acquiring dependent services.
依存性逆転の原則(DIP)
SOLID の D に対応する2つのルール:
- 上位モジュールは下位モジュールに依存してはならない。両者は抽象に依存すべき
- 抽象は詳細に依存してはならない。詳細が抽象に依存すべき
3 つの注入方式
flowchart TB
Constructor["Constructor Injection<br/>コンストラクタで渡す"]
Setter["Setter Injection<br/>setter で渡す"]
Method["Method Injection<br/>メソッド引数で渡す"]
Constructor -. 推奨 .-> Best["不変・必須依存"]
Setter -.-> Option["任意依存"]
Method -.-> Specific["呼び出し毎に変わる"]
構成要素
DI を構成する役割
flowchart LR
Client["Client<br/>(依存を使う側)"]
Service["Service<br/>(依存される側)"]
Interface["Interface<br/>(抽象)"]
Injector["Injector<br/>(組み立て役)"]
Client -->|依存| Interface
Service -->|実装| Interface
Injector -->|作って渡す| Client
Injector -->|作る| Service
- Client: 依存を使うオブジェクト
- Service: 依存される実装
- Interface: 抽象契約
- Injector(DIコンテナ): 依存グラフを解決して注入する仕組み
実装例
1. コンストラクタ注入(Constructor Injection)
最も推奨される方式です。依存は不変(readonly)として扱えます。
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
interface Logger {
info(msg: string, meta?: unknown): void;
error(msg: string, err?: Error): void;
}
class UserRegistrationService {
constructor(
private readonly users: UserRepository,
private readonly email: EmailService,
private readonly logger: Logger,
) {}
async register(data: RegisterDto): Promise<User> {
this.logger.info("registering user", { email: data.email });
const user = User.create(data);
await this.users.save(user);
try {
await this.email.send(user.email, "Welcome", "Thanks for signing up");
} catch (err) {
this.logger.error("failed to send welcome email", err as Error);
}
return user;
}
}
実践メモ: DIの最大の恩恵の一つがテスト容易性です。本番ではDB接続を注入し、テストではインメモリ実装を注入することで高速で安定したテストが書けます。
2. テスト容易性
import { describe, expect, it, vi } from "vitest";
describe("UserRegistrationService", () => {
it("saves user and sends welcome email", async () => {
// すべてモックで置き換え可能
const users: UserRepository = {
findById: vi.fn(),
save: vi.fn().mockResolvedValue(undefined),
};
const email: EmailService = {
send: vi.fn().mockResolvedValue(undefined),
};
const logger: Logger = { info: vi.fn(), error: vi.fn() };
const service = new UserRegistrationService(users, email, logger);
await service.register({ email: "a@example.com", name: "Alice" });
expect(users.save).toHaveBeenCalled();
expect(email.send).toHaveBeenCalledWith(
"a@example.com",
"Welcome",
expect.any(String),
);
});
});
3. Pure DI(コンテナなしでの組み立て)
シンプルなアプリではコンテナ不要で、手動で組み立てるだけで十分です。
// composition root: アプリのエントリポイントで一度だけ組み立てる
import { Pool } from "pg";
function compose() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const logger = new ConsoleLogger();
const users = new PostgresUserRepository(pool, logger);
const email = new SmtpEmailService({
host: process.env.SMTP_HOST!,
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
});
return {
registration: new UserRegistrationService(users, email, logger),
};
}
// main
const app = compose();
await app.registration.register({ email: "a@example.com", name: "Alice" });
4. 簡易 DI コンテナの実装
type Factory<T> = (c: Container) => T;
type Token<T> = symbol & { __type?: T };
function token<T>(name: string): Token<T> {
return Symbol(name) as Token<T>;
}
class Container {
private providers = new Map<symbol, Factory<unknown>>();
private singletons = new Map<symbol, unknown>();
register<T>(tk: Token<T>, factory: Factory<T>, opts?: { singleton?: boolean }) {
this.providers.set(tk, (c) => {
if (opts?.singleton) {
if (!this.singletons.has(tk)) {
this.singletons.set(tk, factory(c));
}
return this.singletons.get(tk);
}
return factory(c);
});
}
resolve<T>(tk: Token<T>): T {
const factory = this.providers.get(tk);
if (!factory) throw new Error(`No provider for ${tk.toString()}`);
return factory(this) as T;
}
}
// 使用例
const TOKENS = {
Logger: token<Logger>("Logger"),
UserRepo: token<UserRepository>("UserRepository"),
Email: token<EmailService>("EmailService"),
Registration: token<UserRegistrationService>("UserRegistrationService"),
};
const c = new Container();
c.register(TOKENS.Logger, () => new ConsoleLogger(), { singleton: true });
c.register(TOKENS.UserRepo, (c) => new PostgresUserRepository(pool, c.resolve(TOKENS.Logger)), { singleton: true });
c.register(TOKENS.Email, () => new SmtpEmailService(smtpConfig), { singleton: true });
c.register(
TOKENS.Registration,
(c) =>
new UserRegistrationService(
c.resolve(TOKENS.UserRepo),
c.resolve(TOKENS.Email),
c.resolve(TOKENS.Logger),
),
);
const registration = c.resolve(TOKENS.Registration);
5. NestJS でのデコレータベース DI
import { Injectable, Module } from "@nestjs/common";
@Injectable()
class UserRegistrationService {
constructor(
private readonly users: UserRepository,
private readonly email: EmailService,
) {}
// ...
}
@Module({
providers: [
UserRegistrationService,
{ provide: "UserRepository", useClass: PostgresUserRepository },
{ provide: "EmailService", useClass: SmtpEmailService },
],
exports: [UserRegistrationService],
})
class UserModule {}
6. Spring Framework でのアノテーションベース DI
@Service
public class UserRegistrationService {
private final UserRepository users;
private final EmailService email;
// Spring 4.3+ では @Autowired は省略可
public UserRegistrationService(UserRepository users, EmailService email) {
this.users = users;
this.email = email;
}
}
メリット・デメリット
メリット
- 疎結合: 抽象に依存するため実装を交換可能
- テスト容易性: モック/フェイクに差し替えて単体テスト可能
- 再利用性: 同じクラスを異なる依存の組み合わせで使える
- 責務の分離: 「使う」と「作る」を分離
- Composition Root: アプリ全体の構成が一箇所に集約
- ライフサイクル管理: コンテナがシングルトン等を管理
デメリット
- 初期学習コスト: IoC/DI の概念理解が必要
- 間接化: デバッグ時に追いにくい
- 過度な抽象化: 不要なインターフェース乱造の危険
- コンテナ依存: フレームワーク固有の記法に縛られる
- 実行時エラー: 型安全でない DI だと実行時まで気づかない
- 起動コスト: 依存グラフ解決のオーバーヘッド
ユースケース
適している場面
- Webアプリケーション: Controller → Service → Repository の構造
- エンタープライズシステム: 多数のコラボレーター
- テスト駆動開発: モックによる単体テストが前提
- プラグイン構造: 実装を差し替え可能にしたい
- マルチテナント: テナントごとに異なる実装
不要な場面
- 1ファイルで完結する小さなスクリプト
- 依存先が1つしかない単純なクラス
- プロトタイプ段階でインターフェースが流動的
注意: サービスロケータパターンはDIと似ていますが、依存関係が隠蔽されるためテスト容易性が低下します。コンストラクタ注入を優先しましょう。
落とし穴
1. サービスロケータとの混同
// ❌ サービスロケータ: 依存を「取りに行く」
class OrderService {
process() {
const db = Container.get<Database>("db"); // 隠れた依存
}
}
// ✅ DI: 依存を「受け取る」
class OrderService {
constructor(private readonly db: Database) {} // 明示的な依存
}
サービスロケータは依存関係が型シグネチャに現れず、DI の利点の多くが失われます。
2. 過剰なインターフェース
1実装しかないのに常に interface を切る必要はありません。YAGNI の原則に従い、必要になってから抽出しましょう。
3. God Container
1つの巨大なコンテナにアプリ全体の依存を詰め込むと、起動時間・メモリ使用・理解困難の問題が発生します。モジュール単位にコンテナを分けます。
4. 循環依存
A → B → A のような循環依存は設計の赤信号です。DI コンテナによっては解決できますが、根本的な設計見直しが必要です。
5. コンストラクタ肥大化
コンストラクタ引数が10個を超えたら、そのクラスは責務過多です。責務分解のサインと捉えます。
6. プロパティ注入の濫用
セッター注入は「任意の依存」に限定すべきで、必須依存はコンストラクタで注入します。セッター注入された必須依存は NullPointer の温床です。
比較表
注入方式の比較
| 方式 | 不変性 | 必須依存 | 可読性 | テスト |
|---|---|---|---|---|
| コンストラクタ | ○ | ○ | ○ | ◎ |
| セッター | × | △ | △ | ○ |
| メソッド | ○ | × | △ | ○ |
| フィールド(アノテーション) | × | △ | × | △ |
DI 方式の比較
| 方式 | 例 | 型安全 | フレームワーク依存 |
|---|---|---|---|
| Pure DI | 手動組み立て | ◎ | なし |
| 軽量コンテナ | tsyringe, awilix | ○ | 小 |
| 重量コンテナ | NestJS, Spring | △ | 大 |
| サービスロケータ | - | × | - |
ベストプラクティス
- コンストラクタ注入を原則に: 不変で明示的
- Composition Root を1つに: アプリ起動時に1箇所で組み立て
- 抽象に依存、実装を注入: DIP を徹底
- インターフェースは必要になってから: 投機的抽象化を避ける
- ライフタイムを意識: Singleton / Scoped / Transient を使い分け
- 循環依存を禁止: リンター/解析ツールで検出
- 小さなコンテナ: モジュール単位に分割
- DIコンテナは最後の手段: Pure DI で間に合うなら不要
まとめ
依存性注入は、オブジェクト指向設計の中核を成す基本技法です。
- 本質: 依存を外部から与える = 疎結合
- 原則: 抽象に依存する(DIP)
- 方式: コンストラクタ注入が基本
- 恩恵: テスト容易性・交換可能性・責務分離
- 実装: Pure DI で十分なら、コンテナは不要
DI は目的ではなく手段です。テスト容易性と変更容易性を実現するための「道具」として使いこなしましょう。
応用トピック
ライフタイムスコープ
DI コンテナが管理する依存のライフサイクルには、主に3種類あります。
| スコープ | 説明 | 例 |
|---|---|---|
| Singleton | アプリ起動から終了まで1インスタンス | Logger, ConfigService |
| Scoped | 1リクエスト内で1インスタンス | DbContext, RequestContext |
| Transient | 取得するたびに新インスタンス | 一時的なバリデータ |
// NestJS での例
@Injectable({ scope: Scope.REQUEST }) // リクエストスコープ
class UserContext {
constructor(@Inject(REQUEST) private readonly req: Request) {}
}
@Injectable() // デフォルトは Singleton
class Logger {}
@Injectable({ scope: Scope.TRANSIENT }) // 毎回新規
class RequestIdGenerator {}
ファクトリ関数による動的依存
構築時に実行時情報が必要な場合、ファクトリ関数を注入します。
type OrderRepositoryFactory = (tenantId: string) => OrderRepository;
class OrderService {
constructor(
private readonly repoFactory: OrderRepositoryFactory,
) {}
async list(tenantId: string): Promise<Order[]> {
// テナントごとにリポジトリを切り替え
const repo = this.repoFactory(tenantId);
return repo.findAll();
}
}
関数型DI: 高階関数アプローチ
クラスを使わない関数型言語・スタイルでは、高階関数によるDIが自然です。
// 依存を部分適用で注入
const makeRegisterUser =
(users: UserRepository, email: EmailService, logger: Logger) =>
async (data: RegisterDto): Promise<User> => {
logger.info("registering", { email: data.email });
const user = User.create(data);
await users.save(user);
await email.send(user.email, "Welcome", "...");
return user;
};
// composition root
const registerUser = makeRegisterUser(usersImpl, emailImpl, loggerImpl);
// 使用
await registerUser({ email: "a@example.com", name: "Alice" });
Reader モナド(関数型)
Scala / Haskell / fp-ts では Reader モナドで DI を表現することもあります。
// fp-ts 風の擬似コード
import * as R from "fp-ts/Reader";
type Deps = { users: UserRepository; logger: Logger };
const findUser = (id: string): R.Reader<Deps, Promise<User | null>> =>
(deps) => {
deps.logger.info("finding", { id });
return deps.users.findById(id);
};
モジュールシステムとしてのDI
DI コンテナは「モジュール構成の仕組み」として機能します。機能単位のモジュールに依存を閉じ込め、公開するインターフェースを明確にすることで、大規模アプリの構造を保てます。
// UserModule が公開する依存
export const userModule = {
UserRepository: token<UserRepository>("UserRepository"),
UserService: token<UserService>("UserService"),
};
// 他モジュールは UserService のみを import する
静的 DI(コンパイル時DI)
一部のライブラリ(Dagger in Java, Macaron in Scala)では、依存グラフをコンパイル時に解決し、実行時オーバーヘッドとエラーを排除します。型安全性が最大化されますが、設定の柔軟性は下がります。
参考リソース
- Martin Fowler - Inversion of Control Containers and the Dependency Injection pattern
- Martin Fowler - InversionOfControl
- Robert C. Martin - The Dependency Inversion Principle (PDF)
- Mark Seemann - Dependency Injection Principles, Practices, and Patterns
- NestJS - Custom providers
- Spring Framework - Core Technologies