T-CREATOR

TypeScript で依存性注入(DI)を実践する:InversifyJS 入門

TypeScript で依存性注入(DI)を実践する:InversifyJS 入門

現代の TypeScript 開発において、コードの保守性と拡張性を高めることは、まさに開発者にとって永遠の課題です。プロジェクトが成長するにつれて、モジュール間の依存関係は複雑化し、テストが困難になり、新機能の追加に時間がかかるようになってしまいます。

しかし、依存性注入(Dependency Injection)という強力な設計パターンと、TypeScript に特化したInversifyJSというライブラリを組み合わせることで、この課題を根本的に解決できるのです。本記事では、InversifyJS を使った実践的な DI 実装を通じて、あなたのコードが劇的に改善される体験をお届けします。

TypeScript での依存性注入は、単なる技術的なパターンではありません。コードに込められた開発者の意図を明確に表現し、チーム全体の生産性を向上させる「思想」なのです。InversifyJS は、この思想を TypeScript の型システムと完璧に融合させ、開発者体験を最大化してくれます。

背景

従来のオブジェクト管理の課題

多くの TypeScript プロジェクトでは、オブジェクトの生成と管理が散在し、コードの結合度が高くなってしまう問題があります。

以下のような従来のコードを見てみましょう:

typescript// 問題のあるコード例:強い結合
class UserService {
  private database: Database;
  private logger: Logger;

  constructor() {
    // 直接インスタンスを生成(強い結合)
    this.database = new MySQLDatabase();
    this.logger = new FileLogger();
  }

  async getUser(id: string): Promise<User> {
    this.logger.log(`Fetching user: ${id}`);
    return await this.database.findUser(id);
  }
}

この書き方では、UserServiceMySQLDatabaseFileLoggerに強く依存してしまい、以下の問題が発生します:

#問題点影響
1テスト時の困難さモックやスタブが使えない
2設定の変更困難データベースの種類を変更したい場合に大幅な修正が必要
3循環依存のリスクサービス間で相互参照が発生しやすい

依存性注入パターンとは何か

依存性注入(DI:Dependency Injection)は、オブジェクトが必要とする依存関係を外部から注入する設計パターンです。これにより、オブジェクト間の結合度を下げ、テストしやすく拡張性の高いコードが実現できます。

DI の基本原則は「依存関係の制御を外部に委ねる」ことです。これは、まさに開発者が書くコードの品質を根本的に向上させる魔法のような仕組みなのです。

typescript// DIパターンの基本概念
interface IDatabase {
  findUser(id: string): Promise<User>;
}

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

// 依存関係をコンストラクタで受け取る
class UserService {
  constructor(
    private database: IDatabase,
    private logger: ILogger
  ) {}

  async getUser(id: string): Promise<User> {
    this.logger.log(`Fetching user: ${id}`);
    return await this.database.findUser(id);
  }
}

TypeScript エコシステムでの DI ライブラリ比較

TypeScript の DI ライブラリは複数存在しますが、それぞれに特徴があります:

ライブラリ特徴学習コストTypeScript 対応
InversifyJSデコレータベース、強力な型サポート中程度★★★★★
TSyringeMicrosoft 製、軽量低い★★★★☆
TypeDIシンプルな構文低い★★★☆☆
Awilix関数型アプローチ高い★★☆☆☆

InversifyJS は、TypeScript の型システムを最大限活用し、コンパイル時の型安全性を保ちながら、実行時の柔軟性も提供する最適な選択肢です。

課題

強い結合による保守性の低下

従来のコード書き方では、以下のような問題に直面することが多々あります:

typescript// 問題のあるコード:複数の責任を持つクラス
class OrderProcessor {
  constructor() {
    // すべての依存関係をハードコーディング
    this.paymentGateway = new StripePaymentGateway();
    this.inventoryService = new DatabaseInventoryService();
    this.emailService = new SendGridEmailService();
    this.logger = new FileLogger('/var/log/orders.log');
  }

  async processOrder(order: Order): Promise<void> {
    // 複雑な処理ロジック...
  }
}

このコードを本番環境から開発環境に移す際、設定変更のために大幅な修正が必要になってしまいます。まさに開発者の悪夢です。

テストしにくいコード構造

上記のコードをユニットテストしようとすると、以下のようなエラーに遭遇します:

