T-CREATOR

NestJS アーキテクチャ超図解:DI コンテナ/プロバイダ/メタデータを一気に把握

NestJS アーキテクチャ超図解:DI コンテナ/プロバイダ/メタデータを一気に把握

NestJS を初めて触ったとき、「なんだか複雑で理解しづらい」と感じた経験はありませんか。特に Express から移行してきた開発者にとって、DI コンテナやプロバイダといった概念は馴染みがなく、戸惑いを感じることが多いでしょう。

しかし、これらの概念を段階的に理解していけば、NestJS の強力なアーキテクチャが持つ真の価値を実感できるはずです。この記事では、NestJS アーキテクチャの核心となるDI コンテナプロバイダメタデータの 3 つの概念を、初心者の方にもわかりやすく図解と実例を交えて解説していきます。

背景

従来の Node.js フレームワークとの違い

従来の Express などの Node.js フレームワークでは、開発者が手動でオブジェクトのインスタンス化や依存関係の管理を行う必要がありました。

以下の図は、従来の Express アプリケーションでの依存関係を示しています。

mermaidflowchart TD
    controller[コントローラー] -->|手動でインスタンス化| service[サービス]
    service -->|手動でインスタンス化| db[データベース接続]
    controller -->|手動で管理| middleware[ミドルウェア]

    style controller fill:#ff9999
    style service fill:#ff9999
    style db fill:#ff9999

このアプローチでは、アプリケーションが大きくなるにつれて以下の問題が発生します。

#問題点具体例
1依存関係の手動管理new UserService(new DatabaseConnection())
2テストの困難さモックオブジェクトの差し替えが複雑
3コードの重複同じインスタンス化処理の繰り返し

Angular 風のアーキテクチャがもたらすメリット

NestJS は、フロントエンドフレームワークの Angular からインスピレーションを得て設計されています。これにより、以下のメリットを享受できます。

mermaidflowchart LR
    subgraph nestjs[NestJS アーキテクチャ]
        di[DI コンテナ]
        provider[プロバイダ]
        metadata[メタデータ]

        di --> provider
        metadata --> di
        provider --> metadata
    end

    subgraph benefits[メリット]
        testable[テスタブル]
        maintainable[保守性]
        scalable[スケーラブル]
    end

    nestjs --> benefits

    style di fill:#e1f5fe
    style provider fill:#e8f5e8
    style metadata fill:#fff3e0

このアーキテクチャにより、大規模なエンタープライズアプリケーションでも、コードの品質と保守性を保ちながら開発を進めることができるのです。

図で理解できる要点

  • DI コンテナが中心となって依存関係を自動管理
  • プロバイダがサービスの提供方法を定義
  • メタデータがこれら全体の設定情報を保持

課題

DI コンテナの仕組みが見えにくい

NestJS の DI コンテナは内部的に動作するため、「なぜオブジェクトが自動的に注入されるのか」「どのタイミングでインスタンス化されるのか」といった仕組みが見えづらいという課題があります。

以下は、初心者がよく遭遇する疑問点です。

mermaidstateDiagram-v2
    [*] --> 疑問1: DIコンテナって何?
    疑問1 --> 疑問2: いつインスタンス化される?
    疑問2 --> 疑問3: どうやって依存関係を解決する?
    疑問3 --> 混乱: 内部動作が見えない
    混乱 --> [*]

プロバイダの種類と使い分けがわからない

NestJS には複数種類のプロバイダが存在し、それぞれ異なる目的と使用場面があります。しかし、公式ドキュメントを読んでも、実際の使い分けが理解しづらいのが現実です。

#プロバイダ種類使用場面初心者の理解度
1Class Provider基本的なサービス⭐⭐⭐
2Value Provider設定値の注入⭐⭐
3Factory Provider動的生成
4Async Provider非同期初期化
5Custom Provider高度なカスタマイズ

メタデータの役割が不明瞭

TypeScript のデコレータによって生成されるメタデータの役割と、それが NestJS の動作にどう関わっているかが理解しづらいという問題があります。

