9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TypeScriptシリーズ - Part 6] Dependency Injection in TypeScript

9
Posted at

download (2).png

📝 注記
私は日本語が得意ではありません。この記事はAIの翻訳サポートを受けて書いています。ご了承ください。

📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. DIコンテナの比較 – ライブラリ選定ガイド
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

1. 問題の提示 – どんな時にこのテクニックが必要か

あなたはECサイトのバックエンドを構築しています。以下のようなコードを書いていませんか?

class OrderService {
  private db: Database;
  private logger: Logger;
  private emailService: EmailService;
  private paymentGateway: PaymentGateway;

  constructor() {
    // ❌ 直接依存関係をnewしている
    this.db             = new Database(config);
    this.logger         = new Logger();
    this.emailService   = new EmailService();
    this.paymentGateway = new PaymentGateway();
  }

  async createOrder(order: Order) {
    this.logger.info("Creating order");
    await this.db.save(order);
    await this.paymentGateway.charge(order.total);
    await this.emailService.sendConfirmation(order);
  }
}

問題点:

  • クラスが自分自身の依存関係を直接インスタンス化している
  • 単体テストが困難(モックに差し替えられない)
  • 依存関係の変更がコード全体に波及する
  • クラス間の結合度が高く、再利用性が低い

問いかけ:

どうすればクラスを依存関係から解放し、テスタブルで疎結合な設計を実現できるでしょうか?


2. 悪い例 – まずはダメなコードを見せる

2.1 直接インスタンス化の弊害

// ❌ 直接インスタンス化
class UserService {
  private db: PostgreSQLDatabase;
  private logger: FileLogger;

  constructor() {
    // これらの依存関係を差し替えられない
    this.db     = new PostgreSQLDatabase({ host: "localhost", port: 5432 });
    this.logger = new FileLogger("/var/log/app.log");
  }