typescript// テスト時に発生する典型的なエラー
describe('OrderProcessor', () => {
  it('should process order successfully', async () => {
    const processor = new OrderProcessor();

    // エラー: 実際のStripeに接続しようとしてしまう
    // Error: ENOTFOUND api.stripe.com
    await processor.processOrder(mockOrder);
  });
});

このエラーは、テスト環境でも実際の外部サービスに接続しようとするために発生します。開発者にとって、このようなテストは書きたくても書けない状況を生み出してしまいます。

モジュール間の依存関係の複雑化

プロジェクトが成長すると、以下のような循環依存エラーが発生することがあります:

typescript// 循環依存エラーの例
// ReferenceError: Cannot access 'UserService' before initialization

// user.service.ts
import { OrderService } from './order.service';

export class UserService {
  constructor() {
    this.orderService = new OrderService(); // OrderServiceがUserServiceを参照している場合エラー
  }
}

// order.service.ts
import { UserService } from './user.service';

export class OrderService {
  constructor() {
    this.userService = new UserService(); // 循環依存が発生
  }
}

解決策

InversifyJS の基本概念とメリット

InversifyJS は、これらの課題を以下の核となる概念で解決します:

  1. コンテナ(Container): 依存関係の登録と解決を管理
  2. 識別子(Identifier): サービスを一意に識別するキー
  3. デコレータ(Decorator): TypeScript デコレータを使った宣言的な注入

InversifyJS の真の美しさは、コードを読む人が「何がどこから来るのか」を一目で理解できることです。

typescript// InversifyJSを使った美しいコード
import { injectable, inject } from 'inversify';
import { TYPES } from './types';

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

  async getUser(id: string): Promise<User> {
    this.logger.log(`Fetching user: ${id}`);
    return await this.database.findUser(id);
  }
}

IoC(制御の反転)コンテナの仕組み

IoC(Inversion of Control)コンテナは、依存関係の制御を開発者からフレームワークに移すパターンです。InversifyJS では、このコンテナが依存関係のライフサイクルを完全に管理してくれます。

typescript// コンテナの設定例
import { Container } from 'inversify';

const container = new Container();

// サービスの登録(バインディング)
container.bind<IDatabase>(TYPES.Database).to(MySQLDatabase);
container.bind<ILogger>(TYPES.Logger).to(FileLogger);
container
  .bind<UserService>(TYPES.UserService)
  .to(UserService);

// 依存関係の自動解決
const userService = container.get<UserService>(
  TYPES.UserService
);

このコンテナシステムにより、開発者は「何を使うか」ではなく「どう使うか」に集中できるようになります。

デコレータベースの依存性管理

InversifyJS の最大の特徴は、TypeScript デコレータを活用した宣言的な依存性管理です:

typescript// 型安全な識別子の定義
const TYPES = {
  Database: Symbol.for('Database'),
  Logger: Symbol.for('Logger'),
  UserService: Symbol.for('UserService'),
  EmailService: Symbol.for('EmailService'),
} as const;

// インターフェースの定義
interface IEmailService {
  sendEmail(
    to: string,
    subject: string,
    body: string
  ): Promise<void>;
}

@injectable()
class EmailService implements IEmailService {
  constructor(
    @inject(TYPES.Logger) private logger: ILogger
  ) {}

  async sendEmail(
    to: string,
    subject: string,
    body: string
  ): Promise<void> {
    this.logger.log(`Sending email to: ${to}`);
    // メール送信ロジック
  }
}

具体例

環境構築とセットアップ

まずは、InversifyJS を使った TypeScript プロジェクトの環境を構築しましょう。以下のコマンドで必要なパッケージをインストールします:

bash# プロジェクトの初期化
yarn init -y

# TypeScript関連のパッケージをインストール
yarn add typescript ts-node @types/node

# InversifyJS関連のパッケージをインストール
yarn add inversify reflect-metadata

# 開発用依存関係をインストール
yarn add -D @types/jest jest ts-jest

次に、TypeScript の設定ファイル(tsconfig.json)を作成します:

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

重要なのは、experimentalDecoratorsemitDecoratorMetadatatrueに設定することです。これにより、InversifyJS のデコレータが正しく動作します。

基本的なサービス登録と注入