特に以下の点で混乱が生じやすくなっています。

  • @Injectable() デコレータの必要性
  • リフレクションメタデータの仕組み
  • コンパイル時と実行時の処理の違い

解決策

DI コンテナ:オブジェクト生成の自動化メカニズム

DI コンテナは、アプリケーション内のオブジェクトの生成と依存関係の解決を自動化する仕組みです。以下の図でその動作を確認してみましょう。

mermaidsequenceDiagram
    participant App as アプリケーション
    participant Container as DIコンテナ
    participant Registry as プロバイダレジストリ
    participant Instance as インスタンス

    App->>Container: サービス要求
    Container->>Registry: プロバイダ検索
    Registry-->>Container: プロバイダ情報
    Container->>Container: 依存関係解決
    Container->>Instance: インスタンス生成
    Instance-->>Container: インスタンス
    Container-->>App: 注入完了

DI コンテナの主な責務は以下の通りです。

  1. プロバイダの登録管理:どのクラスがどのように提供されるかを記録
  2. 依存関係の解決:必要なオブジェクトとその依存関係を特定
  3. ライフサイクル管理:シングルトン、リクエストスコープなどの管理
  4. インスタンスの注入:適切なタイミングでオブジェクトを注入

プロバイダ:サービス提供の 5 つのパターン

プロバイダは、DI コンテナに「どのようにサービスを提供するか」を指示する設定です。NestJS では主に 5 つのパターンが存在します。

1. Class Provider(基本パターン)

最も基本的なプロバイダで、クラスをそのまま注入します。

typescript@Injectable()
export class UserService {
  getUsers() {
    return ['user1', 'user2'];
  }
}
typescript@Module({
  providers: [UserService], // 省略形
  // 完全形: { provide: UserService, useClass: UserService }
})
export class AppModule {}

2. Value Provider(値の注入)

設定値や定数を注入する際に使用します。

typescriptconst config = {
  host: 'localhost',
  port: 3000,
};

@Module({
  providers: [
    {
      provide: 'CONFIG',
      useValue: config,
    },
  ],
})
export class AppModule {}
typescript@Injectable()
export class ApiService {
  constructor(@Inject('CONFIG') private config: any) {}
}

3. Factory Provider(動的生成)

実行時の条件に応じてインスタンスを動的に生成します。

typescript@Module({
  providers: [
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: (config: ConfigService) => {
        return config.get('NODE_ENV') === 'test'
          ? new MockDatabase()
          : new RealDatabase();
      },
      inject: [ConfigService],
    },
  ],
})
export class AppModule {}

4. Async Provider(非同期初期化)

データベース接続など、非同期で初期化が必要な場合に使用します。

typescript@Module({
  providers: [
    {
      provide: 'ASYNC_CONNECTION',
      useFactory: async () => {
        const connection = await createConnection();
        return connection;
      },
    },
  ],
})
export class AppModule {}

5. Existing Provider(エイリアス)

既存のプロバイダに別名を付ける場合に使用します。

typescript@Module({
  providers: [
    UserService,
    {
      provide: 'USER_SERVICE_ALIAS',
      useExisting: UserService,
    },
  ],
})
export class AppModule {}

以下の図は、プロバイダの種類と使用場面を整理したものです。

mermaidflowchart TD
    provider["プロバイダ"] --> classProv["Class Provider"]
    provider --> valueProv["Value Provider"]
    provider --> factoryProv["Factory Provider"]
    provider --> asyncProv["Async Provider"]
    provider --> existingProv["Existing Provider"]

    classProv --> basic["基本的なサービス<br/>UserService, OrderService"]
    valueProv --> config["設定値・定数<br/>API_KEY, DATABASE_URL"]
    factoryProv --> conditional["条件分岐<br/>環境別設定, モック切り替え"]
    asyncProv --> initialization["非同期初期化<br/>DB接続, 外部API設定"]
    existingProv --> alias["エイリアス<br/>互換性維持, 抽象化"]

    style classProv fill:#e3f2fd
    style valueProv fill:#e8f5e8
    style factoryProv fill:#fff3e0
    style asyncProv fill:#fce4ec
    style existingProv fill:#f3e5f5

