目次を表示する

NestJS Deep Dive 2026 ─ 内部構造・再発明回避・パフォーマンスを 10 章で読み解く

DI スコープの罠 ─ Request bubble-up と Durable Providers

第7章: DI スコープの罠 ─ Request bubble-up と Durable Providers

REQUEST 伝染とベンチ + 2 solutions

第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 Providersnestjs-cls (ALS)Scope.REQUEST
Service の Singleton 性サブツリー単位で共有完全に SingletonREQUEST 化
マルチテナント効率
実装の単純さ△ ContextIdStrategy が必要
学習コスト
パフォーマンス◯ +20%✕ +多大
デバッグ容易性

実測 ─ どれだけ違うか

公開ベンチマーク(小規模 NestJS app、1000 reqs/s、p99 measured):

構成p50p99RSS
Singleton + ALS8ms22ms145MB
Singleton + Durable9ms24ms152MB
Scope.REQUEST 1個11ms32ms189MB
Scope.REQUEST 全 tree18ms58ms312MB

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 を使わない、が原則。

本章の要点

#要点
1REQUEST スコープは 依存元方向(上)に伝染(bubble-up)。1か所の REQUEST が tree 全体を REQUEST 化する
2実害は p99 レイテンシ低下・GC 圧・RSS 増加。本番でしか出ない
3NestJS 公式は「正しく設計で約 5% のレイテンシ低下」と説明、設計を誤ると 2-3 倍になりうる
4救済策1:Durable Providers(v9 以降)でテナント単位にサブツリー共有、ContextIdStrategy 実装が必要
5救済策2:nestjs-cls + AsyncLocalStorage で REQUEST scope を使わず context 伝搬、Singleton 維持
6使い分け:マルチテナント効率重視 = Durable / Singleton 性最優先 = nestjs-cls
7nestjs-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 対策まで。