  async getUser(id: string) {
    this.logger.info(`Fetching user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
    //                                        ^ SQLインジェクションの危険性も
  }
}

// テストが不可能に近い
describe("UserService", () => {
  it("should get user", async () => {
    const service = new UserService();
    // ❌ 実際のデータベースに接続してしまう
    // ❌ ログファイルも実際に作成される
    const user = await service.getUser("1");
  });
});

2.2 手動依存性注入(サービスロケーターパターン)

// ❌ サービスロケーター(アンチパターンになりがち)
class ServiceLocator {
  private static services: Map<string, any> = new Map();

  static set(name: string, service: any) {
    this.services.set(name, service);
  }

  static get(name: string) {
    const service = this.services.get(name);
    if (!service) throw new Error(`Service ${name} not found`);
    return service;
  }
}

ServiceLocator.set("database", new PostgreSQLDatabase());
ServiceLocator.set("logger", new FileLogger());

class UserService {
  private db: Database;
  private logger: Logger;

  constructor() {
    // まだServiceLocatorに依存している
    this.db     = ServiceLocator.get("database");
    this.logger = ServiceLocator.get("logger");
  }
}

// 問題点:
// 1. どの依存関係が必要か型から読み取れない
// 2. グローバルな状態を持つ
// 3. テスト時に毎回登録し直す必要がある

なぜ悪いのか:

問題 説明
高結合 クラスが具象クラスに直接依存
テスト困難 モックに差し替えられない
隠れた依存関係 コンストラクタを見ただけでは分からない
単一責任違反 オブジェクト生成の責任も負っている

3. 良い例 – TypeScriptの高度機能で解決する

3.1 コンストラクタインジェクション(手動DI)

// ✅ 依存関係をコンストラクタで受け取る
interface Database {
  query(sql: string, params?: any[]): Promise<any>;
}

interface Logger {
  info(message: string): void;
  error(message: string): void;
}

class UserService {
  constructor(
    private db: Database,
    private logger: Logger
  ) {}

  async getUser(id: string): Promise<User> {
    this.logger.info(`Fetching user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = $1`, [id]);
  }
}

// 実際の使用
const db     = new PostgreSQLDatabase(config);
const logger = new ConsoleLogger();
const userService = new UserService(db, logger);

// テストではモックを注入
class MockDatabase implements Database {
  async query(sql: string): Promise<any> {
    return [{ id: "1", name: "Test User" }];
  }
}

const mockDb     = new MockDatabase();
const mockLogger = { info: jest.fn(), error: jest.fn() };
const testService = new UserService(mockDb, mockLogger);

3.2 シンプルなDIコンテナの自作

📝 注意: 以下の実装は概念説明用のシンプルな例です。依存関係の解決に __paramTypes という静的プロパティを手動で定義しています。実際のプロダクションでは reflect-metadata を使ったデコレーターベースのコンテナ(tsyringe、inversifyなど)を使用することを推奨します。

// ✅ タイプセーフなDIコンテナの実装(概念説明用)
type Constructor<T = any> = new (...args: any[]) => T;

interface Registration {
  factory: Constructor | any;
  singleton: boolean;
}

class Container {
  private services:   Map<string, Registration> = new Map();
  private singletons: Map<string, any>          = new Map();

  register<T>(token: string, implementation: Constructor<T>): void {
    this.services.set(token, { factory: implementation, singleton: true });
  }

  registerValue<T>(token: string, value: T): void {
    this.services.set(token, { factory: value, singleton: true });
  }

  resolve<T>(token: string): T {
    const registration = this.services.get(token);
    if (!registration) throw new Error(`Service ${token} not found`);

    if (typeof registration.factory !== "function") {
      return registration.factory;
    }

    if (this.singletons.has(token)) {
      return this.singletons.get(token);
    }

    // __paramTypesは手動で定義したトークンリスト(概念説明用)
    const paramTypes   = (registration.factory as any).__paramTypes || [];
    const dependencies = paramTypes.map((param: string) => this.resolve(param));
    const instance     = new registration.factory(...dependencies);

    this.singletons.set(token, instance);
    return instance;
  }
}

// 使用例
class AppLogger {
  log(message: string) {
    console.log(`[LOG] ${message}`);
  }
}

class AppDatabase {
  constructor(private config: { host: string }) {}

  async query(sql: string) {
    console.log(`Querying ${this.config.host}: ${sql}`);
  }
}

class UserService {
  // 依存関係のトークンを手動で宣言(概念説明用)
  static __paramTypes = ["database", "logger"];

  constructor(
    private db: AppDatabase,
    private logger: AppLogger
  ) {}

  async getUser(id: string) {
    this.logger.log(`Getting user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = $1`);
  }
}

const container = new Container();
container.registerValue("dbConfig",   { host: "localhost" });
container.register("database",        AppDatabase);
container.register("logger",          AppLogger);
container.register("userService",     UserService);

const userService = container.resolve<UserService>("userService");

3.3 tsyringe(Microsoft製の軽量DI)

// ✅ tsyringeの使用例
// tsconfig.json に以下を追加:
// {
//   "experimentalDecorators": true,
//   "emitDecoratorMetadata": true
// }

import "reflect-metadata";
import { container, injectable, inject, singleton } from "tsyringe";

interface Logger {
  log(message: string): void;
}

const LoggerToken = "Logger";

@injectable()
class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

@injectable()
class Database {
  constructor(@inject("DbConfig") private config: { host: string }) {}

  async query(sql: string): Promise<any> {
    console.log(`Querying ${this.config.host}: ${sql}`);
  }
}

@singleton()
class UserService {
  constructor(
    @inject("Database")  private db: Database,
    @inject(LoggerToken) private logger: Logger
  ) {}

  async getUser(id: string): Promise<any> {
    this.logger.log(`Fetching user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = $1`);
  }
}

// 依存関係の登録
container.register("DbConfig",   { useValue: { host: "localhost" } });
container.register("Database",   { useClass: Database });
container.register(LoggerToken,  { useClass: ConsoleLogger });

const userService = container.resolve(UserService);

3.4 awilix(デコレーター不要の軽量DI)

// ✅ awilixの使用例(Node.js向け)
import { createContainer, asClass, asValue } from "awilix";

class Logger {
  info(message: string) {
    console.log(`[INFO] ${message}`);
  }
}

class Database {
  private config: { host: string };

  constructor({ config }: { config: { host: string } }) {
    this.config = config;
  }

  async query(sql: string) {
    console.log(`Querying ${this.config.host}: ${sql}`);
  }
}

class UserService {
  private db: Database;
  private logger: Logger;

  constructor({ db, logger }: { db: Database; logger: Logger }) {
    this.db     = db;
    this.logger = logger;
  }

  async getUser(id: string) {
    this.logger.info(`Fetching user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = $1`);
  }
}

const container = createContainer();

container.register({
  config:      asValue({ host: "localhost" }),
  logger:      asClass(Logger).singleton(),
  db:          asClass(Database).singleton(),
  userService: asClass(UserService).singleton()
});

const userService = container.resolve<UserService>("userService");

3.5 Inversify(エンタープライズ向け機能満載)

// ✅ inversifyの使用例
import "reflect-metadata";
import { Container, injectable, inject } from "inversify";

const TYPES = {
  Logger:      Symbol.for("Logger"),
  Database:    Symbol.for("Database"),
  UserService: Symbol.for("UserService"),
  DbConfig:    Symbol.for("DbConfig")
};

interface Logger {
  log(message: string): void;
}

@injectable()
class FileLogger implements Logger {
  log(message: string): void {
    console.log(`[FILE] ${message}`);
  }
}

@injectable()
class Database {
  constructor(
    @inject(TYPES.DbConfig) private config: { host: string }
  ) {}

  async query(sql: string): Promise<any> {
    console.log(`Querying ${this.config.host}: ${sql}`);
  }
}

@injectable()
class UserService {
  constructor(
    @inject(TYPES.Database) private db: Database,
    @inject(TYPES.Logger)   private logger: Logger
  ) {}

  async getUser(id: string): Promise<any> {
    this.logger.log(`Fetching user ${id}`);
    return this.db.query(`SELECT * FROM users WHERE id = $1`);
  }
}

const container = new Container();
container.bind(TYPES.DbConfig).toConstantValue({ host: "localhost" });
container.bind(TYPES.Logger).to(FileLogger).inSingletonScope();
container.bind(TYPES.Database).to(Database).inSingletonScope();
container.bind(TYPES.UserService).to(UserService);

const userService = container.get<UserService>(TYPES.UserService);

4. DIコンテナの比較 – ライブラリ選定ガイド

比較表

機能 awilix inversify tsyringe typedi
デコレーター不要
reflect-metadata ❌ 不要 ✅ 必須 ✅ 必須 ✅ 必須
登録スタイル 関数型 バインディング クラスベース グローバル
スコープ管理 ✅ 優れている ✅ 複雑 ⚠️ 制限あり ⚠️ 制限あり
バンドルサイズ
Node.js向け ⭐ 最適 ✅ 良い ✅ 良い ✅ 良い
フロントエンド向け ⭐ 最適 ⚠️ 重い ✅ 良い ✅ 良い
学習コスト

選定フローチャート

プロジェクトの種類は?
    │
    ├── Node.jsバックエンド(リクエストスコープが必要)
    │       │
    │       └── awilix ⭐
    │
    ├── フロントエンド(React/Vue)+ 軽量さ重視
    │       │
    │       ├── デコレーターを使いたい     → tsyringe
    │       └── デコレーターを使いたくない → awilix
    │
    ├── 大規模エンタープライズ(複雑な依存関係)
    │       │
    │       └── inversify
    │
    └── 小さなプロジェクト/プロトタイプ
            │
            └── 手動DI or awilix

各ライブラリの特徴

awilix

  • デコレーター不要、シンプルなセットアップ
  • リクエストスコープのサポートが強力
  • Node.jsバックエンドに最適
  • 明示的な登録でデバッグが容易

tsyringe

  • Microsoft製、アクティブにメンテナンス
  • デコレーターベースだがセットアップは比較的簡単
  • モダンなTypeScriptプロジェクトに適している
  • シングルトン/トランジエントのシンプルな管理

inversify

  • 最も機能が豊富(コンテキストバインディング、ミドルウェアなど)
  • エンタープライズ向けの複雑なユースケースに対応
  • 学習曲線が急で、設定も多い
  • 大きなプロジェクトで真価を発揮

5. 課題 – シニア向けのチャレンジ問題

課題1: 手動DIコンテナの拡張

以下の要件を満たすDIコンテナを実装してください。

  • シングルトンスコープとトランジエントスコープの両方をサポート
  • 循環依存を検出してエラーにする
  • ファクトリーパターンで動的な依存関係解決をサポート

💡 ヒント: resolve 呼び出し中に「解決中トークン」を追跡することで循環依存を検出できます。

✅ 解答を見る(クリック)
interface Registration {
  factory: (container: Container) => any;
  singleton: boolean;
}

class Container {
  private registrations: Map<string, Registration> = new Map();
  private instances:     Map<string, any>          = new Map();
  private resolving:     Set<string>               = new Set();

  register<T>(
    token: string,
    factory: (container: Container) => T,
    options?: { singleton?: boolean }
  ): void {
    this.registrations.set(token, {
      factory,
      singleton: options?.singleton ?? false
    });
  }

  resolve<T>(token: string): T {
    const registration = this.registrations.get(token);
    if (!registration) throw new Error(`Token "${token}" not found`);

    // 循環依存の検出
    if (this.resolving.has(token)) {
      throw new Error(`Circular dependency detected for "${token}"`);
    }

    if (registration.singleton && this.instances.has(token)) {
      return this.instances.get(token);
    }

    this.resolving.add(token);
    const instance = registration.factory(this);
    this.resolving.delete(token);

    if (registration.singleton) {
      this.instances.set(token, instance);
    }
    return instance;
  }
}

課題2: テスト容易性の比較

以下のコードをリファクタリングして、テスタブルにしてください。

class PaymentProcessor {
  private apiKey = process.env.STRIPE_API_KEY;
  private stripe = new Stripe(this.apiKey);

  async processPayment(amount: number, cardToken: string) {
    console.log(`Processing ${amount}`);
    return await this.stripe.charges.create({ amount, source: cardToken });
  }
}

💡 ヒント: PaymentGateway インターフェースを切り出し、コンストラクタインジェクションで差し替え可能にします。

✅ 解答を見る(クリック)
interface PaymentGateway {
  charge(amount: number, source: string): Promise<any>;
}

class StripeGateway implements PaymentGateway {
  constructor(private apiKey: string) {}

  async charge(amount: number, source: string) {
    const stripe = new Stripe(this.apiKey);
    return stripe.charges.create({ amount, source });
  }
}

class PaymentProcessor {
  constructor(private gateway: PaymentGateway) {}

  async processPayment(amount: number, cardToken: string) {
    console.log(`Processing ${amount}`);
    return this.gateway.charge(amount, cardToken);
  }
}

// テストではモックを注入
class MockGateway implements PaymentGateway {
  async charge(amount: number, source: string) {
    return { id: "mock_charge", amount };
  }
}

// テスト
const processor = new PaymentProcessor(new MockGateway());
// 実際のStripe APIを呼ばずにテストできる

課題3: プロジェクトに合ったDIライブラリの選定

以下のプロジェクト要件に最適なDIライブラリを選び、理由を説明してください。

  1. プロジェクトA: Express.js製のREST API。リクエストごとにDB接続を分けたい。チームはデコレーターに否定的。
  2. プロジェクトB: React + TypeScriptのフロントエンド。状態管理にDIを導入したい。バンドルサイズを小さく保ちたい。
  3. プロジェクトC: 複数のマイクロサービスを持つ大規模エンタープライズ。複雑な依存関係と動的なバインディングが必要。

💡 ヒント: 比較表の「リクエストスコープ」「バンドルサイズ」「デコレーター不要」の列を参考にしてください。

✅ 解答を見る(クリック)
プロジェクトA → awilix
  - デコレーター不要でチームの要望に合う
  - リクエストスコープのサポートが強力
  - Node.jsバックエンドで実績豊富

プロジェクトB → tsyringe
  - 軽量でバンドルサイズが小さい
  - Reactとの相性が良い
  - Microsoft製で安定性が高い

プロジェクトC → inversify
  - 機能が最も豊富
  - コンテキストバインディングやミドルウェアをサポート
  - 大規模な依存関係グラフを管理可能

6. まとめ

今日学んだこと

概念 説明
Dependency Injection 依存関係を外部から注入するデザインパターン
Inversion of Control 制御の流れを逆転させ、結合度を下げる原則
DIコンテナ 依存関係の登録と解決を自動化するライブラリ
コンストラクタインジェクション 最も推奨される注入方法
シングルトンスコープ アプリケーション全体で1つのインスタンスを共有
トランジエントスコープ 解決のたびに新しいインスタンスを作成
リクエストスコープ HTTPリクエストごとにインスタンスを分離

シニアへのアドバイス

DIを適切に使うと、コードのテスト容易性、保守性、拡張性が劇的に向上します。ただし、DIコンテナを導入することが目的ではありません。まずはコンストラクタインジェクションから始め、プロジェクトの規模に応じてコンテナの導入を検討しましょう。

また、デコレーターベースのDIは強力ですが、設定の複雑さとトレードオフになります。チームの経験値とプロジェクトの要件を考慮して選定してください。

Have a nice day! 🚀

9
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?