メタデータ:デコレータが生成する設計情報

メタデータは、TypeScript のデコレータによって生成される「設計時情報」です。NestJS はこの情報を実行時に読み取って、DI コンテナの動作を制御します。

リフレクションメタデータの仕組み

typescriptimport 'reflect-metadata';

// メタデータの設定例
@Injectable()
export class UserService {
  constructor(private userRepository: UserRepository) {}
}

上記のコードをコンパイルすると、以下のようなメタデータが生成されます。

typescript// コンパイル後に自動生成される情報
Reflect.defineMetadata(
  'design:type',
  Function,
  UserService
);
Reflect.defineMetadata(
  'design:paramtypes',
  [UserRepository],
  UserService
);

NestJS はこのメタデータを読み取って依存関係を把握します。

typescript// NestJSの内部処理(簡略版)
const paramTypes = Reflect.getMetadata(
  'design:paramtypes',
  UserService
);
// paramTypes = [UserRepository]

以下の図は、メタデータの生成から活用までの流れを示しています。

mermaidflowchart LR
    subgraph compile[コンパイル時]
        decorator[デコレータ] --> metadata[メタデータ生成]
    end

    subgraph runtime[実行時]
        metadata --> reflection[リフレクション読み取り]
        reflection --> di[DI解決]
        di --> injection[インスタンス注入]
    end

    compile --> runtime

    style decorator fill:#e1f5fe
    style metadata fill:#e8f5e8
    style reflection fill:#fff3e0
    style injection fill:#fce4ec

図で理解できる要点

  • コンパイル時にデコレータがメタデータを生成
  • 実行時に NestJS がメタデータを読み取り
  • メタデータに基づいて DI 解決とインスタンス注入を実行

具体例

シンプルな UserService で DI を体験

まずは最もシンプルな DI の例から始めましょう。UserService と UserRepository の依存関係を通じて、DI の基本動作を確認します。

UserRepository の定義

typescript@Injectable()
export class UserRepository {
  private users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ];

  findAll() {
    return this.users;
  }

  findById(id: number) {
    return this.users.find((user) => user.id === id);
  }
}

UserService の定義

typescript@Injectable()
export class UserService {
  // DIコンテナが自動的にUserRepositoryを注入
  constructor(private userRepository: UserRepository) {}

  getAllUsers() {
    return this.userRepository.findAll();
  }

  getUserById(id: number) {
    const user = this.userRepository.findById(id);
    if (!user) {
      throw new Error(`User with ID ${id} not found`);
    }
    return user;
  }
}

コントローラーでの使用

typescript@Controller('users')
export class UserController {
  // DIコンテナが自動的にUserServiceを注入
  constructor(private userService: UserService) {}

  @Get()
  getAllUsers() {
    return this.userService.getAllUsers();
  }

  @Get(':id')
  getUserById(@Param('id') id: string) {
    return this.userService.getUserById(parseInt(id));
  }
}

モジュールでの設定

typescript@Module({
  controllers: [UserController],
  providers: [UserService, UserRepository],
})
export class UserModule {}

この例では、以下の依存関係が自動的に解決されます。

mermaidflowchart TD
    controller[UserController] -->|注入| service[UserService]
    service -->|注入| repository[UserRepository]

    subgraph di[DIコンテナ]
        registration[プロバイダ登録]
        resolution[依存関係解決]
        injection[インスタンス注入]
    end

    registration --> resolution
    resolution --> injection
    injection --> controller

    style controller fill:#e3f2fd
    style service fill:#e8f5e8
    style repository fill:#fff3e0

カスタムプロバイダで柔軟性を確認

次に、環境に応じて異なる実装を注入するカスタムプロバイダの例を見てみましょう。

インターフェースの定義

typescriptexport interface ILoggerService {
  log(message: string): void;
  error(message: string): void;
}

複数の実装