実際のアプリケーションでの基本的な使い方を見てみましょう。まずは、メインファイルで reflect-metadata をインポートします:

typescript// src/main.ts
import 'reflect-metadata';
import { Container } from 'inversify';
import { TYPES } from './types';
import { UserService } from './services/UserService';

// コンテナの作成
const container = new Container();

// サービスのバインディング設定
container
  .bind<UserService>(TYPES.UserService)
  .to(UserService);

// アプリケーションの開始
async function startApp() {
  const userService = container.get<UserService>(
    TYPES.UserService
  );
  const user = await userService.getUser('123');
  console.log('User fetched:', user);
}

startApp().catch(console.error);

型識別子を定義するファイル(types.ts)は以下のようになります:

typescript// src/types.ts
export const TYPES = {
  UserService: Symbol.for('UserService'),
  Database: Symbol.for('Database'),
  Logger: Symbol.for('Logger'),
  Cache: Symbol.for('Cache'),
} as const;

この Symbol を使ったアプローチにより、型安全性を保ちながら文字列による識別子の衝突を防げます。

インターフェースを使った抽象化

実際の開発では、具象クラスではなくインターフェースに依存することが重要です。以下のような構造を作ってみましょう:

typescript// src/interfaces/IDatabase.ts
export interface IDatabase {
  findUser(id: string): Promise<User | null>;
  saveUser(user: User): Promise<void>;
  deleteUser(id: string): Promise<boolean>;
}

// src/interfaces/ILogger.ts
export interface ILogger {
  log(message: string): void;
  error(message: string, error?: Error): void;
  warn(message: string): void;
}

// src/models/User.ts
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

これらのインターフェースを実装する具象クラスを作成します:

typescript// src/services/MySQLDatabase.ts
import { injectable } from 'inversify';
import { IDatabase } from '../interfaces/IDatabase';
import { User } from '../models/User';

@injectable()
export class MySQLDatabase implements IDatabase {
  async findUser(id: string): Promise<User | null> {
    // 実際のMySQL接続ロジック
    console.log(`Querying MySQL for user: ${id}`);

    // サンプルデータを返す
    return {
      id,
      name: 'John Doe',
      email: 'john@example.com',
      createdAt: new Date(),
    };
  }

  async saveUser(user: User): Promise<void> {
    console.log('Saving user to MySQL:', user);
  }

  async deleteUser(id: string): Promise<boolean> {
    console.log(`Deleting user from MySQL: ${id}`);
    return true;
  }
}

ロガーサービスの実装も同様に作成します:

typescript// src/services/ConsoleLogger.ts
import { injectable } from 'inversify';
import { ILogger } from '../interfaces/ILogger';

@injectable()
export class ConsoleLogger implements ILogger {
  log(message: string): void {
    console.log(
      `[INFO] ${new Date().toISOString()}: ${message}`
    );
  }

  error(message: string, error?: Error): void {
    console.error(
      `[ERROR] ${new Date().toISOString()}: ${message}`
    );
    if (error) {
      console.error(error.stack);
    }
  }

  warn(message: string): void {
    console.warn(
      `[WARN] ${new Date().toISOString()}: ${message}`
    );
  }
}

実践的な Web アプリケーション例

Express.js を使った実践的な Web アプリケーションの例を見てみましょう。まず、必要なパッケージを追加します:

bash# Express関連パッケージの追加
yarn add express
yarn add -D @types/express

コントローラーを作成します:

typescript// src/controllers/UserController.ts
import { injectable, inject } from 'inversify';
import { Request, Response } from 'express';
import { TYPES } from '../types';
import { UserService } from '../services/UserService';

@injectable()
export class UserController {
  constructor(
    @inject(TYPES.UserService)
    private userService: UserService
  ) {}

  async getUser(
    req: Request,
    res: Response
  ): Promise<void> {
    try {
      const { id } = req.params;

      if (!id) {
        res
          .status(400)
          .json({ error: 'User ID is required' });
        return;
      }

      const user = await this.userService.getUser(id);

      if (!user) {
        res.status(404).json({ error: 'User not found' });
        return;
      }

      res.json(user);
    } catch (error) {
      res.status(500).json({
        error: 'Internal server error',
        message:
          error instanceof Error
            ? error.message
            : 'Unknown error',
      });
    }
  }

