🔗

[Nestjs] Dependency Injection(DI) 完全ガイド:クリーンアーキテクチャ&DDDの実践的アプローチ

2025/02/16に公開

はじめに

NestJSのDependency Injection(DI)は、モダンなアプリケーション開発において重要な役割を果たしています。特に、クリーンアーキテクチャやドメイン駆動設計(DDD)を採用する際に、その真価を発揮します。

この記事では、実際のコード例を交えながら、DIの本質的な価値と実装パターンについて、初心者にもわかりやすく解説します。

DIとは何か?

Dependency Injection(DI)は、クラス間の依存関係を外部から注入する設計パターンです。

従来の実装の問題点

DIを使用しない場合、以下のような実装になりがちです:

// DIを使用しない場合の例
class AuthService {
  // クラス内で直接依存オブジェクトを生成
  private tokenService = new JwtTokenService();
  private repository = new AuthRepository();

  async login(email: string, password: string) {
    const user = await this.repository.findByEmail(email);
    const token = this.tokenService.generateToken(user);
    return { user, token };
  }
}

この実装には以下の問題があります:

  1. 強い結合: AuthServiceがJwtTokenServiceとAuthRepositoryの具体的な実装に直接依存
  2. テストの困難さ: モックやスタブの作成が困難
  3. 柔軟性の欠如: 実装の変更が困難(例:JWTから別の認証方式への変更)

DIによる解決

DIを使用すると、以下のような利点が得られます:

  1. コードの結合度を下げる

    • クラスが具体的な実装ではなく、インターフェースに依存
    • 実装の詳細を知る必要がない
  2. テストを容易にする

    • 依存関係をモックに置き換えやすい
    • 単体テストが書きやすい
  3. コードの再利用性を高める

    • 同じインターフェースを実装した異なるクラスを簡単に差し替え可能
    • 機能の追加や変更が容易

実践的な例で理解するDI

1. インターフェースと実装の分離

まず、認証サービスを例に見てみましょう。

// domain/services/token.service.interface.ts

// インターフェースの定義
// これにより、トークン生成の「契約」を定義
export interface ITokenService {
  // トークンを生成するメソッド
  // payload: トークンに含めるデータ(例:ユーザーID)
  generateToken(payload: any): string;

  // トークンを検証するメソッド
  // token: 検証対象のトークン
  // 戻り値: デコードされたペイロード
  validateToken(token: string): any;
}

// infrastructure/jwt/jwt-token.service.ts

// JWTを使用した具体的な実装
@Injectable() // NestJSのDIシステムに登録
export class JwtTokenService implements ITokenService {
  constructor(
    // JWTサービスもDIで注入
    private readonly jwtService: JwtService,
  ) {}

  // インターフェースで定義したメソッドの実装
  generateToken(payload: any): string {
    // JWTトークンの生成
    return this.jwtService.sign(payload);
  }

  validateToken(token: string): any {
    // JWTトークンの検証
    return this.jwtService.verify(token);
  }
}

このように、インターフェース(ITokenService)と実装(JwtTokenService)を分離することで得られるメリット:

  1. ドメインロジックの独立性

    • トークン生成のビジネスロジックがJWTという具体的な実装に依存しない
    • ドメイン層が技術的な詳細を知る必要がない
  2. 実装の柔軟性

    • JWTからセッションベースの認証に変更する場合も、インターフェースを実装した新しいクラスを作成するだけ
    • 既存のコードを変更する必要がない
  3. テストの容易さ

    • インターフェースに基づいてモックを作成可能
    • 単体テストで実際のJWTを使用する必要がない

2. モジュールによる依存関係の管理

NestJSのモジュールは、DIコンテナの設定を管理します。

// app/modules/auth.module.ts

@Module({
  // 他のモジュールのインポート
  imports: [
    // JWTモジュールの設定
    JwtModule.register({
      secret: 'your-secret-key',
      signOptions: { expiresIn: '1h' },
    }),
  ],
  // DIコンテナに登録するプロバイダー
  providers: [
    // インターフェースと実装のマッピング
    {
      provide: 'ITokenService', // トークンサービスのインターフェース
      useClass: JwtTokenService, // 実際の実装クラス
    },
    AuthService, // 認証サービス
    AuthRepository, // リポジトリ
  ],
  // 他のモジュールで使用可能にするサービス
  exports: [AuthService],
})
export class AuthModule {}

このモジュール設定のメリット:

  1. 依存関係の集中管理

    • すべての依存関係が1箇所で管理される
    • アプリケーションの構造が明確になる
  2. 設定の柔軟性

    • 環境に応じて異なる実装を注入可能
    • テスト時にモックに置き換えやすい
  3. スコープの制御

    • exportsで公開するサービスを制御
    • モジュール間の依存関係を明確に

3. レイヤー間の依存性注入

クリーンアーキテクチャの文脈でDIを活用する例を見てみましょう:

// application/commands/login.handler.ts

@Injectable()
export class LoginHandler {
  constructor(
    // 認証サービスのインターフェースを注入
    @Inject('IAuthService')
    private readonly authService: IAuthService,
  ) {}

  // コマンドパターンの実装
  // LoginCommandオブジェクトを受け取り、認証を実行
  async execute(command: LoginCommand): Promise<AuthEntity> {
    return this.authService.login(command);
  }
}

// application/services/auth.service.ts

@Injectable()
export class AuthService implements IAuthService {
  constructor(
    // リポジトリとトークンサービスのインターフェースを注入
    @Inject('IAuthRepository')
    private readonly authRepository: IAuthRepository,
    @Inject('ITokenService')
    private readonly tokenService: ITokenService,
  ) {}