typescript@Injectable()
export class ConsoleLoggerService
  implements ILoggerService
{
  log(message: string) {
    console.log(`[LOG] ${message}`);
  }

  error(message: string) {
    console.error(`[ERROR] ${message}`);
  }
}
typescript@Injectable()
export class FileLoggerService implements ILoggerService {
  log(message: string) {
    // ファイルに書き込む処理
    this.writeToFile(`[LOG] ${message}`);
  }

  error(message: string) {
    this.writeToFile(`[ERROR] ${message}`);
  }

  private writeToFile(message: string) {
    // 実際のファイル書き込み実装
  }
}

Factory Provider で動的切り替え

typescript@Module({
  providers: [
    ConsoleLoggerService,
    FileLoggerService,
    {
      provide: 'LOGGER_SERVICE',
      useFactory: (config: ConfigService) => {
        const env = config.get('NODE_ENV');

        // 環境に応じて実装を切り替え
        if (env === 'production') {
          return new FileLoggerService();
        } else {
          return new ConsoleLoggerService();
        }
      },
      inject: [ConfigService],
    },
  ],
})
export class AppModule {}

サービスでの使用

typescript@Injectable()
export class OrderService {
  constructor(
    @Inject('LOGGER_SERVICE') private logger: ILoggerService
  ) {}

  createOrder(orderData: any) {
    this.logger.log('Creating new order');

    try {
      // 注文作成処理
      const order = this.processOrder(orderData);
      this.logger.log(
        `Order created successfully: ${order.id}`
      );
      return order;
    } catch (error) {
      this.logger.error(
        `Failed to create order: ${error.message}`
      );
      throw error;
    }
  }

  private processOrder(orderData: any) {
    // 注文処理の実装
    return { id: Math.random(), ...orderData };
  }
}

リフレクションメタデータの実際の動作

最後に、メタデータがどのように生成され、活用されるかを詳しく見てみましょう。

メタデータの確認用デコレータ

typescriptimport 'reflect-metadata';

export function LogMetadata(target: any) {
  const paramTypes = Reflect.getMetadata(
    'design:paramtypes',
    target
  );
  const type = Reflect.getMetadata('design:type', target);

  console.log('Class:', target.name);
  console.log(
    'Constructor param types:',
    paramTypes?.map((t) => t.name)
  );
  console.log('Type:', type?.name);

  return target;
}

メタデータを確認するサービス

typescript@LogMetadata
@Injectable()
export class MetadataExampleService {
  constructor(
    private userService: UserService,
    private logger: ConsoleLoggerService,
    @Inject('CONFIG') private config: any
  ) {}

  demonstrateMetadata() {
    // このメソッドの実行時にコンソールに以下が出力される:
    // Class: MetadataExampleService
    // Constructor param types: ['UserService', 'ConsoleLoggerService', 'Object']
    // Type: Function
  }
}

カスタムメタデータの設定と取得

typescriptexport const METADATA_KEY = 'custom_metadata';

export function CustomMetadata(value: string) {
  return function (
    target: any,
    propertyKey?: string,
    descriptor?: PropertyDescriptor
  ) {
    Reflect.defineMetadata(
      METADATA_KEY,
      value,
      target,
      propertyKey
    );
  };
}
typescript@Injectable()
export class MetadataService {
  @CustomMetadata('important_method')
  processData(data: any) {
    return data;
  }

  getMethodMetadata() {
    const metadata = Reflect.getMetadata(
      METADATA_KEY,
      this,
      'processData'
    );
    console.log('Custom metadata:', metadata); // 'important_method'
    return metadata;
  }
}

以下の図は、メタデータの流れと DI コンテナでの活用を示しています。

mermaidsequenceDiagram
    participant Compiler as TypeScript<br/>コンパイラ
    participant Metadata as メタデータ<br/>ストレージ
    participant DI as DIコンテナ
    participant Service as サービス

    Note over Compiler: @Injectable() デコレータ処理
    Compiler->>Metadata: design:paramtypes 生成
    Compiler->>Metadata: カスタムメタデータ保存

    Note over DI: アプリケーション起動時
    DI->>Metadata: メタデータ読み取り
    Metadata-->>DI: 依存関係情報

    Note over DI: インスタンス生成時
    DI->>DI: 依存関係解決
    DI->>Service: インスタンス注入

