[Nestjs] Dependency Injection(DI) 完全ガイド:クリーンアーキテクチャ&DDDの実践的アプローチ
はじめに
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 };
}
}
この実装には以下の問題があります:
- 強い結合: AuthServiceがJwtTokenServiceとAuthRepositoryの具体的な実装に直接依存
- テストの困難さ: モックやスタブの作成が困難
- 柔軟性の欠如: 実装の変更が困難(例:JWTから別の認証方式への変更)
DIによる解決
DIを使用すると、以下のような利点が得られます:
-
コードの結合度を下げる
- クラスが具体的な実装ではなく、インターフェースに依存
- 実装の詳細を知る必要がない
-
テストを容易にする
- 依存関係をモックに置き換えやすい
- 単体テストが書きやすい
-
コードの再利用性を高める
- 同じインターフェースを実装した異なるクラスを簡単に差し替え可能
- 機能の追加や変更が容易
実践的な例で理解する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)を分離することで得られるメリット:
-
ドメインロジックの独立性
- トークン生成のビジネスロジックがJWTという具体的な実装に依存しない
- ドメイン層が技術的な詳細を知る必要がない
-
実装の柔軟性
- JWTからセッションベースの認証に変更する場合も、インターフェースを実装した新しいクラスを作成するだけ
- 既存のコードを変更する必要がない
-
テストの容易さ
- インターフェースに基づいてモックを作成可能
- 単体テストで実際の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箇所で管理される
- アプリケーションの構造が明確になる
-
設定の柔軟性
- 環境に応じて異なる実装を注入可能
- テスト時にモックに置き換えやすい
-
スコープの制御
- 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 });
}
}
この実装により得られるメリット:
-
レイヤー間の独立性
- アプリケーション層がインフラ層の詳細を知らない
- 各レイヤーが独立してテスト可能
-
ビジネスロジックの集中
- 認証ロジックがAuthServiceに集中
- 責務が明確に分離
-
保守性の向上
- 変更の影響範囲が限定的
- 新機能の追加が容易
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);
});
});
このテストのメリット:
-
独立したテスト
- 実際のデータベースやJWTを使用せずにテスト可能
- テストが高速で信頼性が高い
-
テストの可読性
- テストの意図が明確
- 期待する動作が明示的
-
テストのメンテナンス性
- モックの更新が容易
- テストケースの追加が簡単
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 {}
この実装の切り替えのメリット:
-
最小限の変更
- インターフェースを実装した新しいクラスを追加するだけ
- 既存のコードは変更不要
-
リスクの低減
- 変更の影響範囲が限定的
- 段階的な移行が可能
-
柔軟な設定
- 環境ごとに異なる実装を使用可能
- 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 },
});
}
}
この構造のメリット:
-
ドメインロジックの純粋性
- データベース実装の詳細を知る必要がない
- ビジネスロジックに集中できる
-
インフラ層の独立性
- データベースをPrismaからTypeORMに変更可能
- キャッシュの追加なども容易
-
テストの容易さ
- インメモリデータベースでのテストが可能
- モックリポジトリの作成が簡単
まとめ
NestJSのDependency Injectionは、以下のような具体的なメリットを提供します:
-
疎結合な設計
- インターフェースを通じた依存関係の管理
- 実装の詳細を隠蔽
- 変更の影響範囲を限定
-
テスタビリティ
- モックやスタブの作成が容易
- 単体テストが書きやすい
- テストの信頼性が高い
-
保守性
- 実装の変更が局所的
- コードの再利用が容易
- 責務が明確に分離
-
スケーラビリティ
- 新機能の追加が容易
- 既存機能の拡張が簡単
- 段階的な改善が可能
クリーンアーキテクチャやDDDと組み合わせることで、これらのメリットがより顕著になり、保守性の高い堅牢なアプリケーションを構築できます。
DIを活用することで、アプリケーションの品質を高め、長期的なメンテナンス性を確保することができます。特に大規模なアプリケーションや、チームでの開発において、その価値は非常に大きいものとなります。
Discussion