🐡

DI(依存性注入)を理解する:基本概念からNestJSでの実装まで

に公開

DIをあまり理解していなかったので、まとめます。
コードの書き方には、一般的な書き方とフレームワークの機能を利用した書き方があります。
私は最近書く頻度が高いNestJSの書き方を中心に調べました。
この記事では依存性やDIの概要、メリットを整理し、DIなしの場合とDIありの場合(一般的な実装とNestJS特有の実装)を比較していきます。

依存性

依存性とは

DIを理解するには、まず依存性の概念を理解している必要があります。
依存性とは、あるクラスが別のクラスやモジュールに依存している状態を指します。
例えば、下記のコードのようにNotificationServiceがMailServiceやChatServiceのインスタンスを直接生成・利用する場合、NotificationServiceはMailServiceやChatServiceに依存していると言えます。

依存性のあるコード例

依存するクラスを実装する

src/services/mail.service.ts
// メールサービス(DBアクセスあり)
class MailService {
  send(message: string): void {
    const emailAddress = this.getEmailFromDB();
    console.log(`Sending Email to ${emailAddress}: ${message}`);
  }

  private getEmailFromDB(): string {
    // DBから取得するが、ここでは固定値
    // .
    // .
    return 'user@example.com';
  }
}
src/services/chat.service.ts
// チャットサービス(外部APIアクセスあり)
class ChatService {
  send(message: string): void {
    const apiResponse = this.sendToChatAPI(message);
    console.log(`Chat APIからのレスポンス: ${apiResponse}`);
  }

  private sendToChatAPI(message: string): string {
    // 外部APIを呼び出すが、ここでは固定値
    // .
    // .
    console.log('外部Chat APIにリクエスト中...');
    return `Success: ${message}`;
  }
}

依存を受けるクラスを実装する

src/notification/notification.service.ts
// 通知クラス(直接依存)
class NotificationService {
  private mailService = new MailService(); // 直接依存
  private chatService = new ChatService(); // 直接依存

  sendNotification(method: string, message: string): void {
    if (method === 'email') {
      this.mailService.send(message);
    } else if (method === 'chat') {
      this.chatService.send(message);
    } else {
      console.log('Unsupported notification method');
    }
  }
}

今回の例では、依存するクラスを依存を受けるクラス内で直接生成しています。

依存を受けるクラスを実行する

src/main.ts
// 実行例
const notificationService = new NotificationService();
notificationService.sendNotification('email', 'Hello via Email!');
notificationService.sendNotification('chat', 'Hello via Chat!');

テストコード

src/notification/notification.service.no-di.spec.ts
// src/notification/notification.service.no-di.spec.ts
import { NotificationService } from './notification.service.no-di';

describe('NotificationService without DI', () => {
  it('sends an email notification', () => {
    const service = new NotificationService();
    service.sendNotification('email', 'test message'); // DBアクセスが起きてしまう可能性あり
  });
});

依存性の問題点

上記の書き方は以下問題が生じます。

クラスの差し替えをしにくい

NotificationServiceからしてみると、メッセージを送る機能があればよいのですが、NotificationServiceはMailServiceやChatServiceを直接生成しているため、結びつきが強くなっています。
もし新たにLineServiceを追加したい場合、NotificationService自体を修正する必要があります。

単体テストをしにくい

例で言うと、MailServiceでDBに接続できなかったときや、ChatServiceで外部APIにアクセスできなかったときなどが原因で、NotificationServiceのテストが失敗する可能性があります。
本来NotificationServiceが気にしなくてもよいことが原因でテストが失敗してしまうのは、単体テストとしてあるべき形ではありません。
また、テスト時にモックを使いたくても、クラスが直接依存しているため、差し替えが難しいです。

これらの問題を解決するのがDIです。

DIとは

DI(依存性注入)は、クラスが依存するオブジェクトを外部から注入することで、直接依存を排除する設計パターンです。
これによりクラス間の結びつきを弱め(疎結合)、保守性が向上します。

DIのメリット

コードの再利用性を高める