図で理解できる要点

  • TypeScript コンパイラがデコレータを処理してメタデータを生成
  • DI コンテナが起動時にメタデータを読み取り依存関係を把握
  • 実行時に適切なインスタンスを注入して動作

まとめ

3 つの概念の相互関係

NestJS アーキテクチャの核心となる 3 つの概念は、密接に連携して動作しています。

mermaidflowchart TD
    subgraph concepts[核心概念]
        metadata[メタデータ<br/>設計時情報の保存]
        di[DIコンテナ<br/>依存関係の自動解決]
        provider[プロバイダ<br/>サービス提供の設定]
    end

    subgraph benefits[実現される価値]
        testability[テスタビリティ向上]
        maintainability[保守性向上]
        scalability[スケーラビリティ向上]
    end

    metadata --> di
    provider --> di
    di --> testability
    di --> maintainability
    di --> scalability

    style metadata fill:#e1f5fe
    style di fill:#e8f5e8
    style provider fill:#fff3e0

これらの概念を理解することで、以下のメリットを享受できます。

#概念主な役割開発への影響
1メタデータ型情報と DI 設定の保存コンパイル時の安全性確保
2DI コンテナ依存関係の自動解決手動管理からの解放
3プロバイダサービス提供方法の定義柔軟な設定と環境対応

実践で活用するポイント

1. 段階的な導入

NestJS を既存プロジェクトに導入する際は、以下の順序で進めることをお勧めします。

typescript// Step 1: 基本的なプロバイダから開始
@Injectable()
export class UserService {
  getUsers() {
    return ['user1', 'user2'];
  }
}
typescript// Step 2: 依存関係のあるサービスへ拡張
@Injectable()
export class UserService {
  constructor(private userRepository: UserRepository) {}
}
typescript// Step 3: カスタムプロバイダで柔軟性を追加
@Module({
  providers: [
    {
      provide: 'USER_CONFIG',
      useFactory: (config: ConfigService) => ({
        maxUsers: config.get('MAX_USERS', 100),
      }),
      inject: [ConfigService],
    },
  ],
})
export class UserModule {}

2. テストでの活用

DI アーキテクチャの真価は、テストのしやすさにあります。

typescriptdescribe('UserService', () => {
  let service: UserService;
  let mockRepository: jest.Mocked<UserRepository>;

  beforeEach(async () => {
    const mockRepo = {
      findAll: jest.fn(),
      findById: jest.fn(),
    };

    const module: TestingModule =
      await Test.createTestingModule({
        providers: [
          UserService,
          {
            provide: UserRepository,
            useValue: mockRepo,
          },
        ],
      }).compile();

    service = module.get<UserService>(UserService);
    mockRepository = module.get(UserRepository);
  });

  it('should return all users', () => {
    const users = [{ id: 1, name: 'Test User' }];
    mockRepository.findAll.mockReturnValue(users);

    expect(service.getAllUsers()).toEqual(users);
    expect(mockRepository.findAll).toHaveBeenCalled();
  });
});

3. エラーハンドリングのベストプラクティス

DI コンテナでよく発生するエラーと対処法をまとめました。

typescript// エラー: Circular dependency detected
// 解決策: forwardRefを使用
@Injectable()
export class UserService {
  constructor(
    @Inject(forwardRef(() => OrderService))
    private orderService: OrderService
  ) {}
}
typescript// エラー: Provider not found
// 解決策: プロバイダが正しくモジュールに登録されているか確認
@Module({
  providers: [UserService, UserRepository], // 両方とも必要
  exports: [UserService], // 他のモジュールで使用する場合
})
export class UserModule {}

これらの概念をマスターすることで、スケーラブルで保守性の高い NestJS アプリケーションを構築できるようになります。特に大規模なチーム開発では、このアーキテクチャの恩恵を強く実感できるでしょう。

関連リンク