  async createUser(
    req: Request,
    res: Response
  ): Promise<void> {
    try {
      const userData = req.body;
      const user = await this.userService.createUser(
        userData
      );
      res.status(201).json(user);
    } catch (error) {
      res.status(400).json({
        error: 'Failed to create user',
        message:
          error instanceof Error
            ? error.message
            : 'Unknown error',
      });
    }
  }
}

Express アプリケーションの設定:

typescript// src/app.ts
import 'reflect-metadata';
import express from 'express';
import { Container } from 'inversify';
import { TYPES } from './types';
import { UserController } from './controllers/UserController';
import { UserService } from './services/UserService';
import { MySQLDatabase } from './services/MySQLDatabase';
import { ConsoleLogger } from './services/ConsoleLogger';

// DIコンテナの設定
const container = new Container();

// インターフェースと実装のバインディング
container.bind(TYPES.Database).to(MySQLDatabase);
container.bind(TYPES.Logger).to(ConsoleLogger);
container.bind(TYPES.UserService).to(UserService);
container.bind(TYPES.UserController).to(UserController);

// Express アプリケーションの作成
const app = express();
app.use(express.json());

// コントローラーの取得
const userController = container.get<UserController>(
  TYPES.UserController
);

// ルーティングの設定
app.get('/users/:id', (req, res) =>
  userController.getUser(req, res)
);
app.post('/users', (req, res) =>
  userController.createUser(req, res)
);

// サーバー起動
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

export { app, container };

テストファイルの例:

typescript// src/__tests__/UserService.test.ts
import 'reflect-metadata';
import { Container } from 'inversify';
import { UserService } from '../services/UserService';
import { IDatabase } from '../interfaces/IDatabase';
import { ILogger } from '../interfaces/ILogger';
import { TYPES } from '../types';

// モッククラスの作成
class MockDatabase implements IDatabase {
  async findUser(id: string) {
    return {
      id,
      name: 'Test User',
      email: 'test@example.com',
      createdAt: new Date(),
    };
  }

  async saveUser() {}
  async deleteUser() {
    return true;
  }
}

class MockLogger implements ILogger {
  log = jest.fn();
  error = jest.fn();
  warn = jest.fn();
}

describe('UserService', () => {
  let container: Container;
  let userService: UserService;
  let mockLogger: MockLogger;

  beforeEach(() => {
    container = new Container();
    mockLogger = new MockLogger();

    // モックのバインディング
    container
      .bind<IDatabase>(TYPES.Database)
      .toConstantValue(new MockDatabase());
    container
      .bind<ILogger>(TYPES.Logger)
      .toConstantValue(mockLogger);
    container
      .bind<UserService>(TYPES.UserService)
      .to(UserService);

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

  it('should fetch user successfully', async () => {
    const user = await userService.getUser('123');

    expect(user).toBeDefined();
    expect(user?.id).toBe('123');
    expect(mockLogger.log).toHaveBeenCalledWith(
      'Fetching user: 123'
    );
  });
});

このテストでは、InversifyJS のコンテナを使ってモックオブジェクトを注入し、実際の外部依存関係なしでユニットテストを実行できます。これが真の意味でのテスタブルなコードです。

まとめ

TypeScript での依存性注入と InversifyJS の導入は、単なる技術的な改善以上の価値をもたらします。それは、コードを書く喜びを取り戻し、チーム全体の開発体験を向上させる革命的な変化なのです。

InversifyJS を使うことで得られる主な利益:

項目従来の方法InversifyJS 使用後
テストの書きやすさ困難(外部依存関係のため)簡単(モック注入が容易)
コードの保守性低い(強い結合)高い(疎結合)
新機能追加の速度遅い(既存コードへの影響)速い(独立したモジュール)
バグの発生率高い(循環依存など)低い(型安全性)
チーム開発の効率低い(コード理解に時間)高い(明確な依存関係)

この記事で学んだ知識を実際のプロジェクトに適用することで、あなたの TypeScript コードは次のレベルに到達するでしょう。最初は学習コストを感じるかもしれませんが、一度習得すれば、もう従来の書き方には戻れなくなるはずです。

依存性注入は、単なるデザインパターンではありません。それは、より良いソフトウェアを作るための哲学であり、開発者としての成長を促す強力なツールなのです。

関連リンク