クラスを特定の実装に依存させずインターフェースに依存させることで、実装を差し替えやすくします。

単体テストの実装を容易にする

DIを取り入れることで、テスト用のモックを注入しやすくなり、外部APIやDBアクセスの影響を受けないテストが可能です。

DIの実装

一般的な実装と利用しているフレームワークの機能を使った実装(本記事はNestJS)で実現できます。

一般的な実装

インターフェイスを作成してクラスに適応します。
クラス内で別のクラスを利用したい場合は、呼び出す側のクラス(CA)で呼び出されるクラス(CB)のインスタンスを作成するのではなく、CBをCAの引数で渡すことで、インターフェイスと実装の責任を分割します。
これにより、CAが依存するのはあくまでCBのインターフェイスであり、CBの実装に依存しなくなるため、柔軟性と保守性が高まります。

実装例は以下です。

依存するクラスのインターフェースを定義する

src/interfaces/send-service.interface.ts
export interface ISendService {
  send(message: string): void;
}

依存するクラスにインターフェースISendServiceを適応する

src/services/mail.service.ts
// メールサービス(DBアクセスあり)
export class MailService implements ISendService {
  send(message: string): void {
    const email = this.getEmailFromDB();
    console.log(`Sending email to ${email}: ${message}`);
  }

  private getEmailFromDB(): string {
    console.log('DB接続中...');
    return 'user@example.com';
  }
}
src/services/chat.service.ts
// チャットサービス(外部APIアクセスあり)
export class ChatService implements ISendService {
  send(message: string): void {
    const result = this.sendToExternalAPI(message);
    console.log(`Chat API Response: ${result}`);
  }

  private sendToExternalAPI(message: string): string {
    console.log('外部APIにリクエスト中...');
    return `API Success: ${message}`;
  }
}

依存するクラスをコンストラクタ経由で受け取る

src/notification/notification.service.ts
// 通知クラス
export class NotificationService {
  constructor(private readonly sender: ISendService) {} // 間接依存

  sendNotification(message: string): void {
    this.sender.send(message);
  }
}

依存するクラスをNotificationServiceの引数に渡す

// 実行例
const mailService = new MailService();
const mailNotification = new NotificationService(mailService);
mailNotification.sendNotification('Hello via Email');

const chatService = new ChatService();
const chatNotification = new NotificationService(chatService);
chatNotification.sendNotification('Hello via Chat');

NotificationServiceが依存するのはISendService(インターフェイス)とし、MailServiceやChatService(具体的な実装)に依存させないようにすることで、MailServiceやChatServiceの中身が書き変わってもNotificationServiceに影響しないようにします。
また、MailServiceとChatServiceどちらを利用するかは、NotificationServiceインスタンスを作成する際の引数に渡すことで指定します。

NestJSの実装

NestJSでは、DIをサポートするDIコンテナを使うことができます。
DIコンテナとは、依存関係を自動で解決し、クラスに注入してくれる仕組みです。
クラスに@Injectable()デコレータを付けることで、自動的にDIコンテナに登録されます。

依存するクラスのインターフェースを定義する

src/interfaces/send-service.interface.ts
export interface ISendService {
  send(message: string): void;
}

依存するクラスを実装する

src/interfaces/send-service.interface.ts
import { Injectable } from '@nestjs/common';
import { ISendService } from './send-service.interface';

@Injectable()
export class MailService implements ISendService {
  send(message: string): void {
    const email = this.getEmailFromDB();
    console.log(`Sending email to ${email}: ${message}`);
  }

  private getEmailFromDB(): string {
    console.log('DB接続中...');
    return 'user@example.com';
  }
}
src/services/chat.service.ts
import { Injectable } from '@nestjs/common';
import { ISendService } from './send-service.interface';

@Injectable()
export class ChatService implements ISendService {
  send(message: string): void {
    const result = this.sendToExternalAPI(message);
    console.log(`Chat API Response: ${result}`);
  }

  private sendToExternalAPI(message: string): string {
    console.log('外部APIにリクエスト中...');
    return `API Success: ${message}`;
  }
}

