第7章: DI スコープの罠 ─ Request bubble-up と Durable Providers
第2部までで「何を再発明していないか」を棚卸しした。第3部はパフォーマンス・メモリ効率に視点を切り替える。本章で扱うのは最も静かに重い罠 ─ REQUEST スコープの伝染(bubble-up) だ。
「便利だから」と Scope.REQUEST を1か所追加した結果、全エンドポイントが約 5% 遅くなる現象を、構造から解明する。
REQUEST スコープが「上に伝染」する仕組み
第1章で軽く触れたが、ここで完全に分解する。
@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {
constructor(@Inject(REQUEST) private req: Request) {}
get tenantId() { return this.req.headers['x-tenant-id']; }
}
@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 BT
T[TenantContextService<br/>明示的に REQUEST] -->|inject| S[CatsService<br/>暗黙的に REQUEST に昇格]
S -->|inject| C[CatsController<br/>暗黙的に REQUEST に昇格]
style T fill:#1a2030,stroke:#ff4d6d
style S fill:#1a2030,stroke:#ff6b35
style C fill:#1a2030,stroke:#ff4d6d
REQUEST が依存元方向(上)に伝染する。これが NestJS 公式が “bubble-up” と呼ぶ現象。
bubble-up が引き起こす実害
1. メモリオーバーヘッド
1 リクエストごとに:
- Controller 1 個(新規 instance)
- Service 1 個(新規 instance)
- Repository 1 個(新規 instance、これも REQUEST 経由で連鎖)
- その他 helper 5-10 個
30,000 並行リクエストなら:30,000 × N 個のオブジェクトが同時存在する。GC 圧が増える。
2. レイテンシ低下
NestJS 公式は「正しく設計されたアプリで約 5% のレイテンシ低下」と説明。設計を誤ると桁違いに遅くなる。
実測例(公開記事『Why You Should Avoid Using Request-scoped Injection in NestJS』より):
- Singleton: P99 18ms
- 全 tree REQUEST: P99 38ms
- 倍以上の差がつくこともある
3. 「壊れにくいが、遅い」
機能としては正常に動く。テストもパスする。問題は本番でしか現れない ─ p99 レイテンシ・GC pause・メモリ使用量。
bubble-up が起きる典型シナリオ
シナリオ1:Tenant Context を REQUEST スコープで実装
最も多いケース。マルチテナント SaaS で「現在のリクエストのテナント ID」を Service から取りたい。
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
constructor(@Inject(REQUEST) private req: Request) {}
get tenantId() { return this.req.tenantId; }
}
これを使う Service / Repository / Controller がすべて REQUEST に昇格。全 endpoint が遅くなる。
シナリオ2:Request-scoped Logger
「全ログに request ID を付けたい」。
@Injectable({ scope: Scope.REQUEST })
export class RequestLogger {
constructor(@Inject(REQUEST) private req: Request) {}
log(msg: string) { console.log(`[${this.req.id}] ${msg}`); }
}
これを各 Service に注入したら、全 Service が REQUEST 化。
シナリオ3:Request-scoped Database Connection
「リクエストごとに transaction を分けたい」。
@Injectable({ scope: Scope.REQUEST })
export class TransactionContext {
/* リクエスト独自の DB transaction を保持 */
}
これも全 tree が REQUEST に。
3 つとも正当な要求だ。問題は「REQUEST scope を使う」という実装手段が重いことにある。
救済策1:Durable Providers(v9 以降)
NestJS は v9 で Durable Providers を導入してこの問題に答えた。
// 1. 通常の REQUEST スコープに durable: true を付ける
@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantContext { /* ... */ }
// 2. ContextIdStrategy を実装
@Injectable()
export class TenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
const tenantId = request.headers['x-tenant-id'];
if (!tenantId) return;
// 同じ tenantId なら同じ ContextId を返す
return (info: HostComponentInfo) =>
info.isTreeDurable
? ContextIdFactory.getByRequest({ tenantId } as any)
: contextId;
}
}
// 3. Application で登録
ContextIdFactory.apply(new TenantContextIdStrategy());
Durable Providers の効果
graph TB
R1[Request 1<br/>tenant=A]
R2[Request 2<br/>tenant=A]
R3[Request 3<br/>tenant=B]
R4[Request 4<br/>tenant=A]
R5[Request N<br/>tenant=A]
R1 --> SA[Sub-tree for tenant A<br/>共有]
R2 --> SA
R4 --> SA
R5 --> SA
R3 --> SB[Sub-tree for tenant B]
style SA fill:#1a2030,stroke:#00d9c0
style SB fill:#1a2030,stroke:#b794f4
同じテナントなら DI サブツリーを再利用。リクエストごとの再構築コストが消える。マルチテナント SaaS ではこれが現実的な救済策。
Durable Providers の前提条件
info.isTreeDurableでフィルタするので、通常の REQUEST scope provider は durable にしない限り従来通り- すべての REQUEST scope provider に
durable: trueが必要(混在させない) - ContextIdStrategy のキー(上の例なら
tenantId)が実質的にサブツリーの粒度を決める
救済策2:AsyncLocalStorage(nestjs-cls)
別のアプローチが REQUEST scope を使わずに request context を伝搬すること。Node.js の AsyncLocalStorage を使う。
// app.module.ts
import { ClsModule } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
setup: (cls, req) => {
cls.set('tenantId', req.headers['x-tenant-id']);
cls.set('userId', req.user?.id);
cls.set('requestId', req.id);
},
},
}),
],
})
export class AppModule {}
// service(Singleton のまま)
@Injectable() // ← DEFAULT
export class CatsService {
constructor(private cls: ClsService) {}
async findAll() {
const tenantId = this.cls.get('tenantId');
return this.repo.find({ where: { tenantId } });
}
}
Service は Singleton のままで、AsyncLocalStorage 経由で context が取れる。
AsyncLocalStorage の動作原理
graph LR
Req1[Request 1<br/>req.id=A] -->|enter ALS| Ctx1[AsyncLocalStorage<br/>context A]
Req2[Request 2<br/>req.id=B] -->|enter ALS| Ctx2[AsyncLocalStorage<br/>context B]
Ctx1 --> S[Singleton CatsService]
Ctx2 --> S
S --> Get1[cls.get tenant'<br/>request 1 の文脈で取得]
S --> Get2[cls.get 'tenant'<br/>request 2 の文脈で取得]
style S fill:#1a2030,stroke:#4cc9f0
Node.js が async_hooks で「現在のリクエストはどれか」を追跡する。cls.get() は呼び出し時のリクエストの context を返す。
nestjs-cls のオーバーヘッド
ベンチマーク(公開記事より):
- pino 単体:114ms
- pino + nestjs-cls:136.8ms(+20%)
REQUEST scope 伝染よりは軽い。マルチテナント以外なら多くの場合これで十分。
Durable Providers vs AsyncLocalStorage の使い分け
flowchart TB
Q[Request context が必要]
Q --> Q1{マルチテナント?}
Q1 -- Yes, テナント単位で<br/>サブツリー再利用したい --> D[Durable Providers]
Q1 -- No --> Q2{Service の<br/>Singleton 性を保ちたい?}
Q2 -- Yes 強く --> ALS[nestjs-cls<br/>AsyncLocalStorage]
Q2 -- いずれでも --> Q3{REQUEST scope の<br/>20% オーバーヘッドを許容?}
Q3 -- 許容する --> Naive[Scope.REQUEST<br/>素直に実装]
Q3 -- 許容しない --> ALS
| 観点 | Durable Providers | nestjs-cls (ALS) | Scope.REQUEST |
|---|---|---|---|
| Service の Singleton 性 | サブツリー単位で共有 | 完全に Singleton | REQUEST 化 |
| マルチテナント効率 | ◎ | ◯ | ✕ |
| 実装の単純さ | △ ContextIdStrategy が必要 | ◎ | ◎ |
| 学習コスト | 高 | 中 | 低 |
| パフォーマンス | ◎ | ◯ +20% | ✕ +多大 |
| デバッグ容易性 | ◯ | ◯ | ◎ |
実測 ─ どれだけ違うか
公開ベンチマーク(小規模 NestJS app、1000 reqs/s、p99 measured):
| 構成 | p50 | p99 | RSS |
|---|---|---|---|
| Singleton + ALS | 8ms | 22ms | 145MB |
| Singleton + Durable | 9ms | 24ms | 152MB |
| Scope.REQUEST 1個 | 11ms | 32ms | 189MB |
| Scope.REQUEST 全 tree | 18ms | 58ms | 312MB |
REQUEST 伝染は p99 で 2.6 倍、RSS で 2.1 倍。これが「正しく設計で 5%」の倍以上に膨らむ典型。
既存コードベースでの移行戦略
「全 Service が REQUEST に汚染されている」既存プロジェクトを救うときの手順:
1. 起点を見つける
- 「REQUEST 伝染の発端」になっている Provider を grep で探す
- `scope: Scope.REQUEST` が指定されている class を全数把握
2. nestjs-cls を導入
- ClsModule.forRoot を app.module に追加
- middleware で必要な context(userId / tenantId / requestId)を set
3. 「発端の Provider」を Singleton + cls.get に書き換える
@Injectable({ scope: Scope.REQUEST }) ↓
@Injectable()
export class TenantContext {
constructor(private cls: ClsService) {}
get tenantId() { return this.cls.get('tenantId'); }
}
4. 依存元の Service / Controller の REQUEST 化が消えることを確認
- DI ツリー全体が Singleton に戻る
5. ベンチマーク
- autocannon で Before/After を測定
- p99 / RSS が改善しているはず
アンチパターン:Logger を Singleton Service に直接注入
特に多いアンチパターン。
// ❌ 悪い:Request-scoped Logger を Singleton Service に注入
@Injectable({ scope: Scope.REQUEST })
export class RequestLogger { /* req.id 含む */ }
@Injectable() // ← DEFAULT のつもり
export class CatsService {
constructor(private logger: RequestLogger) {} // ← 伝染
// CatsService 全体が REQUEST 化、全エンドポイントが遅くなる
}
// ✅ 良い:nestjs-pino + ALS で Singleton 維持
@Injectable() // ← DEFAULT
export class CatsService {
constructor(@InjectPinoLogger(CatsService.name) private logger: PinoLogger) {}
async findOne(id: number) {
this.logger.info({ id }, 'finding cat');
// ↑ pino-http が ALS 経由で req.id を自動付与、Service は Singleton のまま
}
}
第8章で Logger 設計を深掘りする。
REQUEST scope を使ってもよい場面
ここまで「使うな」のような書き方をしたが、正当な使い所もある:
- 明示的に「リクエストごとに別」状態が必要で、context だけでは足りない(複雑な state machine)
- ライブラリの制約で REQUEST scope が必要(一部の transaction manager 等)
- そもそも REQUEST 数が少ない管理画面・バッチ処理用エンドポイント
「何となく便利そうだから」で REQUEST を使わない、が原則。
本章の要点
| # | 要点 |
|---|---|
| 1 | REQUEST スコープは 依存元方向(上)に伝染(bubble-up)。1か所の REQUEST が tree 全体を REQUEST 化する |
| 2 | 実害は p99 レイテンシ低下・GC 圧・RSS 増加。本番でしか出ない |
| 3 | NestJS 公式は「正しく設計で約 5% のレイテンシ低下」と説明、設計を誤ると 2-3 倍になりうる |
| 4 | 救済策1:Durable Providers(v9 以降)でテナント単位にサブツリー共有、ContextIdStrategy 実装が必要 |
| 5 | 救済策2:nestjs-cls + AsyncLocalStorage で REQUEST scope を使わず context 伝搬、Singleton 維持 |
| 6 | 使い分け:マルチテナント効率重視 = Durable / Singleton 性最優先 = nestjs-cls |
| 7 | nestjs-cls のオーバーヘッドは約 +20%、REQUEST 伝染よりは軽い |
| 8 | アンチパターン:Request-scoped Logger を Singleton Service に注入 ─ 全 Service が REQUEST 化 |
効いている根本原理
本章は 原理1(DI コンテナを信頼する) + 原理4(メモリ効率は DI スコープ × ストリーム × Logger に集約) が真ん中に立った章だった。REQUEST scope 伝染はアンチパターン15個の根本原因の1つ。
次章では引き続き「静かに重い」3層 ─ Validation / Logger / Cache ─ のコストを分解する。class-validator が typia 比 15,000 倍遅いという衝撃の数字、Pino + ALS の正しい統合、Cache Stampede 対策まで。