第1章: NestJS の心臓 ─ DI コンテナの内部動作
第1部の最初の章として、NestJS の最も内側にある DI コンテナを分解する。@Injectable() を付けた Service が、なぜ Controller のコンストラクタに自動で渡ってくるのか ─ その仕組みを把握すれば、Provider スコープ・循環依存・Dynamic Module・REQUEST scope の伝染まで、すべて同じ式で理解できるようになる。
NestFactory.create() の中で起きていること
const app = await NestFactory.create(AppModule);
await app.listen(3000);
この 2 行で、内部では大きく分けて以下が動く。
graph TB
Start[NestFactory.create] --> A[1. ApplicationConfig 生成]
A --> B[2. AbstractHttpAdapter 生成<br/>デフォルト ExpressAdapter]
B --> C[3. NestContainer 初期化]
C --> D[4. DependenciesScanner]
D --> E[5. InstanceLoader]
E --> F[6. OnModuleInit / OnApplicationBootstrap]
F --> G[7. listen]
style D fill:#1a2030,stroke:#ff4d6d
style E fill:#1a2030,stroke:#b794f4
このうち4と5が DI コンテナの中核だ。
DependenciesScanner ─ メタデータを集めるフェーズ
DependenciesScanner は、まだインスタンスを作らない。やることは「モジュールツリーを再帰的に走査して、どの Provider をどの Module が持っているかを記録する」だけだ。
graph LR
AppModule -->|imports| AuthModule
AppModule -->|imports| UsersModule
AppModule -->|providers| AppService
AuthModule -->|providers| AuthService
UsersModule -->|providers| UsersService
UsersModule -->|providers| UsersRepository
スキャナの動き:
scanForModules():モジュールツリーを再帰的に走査、importsメタデータと dynamic module プロパティをマージscanModulesForDependencies():providers / controllers / exports をReflect.getMetadata()で読み取り、コンテナへ登録。トポロジカルソート用に depth と global scope の bind 情報を作る
ここで重要なのは、全ての Provider が InstanceWrapper として登録されるが、コンストラクタはまだ呼ばれないこと。これが次のフェーズの自由度を生む。
InstanceLoader ─ 2 フェーズで作る
InstanceLoader は登録された InstanceWrapper を実体化する。ここがトリッキーで、2 フェーズに分かれている。
graph TB
Phase1[Phase 1: loadPrototype]
Phase1 --> P1[Object.create metatype.prototype]
P1 --> P2[コンストラクタを呼ばずに<br/>プロトタイプだけ生成]
P2 --> Phase2[Phase 2: loadInstance]
Phase2 --> R1[resolveConstructorParams で依存解決]
R1 --> R2[最後にコンストラクタを実行]
style Phase1 fill:#1a2030,stroke:#ff4d6d
style Phase2 fill:#1a2030,stroke:#b794f4
Phase 1: loadPrototype(コンストラクタを呼ばない)
const instance = Object.create(metatype.prototype);
// ↑ ここではコンストラクタは実行されない
// プロトタイプチェーンが繋がっただけのオブジェクトができる
Object.create() でクラスのプロトタイプは継承するが、コンストラクタは実行されない。これが循環依存の解決を可能にする鍵だ(後述)。
Phase 2: loadInstance(依存を解決して、実際に作る)
// 概念的なイメージ
const params = wrapper.metatype.deps;
const resolvedDeps = await Promise.all(params.map(dep => resolveDependency(dep)));
const instance = new wrapper.metatype(...resolvedDeps);
依存を先に解決してから、最後にコンストラクタを実行する。isPending / isResolved / donePromise で並行 register の競合を制御する。
Provider のスコープ ─ DEFAULT / REQUEST / TRANSIENT
Provider のスコープは 3 種類だ。
| Scope | 挙動 | 主な用途 |
|---|---|---|
| DEFAULT (Singleton) | アプリ全体で 1 インスタンス | ほぼすべての Service / Repository |
| REQUEST | リクエストごとにインスタンス、リクエスト終了で GC | リクエスト固有 state、認証コンテキスト、テナント情報 |
| TRANSIENT | 注入先ごとに新インスタンス。スコープは伝染しない | 状態を持つユーティリティ |
@Injectable() // = DEFAULT (Singleton)
export class CatsService {}
@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {}
@Injectable({ scope: Scope.TRANSIENT })
export class StatefulHelper {}
REQUEST スコープの「伝染(bubble-up)」
ここが NestJS で最も重要かつ誤解されやすいポイント。
@Injectable({ scope: Scope.REQUEST })
export class TenantContextService { /* ... */ }
@Injectable() // ← DEFAULT のつもり
export class CatsService {
constructor(private tenant: TenantContextService) {}
// ↑ REQUEST スコープのサービスを注入した瞬間
// CatsService 自身も REQUEST スコープに昇格する
}
@Controller()
export class CatsController {
constructor(private cats: CatsService) {}
// ↑ CatsService が REQUEST なので、Controller も REQUEST に昇格
}
graph TB
T[TenantContextService<br/>REQUEST] -->|inject| C[CatsService<br/>REQUEST に昇格]
C -->|inject| Ctrl[CatsController<br/>REQUEST に昇格]
style T fill:#1a2030,stroke:#ff4d6d
style C fill:#1a2030,stroke:#ff6b35
style Ctrl fill:#1a2030,stroke:#ff4d6d
つまり、依存元方向に「上にバブルアップ」する。30,000 並行リクエストなら、Controller・Service・Repository がそれぞれ 30,000 個ずつ作られる。NestJS 公式は「正しく設計されたアプリで約 5% のレイテンシ低下」と説明しているが、設計を誤ると桁違いに遅くなる。
注意:TRANSIENT は伝染しない。
Durable Providers ─ REQUEST 伝染の救済策(v9 以降)
@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantContextService { /* ... */ }
durable: true を付けて ContextIdStrategy を実装すると、テナントヘッダなどによってコンテキスト ID を共有してサブツリーを再利用できる。マルチテナント SaaS の救済策。第7章で詳しく扱う。
Custom Provider の 4 種
Provider は単純なクラス指定だけでなく、4 種類のカスタム形式がある。
// 1. useClass: 実装クラスを動的に決定
{
provide: ConfigService,
useClass: process.env.NODE_ENV === 'prod' ? ProdConfig : DevConfig,
}
// 2. useValue: 定数・既存インスタンス・モック
{
provide: 'API_KEY',
useValue: process.env.API_KEY,
}
// 3. useFactory: 関数の戻り値が provider
{
provide: DatabaseConnection,
useFactory: async (config: ConfigService) => {
return await createConnection(config.get('DATABASE_URL'));
},
inject: [ConfigService],
}
// 4. useExisting: 既存 provider のエイリアス
{
provide: LoggerService,
useExisting: WinstonLoggerService,
}
useFactory の関数を async にすると Async Provider になり、起動時に Promise が解決されるまで待ってから次のフェーズへ進む。
循環依存と forwardRef
「Service A が Service B に依存し、Service B も Service A に依存する」という循環依存は、設計としては避けたいが現実には起きる。NestJS は forwardRef() で解決手段を提供する。
@Injectable()
export class CatsService {
constructor(@Inject(forwardRef(() => DogsService)) private dogs: DogsService) {}
}
@Injectable()
export class DogsService {
constructor(@Inject(forwardRef(() => CatsService)) private cats: CatsService) {}
}
なぜこれで解けるのか
第2フェーズの loadInstance で Object.create(metatype.prototype) を使った理由がここで効く。
sequenceDiagram
participant L as InstanceLoader
participant A as CatsService
participant B as DogsService
L->>A: loadPrototype (Object.create)
Note over A: プロトタイプだけ作成<br/>コンストラクタ未実行
L->>B: loadPrototype (Object.create)
Note over B: プロトタイプだけ作成<br/>コンストラクタ未実行
L->>A: resolveConstructorParams
A-->>L: 依存: forwardRef(() => DogsService)
L->>L: B のプロトタイプは既にあるので参照渡し
L->>A: コンストラクタ実行(B のプロトタイプ参照を渡す)
L->>B: コンストラクタ実行(A のプロトタイプ参照を渡す)
両方のプロトタイプを先に作っておくから、循環している依存も「とりあえずプロトタイプ参照を渡す」ことで解ける。コンストラクタ実行はその後。
forwardRef の落とし穴
- ❌ 片方だけに
forwardRef:両側に必須 - ❌ コンストラクタで相手のメソッドを即呼ぶ:相手のコンストラクタはまだ完了していない可能性
- ❌ REQUEST スコープと混ぜる:
undefinedが混入することがある - ✅ そもそも循環を作らない:共有部分を別 Module に切り出す方が綺麗
ModuleRef ─ 動的に Provider を取り出す
DI コンテナを外側から触るための API。
@Injectable()
export class TaskRunner {
constructor(private moduleRef: ModuleRef) {}
async runTask(handlerName: string) {
// 1. Singleton を取得(同じインスタンスを返す)
const handler = this.moduleRef.get(handlerName, { strict: false });
// 2. TRANSIENT / REQUEST を解決(毎回新サブツリー)
const requestScoped = await this.moduleRef.resolve(SomeService);
// 3. 同じ context で複数解決(サブツリー共有)
const contextId = ContextIdFactory.create();
const a = await this.moduleRef.resolve(ServiceA, contextId);
const b = await this.moduleRef.resolve(ServiceB, contextId);
// a と b は同じ contextId なので、共通依存は同一インスタンス
// 4. DI に登録されていないクラスを動的 instantiate
const adhoc = await this.moduleRef.create(SomeClass);
}
}
get() と resolve() の使い分けが重要:
| メソッド | 対象 | 戻り値 |
|---|---|---|
get(Token) | DEFAULT (Singleton) のみ | 同じインスタンス |
resolve(Token) | TRANSIENT / REQUEST | 毎回新サブツリー(同じ contextId 指定で共有可) |
REQUEST scope を get() で取ろうとすると例外になる。これはテストコードでよく見る落とし穴。
DI コンテナのパフォーマンス特性
「正しく設計された」Singleton 中心のアプリでは、DI コンテナのコストは起動時のみ。リクエスト処理時は単に既存インスタンスを参照するだけなので、ほぼゼロ。
逆に REQUEST スコープを使うと、毎リクエストでサブツリー全体を再構築する。これがレイテンシ・GC 圧の主因になる。
graph LR
R1[Request 1] --> S1[Sub-tree 1]
R2[Request 2] --> S2[Sub-tree 2]
R3[Request 3] --> S3[Sub-tree 3]
Rn[Request N] --> Sn[Sub-tree N]
S1 --> GC[GC 圧]
S2 --> GC
S3 --> GC
Sn --> GC
style GC fill:#1a2030,stroke:#ff4d6d
第7章の「DI スコープの罠」で、この実装上の最適化(Durable Providers、AsyncLocalStorage 代替)を深掘りする。
起動シーケンスとライフサイクルフック
NestFactory.create() のあと、以下のフックが順序通りに呼ばれる:
graph TB
A[OnModuleInit<br/>依存先 → 依存元 leaf-first]
A --> B[OnApplicationBootstrap<br/>全モジュール init 後]
B --> C[listen 開始]
C --> D[実行フェーズ]
D --> E[SIGTERM 受信]
E --> F[OnModuleDestroy<br/>依存元 → 依存先 root-first]
F --> G[BeforeApplicationShutdown]
G --> H[HTTP 接続 close]
H --> I[OnApplicationShutdown]
style A fill:#1a2030,stroke:#4cc9f0
style F fill:#1a2030,stroke:#ff4d6d
v11 で重要な変更:依存チェーン A → B → C のとき、
OnModuleInitは C → B → A(依存先から)OnModuleDestroyは A → B → C(依存元から)
これにより「初期化時に依存先を使い、破棄時に依存元から閉じる」という正しい順序が保証される。第4章で詳細を扱う。
Graceful Shutdown は明示的に有効化が必要:
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // ← 忘れがち、デフォルト無効
await app.listen(3000);
本章の要点
| # | 要点 |
|---|---|
| 1 | NestJS 起動は DependenciesScanner(メタデータ収集)→ InstanceLoader(インスタンス化) の 2 段構え |
| 2 | InstanceLoader は loadPrototype(コンストラクタ呼ばず)→ loadInstance(依存解決後コンストラクタ) の 2 フェーズ |
| 3 | Provider スコープは DEFAULT / REQUEST / TRANSIENT の 3 種。REQUEST は依存元方向に伝染(bubble-up) |
| 4 | Durable Providers(v9 以降)でテナント単位にサブツリー再利用が可能 |
| 5 | Custom Provider は useClass / useValue / useFactory / useExisting の 4 種 |
| 6 | 循環依存は forwardRef() で解ける。両側必須、REQUEST と混ぜない |
| 7 | ModuleRef.get() は Singleton 用、resolve() は TRANSIENT/REQUEST 用。ContextId 共有でサブツリー共有可 |
| 8 | Graceful Shutdown は app.enableShutdownHooks() を呼ばないと有効にならない |
効いている根本原理
本章は 原理1(DI コンテナを信頼する) を根本から分解した章だった。「自分で new していないか」「DI で取れるものを取っているか」という問いに答えるには、まず DI コンテナが何をしているのかを知る必要がある。次章では、これを支える土台 ─ Decorator と Reflect.metadata の二層構造 ─ を扱う。@Injectable() がなぜ動くのか、なぜ NestJS は今でも experimentalDecorators を使い続けているのか、を見ていく。