@Injectable()をつけることで、そのクラスはNestのDIコンテナで注入可能な「プロバイダ」として扱われます。

依存を受けるクラスを実行する

src/notification/notification.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { ISendService } from './send-service.interface';

@Injectable()
export class NotificationService {
  constructor(
    @Inject('ISendService') private readonly sender: ISendService
  ) {} // 間接依存

  send(message: string): void {
    this.sender.send(message);
  }
}

NotificationServiceは依存を受ける側ですが、インスタンス化して他のクラスで使えるようにするため、@Injectable()を記載する必要があります。
@Inject('ISendService')はNestJSのDIコンテナに登録されたプロバイダを明示的に注入するためのデコレータです。
()内には下記モジュールのprovideに設定した値を入れます。

モジュールに登録する

依存するクラスを利用できるようにモジュールに登録します。

src/app.module.ts
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { MailService } from './mail.service';
import { ChatService } from './chat.service';

@Module({
  providers: [
    // 使いたいクラスをここで紐付ける
    { provide: 'ISendService', useClass: MailService },
    NotificationService,
  ],
})
export class AppModule {}

実行する

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NotificationService } from './notification.service';

async function bootstrap() {
  const app = await NestFactory.createApplicationContext(AppModule);
  const notificationService = app.get(NotificationService);

  notificationService.send('Hello via Email');
}
bootstrap();

テストコード

src/notification/notification.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing'; // NestJSのテスト用ユーティリティ
import { NotificationService } from './notification.service';
import { ISendService } from '../interfaces/send-service.interface';

describe('NotificationService with DI', () => {
  let service: NotificationService;

  // sendメソッドを持つモック(Jestのモック関数)
  const mockSender: ISendService = {
    send: jest.fn(), // 呼び出しを記録
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        NotificationService, // テスト対象のサービスを登録
        {
          provide: 'ISendService', // DIトークンとしてISendServiceを使用
          useValue: mockSender,    // 実際のクラスではなく、モックを注入
        },
      ],
    }).compile(); // DIコンテナを初期化

    service = module.get(NotificationService); // テスト対象のインスタンスを取得
  });

  it('calls send on the injected service', () => {
    const message = 'Hello!';
    service.send(message);

    // モックのsendが正しく呼び出されたか検証
    expect(mockSender.send).toHaveBeenCalledWith(message); // 引数の一致
    expect(mockSender.send).toHaveBeenCalledTimes(1);      // 呼び出し回数が1回
  });
});

まとめ

クラスのインターフェースと実装を分けて書くことでクラス間の依存性をなくすことができます。
これをDIと呼びます。
DIを実装することで、柔軟なクラスの差し替え、他のクラスに依存しない単体テスト記載 ができるようになります。

観点 DIなし DIあり
クラスの結びつき 強い(newで直接生成) 弱い(インターフェースで疎結合)
実装の柔軟性 低い 高い
単体テストの書きやすさ 書きにくい モックの注入が容易でテストしやすい

付録

本記事の本筋では扱いませんでしたが、公式サイトにもあるように、以下のように書けば環境ごとに利用するクラスを分けることができます。

src/main.ts
import { Module } from '@nestjs/common';
import { MailService } from './services/mail.service';
import { ChatService } from './services/chat.service';
import { NotificationService } from './notification/notification.service';
import { ISendService } from './interfaces/send-service.interface';

// 環境変数による切り替え(簡易版)
const sendServiceProvider = {
  provide: 'ISendService',
  useClass:
    process.env.NOTIFICATION_TYPE === 'chat'
      ? ChatService
      : MailService,
};

@Module({
  providers: [
    sendServiceProvider,
    NotificationService,
    MailService,
    ChatService,
  ],
})
export class AppModule {}

参考

下記を参考にさせていただきました。
https://zenn.dev/codeciao/articles/55a856a743c78a
https://qiita.com/okazuki/items/a0f2fb0a63ca88340ff6

公式サイト
https://docs.nestjs.com/fundamentals/custom-providers

Discussion