  // ログイン処理の実装
  async login(command: LoginCommand): Promise<AuthEntity> {
    // メールアドレスでユーザーを検索
    const user = await this.authRepository.findByEmail(command.email);
    if (!user) throw new UnauthorizedException();
    
    // パスワード検証など...
    
    // JWTトークンの生成
    const token = this.tokenService.generateToken({ userId: user.id });
    
    // 認証エンティティの作成と返却
    return new AuthEntity({ user, token });
  }
}

この実装により得られるメリット:

  1. レイヤー間の独立性

    • アプリケーション層がインフラ層の詳細を知らない
    • 各レイヤーが独立してテスト可能
  2. ビジネスロジックの集中

    • 認証ロジックがAuthServiceに集中
    • 責務が明確に分離
  3. 保守性の向上

    • 変更の影響範囲が限定的
    • 新機能の追加が容易

DIのメリット:具体例で理解する

1. テスタビリティの向上

// テストコード例
describe('LoginHandler', () => {
  let handler: LoginHandler;
  let mockAuthService: jest.Mocked<IAuthService>;

  beforeEach(() => {
    // モックの作成
    mockAuthService = {
      login: jest.fn(),
    };

    // モックを注入してハンドラーを作成
    handler = new LoginHandler(mockAuthService);
  });

  it('should call auth service with correct parameters', async () => {
    // テストデータの準備
    const command = new LoginCommand('test@example.com', 'password');
    
    // ハンドラーの実行
    await handler.execute(command);
    
    // 認証サービスが正しく呼び出されたか検証
    expect(mockAuthService.login).toHaveBeenCalledWith(command);
  });
});

このテストのメリット:

  1. 独立したテスト

    • 実際のデータベースやJWTを使用せずにテスト可能
    • テストが高速で信頼性が高い
  2. テストの可読性

    • テストの意図が明確
    • 期待する動作が明示的
  3. テストのメンテナンス性

    • モックの更新が容易
    • テストケースの追加が簡単

2. 実装の切り替えが容易

例えば、認証方式をJWTからセッションベースに変更する場合:

// 新しい実装を追加
@Injectable()
export class SessionTokenService implements ITokenService {
  // セッションベースのトークン生成
  generateToken(payload: any): string {
    // セッションIDの生成
    const sessionId = crypto.randomUUID();
    // セッションストアへの保存
    this.sessionStore.set(sessionId, payload);
    return sessionId;
  }

  // セッションの検証
  validateToken(token: string): any {
    // セッションストアからデータを取得
    return this.sessionStore.get(token);
  }
}

// モジュールの設定を変更
@Module({
  providers: [
    {
      provide: 'ITokenService',
      useClass: SessionTokenService, // JWTTokenServiceから変更
    },
  ],
})
export class AuthModule {}

この実装の切り替えのメリット:

  1. 最小限の変更

    • インターフェースを実装した新しいクラスを追加するだけ
    • 既存のコードは変更不要
  2. リスクの低減

    • 変更の影響範囲が限定的
    • 段階的な移行が可能
  3. 柔軟な設定

    • 環境ごとに異なる実装を使用可能
    • A/Bテストなども容易

3. 関心の分離

// domain/repositories/auth.repository.interface.ts

// リポジトリのインターフェース
export interface IAuthRepository {
  // メールアドレスでユーザーを検索
  findByEmail(email: string): Promise<User>;
  
  // トークンの保存
  saveToken(userId: string, token: string): Promise<void>;
}

// infrastructure/prisma/auth.repository.ts

@Injectable()
export class AuthRepository implements IAuthRepository {
  constructor(
    // Prismaサービスの注入
    private readonly prisma: PrismaService,
  ) {}

  // ユーザー検索の実装
  async findByEmail(email: string): Promise<User> {
    return this.prisma.user.findUnique({ where: { email } });
  }

  // トークン保存の実装
  async saveToken(userId: string, token: string): Promise<void> {
    await this.prisma.user.update({
      where: { id: userId },
      data: { token },
    });
  }
}

この構造のメリット:

  1. ドメインロジックの純粋性

    • データベース実装の詳細を知る必要がない
    • ビジネスロジックに集中できる
  2. インフラ層の独立性

    • データベースをPrismaからTypeORMに変更可能
    • キャッシュの追加なども容易
  3. テストの容易さ

    • インメモリデータベースでのテストが可能
    • モックリポジトリの作成が簡単

まとめ

NestJSのDependency Injectionは、以下のような具体的なメリットを提供します:

  1. 疎結合な設計

    • インターフェースを通じた依存関係の管理
    • 実装の詳細を隠蔽
    • 変更の影響範囲を限定
  2. テスタビリティ

    • モックやスタブの作成が容易
    • 単体テストが書きやすい
    • テストの信頼性が高い
  3. 保守性

    • 実装の変更が局所的
    • コードの再利用が容易
    • 責務が明確に分離
  4. スケーラビリティ

    • 新機能の追加が容易
    • 既存機能の拡張が簡単
    • 段階的な改善が可能

クリーンアーキテクチャやDDDと組み合わせることで、これらのメリットがより顕著になり、保守性の高い堅牢なアプリケーションを構築できます。

DIを活用することで、アプリケーションの品質を高め、長期的なメンテナンス性を確保することができます。特に大規模なアプリケーションや、チームでの開発において、その価値は非常に大きいものとなります。

codeciaoテックブログ

Discussion