第3章: リクエストライフサイクル ─ 6 つの層を正しく使い分ける
第1章で起動時、第2章で Decorator を扱った。本章は実行時 ─ リクエストが来てからレスポンスを返すまでに NestJS が走らせる 6 つの層を分解する。
「どこで何をすべきか」を間違えると、第9章で扱うアンチパターンの大半が生まれる。Interceptor で DB を引く / Pipe で外部 API を叩く / Service 内に try-catch を散らかす ─ これらは全て、層の役割を取り違えた結果だ。
完全な順序
NestJS のリクエストパイプラインは以下の順序で動く。
graph TB
Req[Incoming Request] --> M[1. Middleware<br/>global → module-bound]
M --> G[2. Guards<br/>global → controller → route]
G --> IB[3. Interceptors before<br/>global → controller → route]
IB --> P[4. Pipes<br/>global → controller → route → 引数<br/>※引数は last → first]
P --> H[5. Handler]
H --> IA[6. Interceptors after<br/>route → controller → global<br/>※first-in-last-out]
IA --> EF{例外?}
EF -->|Yes| F[Exception Filter<br/>route → controller → global]
EF -->|No| Res[Outgoing Response]
F --> Res
style M fill:#1a2030,stroke:#7c8db5
style G fill:#1a2030,stroke:#ff4d6d
style IB fill:#1a2030,stroke:#b794f4
style P fill:#1a2030,stroke:#4cc9f0
style H fill:#1a2030,stroke:#00d9c0
style IA fill:#1a2030,stroke:#b794f4
style F fill:#1a2030,stroke:#ff6b35
「Interceptor before」と「Interceptor after」は同じ Interceptor の intercept() メソッドの前半と後半だ。RxJS の Observable を使って handler を「囲む」構造になっている。
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('Before...'); // ← Interceptor before
const start = Date.now();
return next.handle().pipe(
tap(() => console.log(`After... ${Date.now() - start}ms`)) // ← Interceptor after
);
}
}
各層の責務 ─ 「ここで何をすべきか」
| 層 | 責務 | やってよいこと | やってはいけないこと |
|---|---|---|---|
| Middleware | プラットフォーム依存の前処理 | ロギング、ヘッダ操作、CORS | ビジネスロジック、エラー整形 |
| Guard | 認可・認証 | boolean を返して通過/拒否 | データ取得(Service / Repository に) |
| Interceptor (before) | 実行前共通処理 | タイマー開始、セッション解決 | DB アクセス、外部 API 呼び出し |
| Pipe | データ検証・変換 | class-validator / transform | 副作用のある処理、外部 API |
| Handler | ビジネスロジック | Service の呼び出し、結果返却 | 横断関心事(log/cache/auth) |
| Interceptor (after) | レスポンス変換、tap 系 | map / tap でログ・キャッシュ | DB アクセス、レスポンスの再 fetch |
| Exception Filter | 例外整形 | エラーを HTTP レスポンスに変換 | ビジネスロジック、リトライ |
エラー時の流れ
任意のステージ(Middleware, Guard, Interceptor, Pipe, Handler, Service)で throw されると、ExceptionsHandler が lowest scope の Filter から順 に探して match した filter で処理する。
graph LR
Throw[throw new HttpException]
Throw --> R{route Filter?}
R -->|hit| Done1[整形して response]
R -->|miss| C{controller Filter?}
C -->|hit| Done2[整形して response]
C -->|miss| G{global Filter?}
G -->|hit| Done3[整形して response]
G -->|miss| Default[ビルトインの error response]
ここで重要なのは、Service 内で try-catch を書きまくらないということ。NestJS の哲学は「throw すれば Filter が拾う」だ。
// ❌ 悪い:Service 内で try-catch、独自エラー整形
@Injectable()
export class CatsService {
async findById(id: number) {
try {
const cat = await this.repo.findOne(id);
if (!cat) {
return { error: 'NOT_FOUND', message: '...' }; // ← 戻り値で表現
}
return cat;
} catch (e) {
return { error: 'INTERNAL', message: e.message }; // ← 戻り値で表現
}
}
}
// ✅ 良い:throw に統一、Filter で一括整形
@Injectable()
export class CatsService {
async findById(id: number) {
const cat = await this.repo.findOne(id);
if (!cat) throw new NotFoundException(`Cat ${id} not found`);
return cat;
}
}
各層のスコープ ─ global / controller / route
各層は 3 つのスコープで適用できる。global が最も粗く、route が最も細かい。
// 1. Global - 全エンドポイントに適用
app.useGlobalGuards(new AuthGuard()); // ← DI が効かない!
// または
{ provide: APP_GUARD, useClass: AuthGuard }; // ← DI が効く(推奨)
// 2. Controller - 特定 Controller のすべての route
@UseGuards(AuthGuard)
@Controller('cats')
export class CatsController {}
// 3. Route - 特定 method のみ
@UseGuards(AuthGuard)
@Get(':id')
findOne() {}
useGlobalGuards(new AuthGuard()) の落とし穴
useGlobalGuards() で インスタンスを渡すと、そのインスタンスは DI コンテナの外で生成される。Guard 内で他の Service を inject できない。
// ❌ 悪い:DI 外でインスタンス生成
app.useGlobalGuards(new AuthGuard()); // ← AuthGuard は ConfigService 等を注入できない
// ✅ 良い:APP_GUARD token で provider 登録
@Module({
providers: [
{ provide: APP_GUARD, useClass: AuthGuard }, // ← DI ツリーに乗る
],
})
export class AppModule {}
これは Pipe / Interceptor / Filter でも同じ。APP_PIPE / APP_INTERCEPTOR / APP_FILTER が用意されている。実装で DI を使うなら絶対にこちらを使う。
Pipe ─ 引数ごとの動作
Pipe は他の層と違い、引数ごとに last → first の順で適用される。
@Get(':id/:type')
findOne(
@Param('id', ParseIntPipe) id: number, // ← 第1引数(最後に処理される)
@Param('type', new ValidatePipe()) type: string, // ← 第2引数(先に処理される)
) {}
これは内部的に「右から左へ」処理されるという RxJS / 関数合成の伝統に従う。
ただし、Pipe で副作用のある処理を書いてはいけない。
// ❌ 悪い:Pipe で外部 API 呼び出し
@Injectable()
export class FetchUserPipe implements PipeTransform {
async transform(value: number) {
return await fetch(`/api/users/${value}`).then(r => r.json()); // ← Pipe で外部呼び出し
}
}
// ✅ 良い:Pipe は変換のみ、外部呼び出しは Service / Interceptor に
@Injectable()
export class ParseIdPipe implements PipeTransform {
transform(value: string): number {
const id = parseInt(value);
if (isNaN(id)) throw new BadRequestException('id must be a number');
return id;
}
}
Interceptor の重ね順と RxJS
Interceptor は first-in-last-out で重なる。最後に登録された Interceptor の tap が最初に実行される。
// app.module.ts
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
graph TB
Req[Request] --> L1[LoggingInterceptor before]
L1 --> T1[TransformInterceptor before]
T1 --> H[Handler]
H --> T2[TransformInterceptor after]
T2 --> L2[LoggingInterceptor after]
L2 --> Res[Response]
style L1 fill:#1a2030,stroke:#b794f4
style T1 fill:#1a2030,stroke:#4cc9f0
style H fill:#1a2030,stroke:#00d9c0
style T2 fill:#1a2030,stroke:#4cc9f0
style L2 fill:#1a2030,stroke:#b794f4
Interceptor は RxJS の Observable を使うため、不要な演算子を入れるとオーバーヘッドになる。
// ❌ 悪い:何もしない map() を入れる
return next.handle().pipe(
map(data => data), // ← 無意味
);
// ✅ 良い:必要な処理のみ
return next.handle().pipe(
tap(() => /* log */),
);
// ✅ 良い:レスポンス整形
return next.handle().pipe(
map(data => ({ data, timestamp: Date.now() })),
);
ExecutionContext / ArgumentsHost ─ コンテキストを取得する API
Guard / Interceptor / Filter には ExecutionContext が渡される。これは ArgumentsHost を継承していて、HTTP / RPC / WebSocket / GraphQL のコンテキストを抽象的に扱うための API だ。
canActivate(context: ExecutionContext): boolean {
// 1. 種別の判定
const type = context.getType(); // 'http' | 'rpc' | 'ws' | 'graphql'
// 2. HTTP コンテキスト
if (type === 'http') {
const req = context.switchToHttp().getRequest<Request>();
const res = context.switchToHttp().getResponse<Response>();
}
// 3. RPC(Microservice)コンテキスト
if (type === 'rpc') {
const data = context.switchToRpc().getData();
const ctx = context.switchToRpc().getContext();
}
// 4. WebSocket コンテキスト
if (type === 'ws') {
const data = context.switchToWs().getData();
const client = context.switchToWs().getClient();
}
// 5. ExecutionContext 固有:handler / class への参照
const handler = context.getHandler(); // 次に呼ばれるメソッド
const cls = context.getClass(); // controller クラス
// 6. メタデータ取得(Reflector 経由)
const roles = this.reflector.get(Roles, handler);
return /* 判定 */;
}
switchToHttp() 等は薄いラッパで、コンテキスト依存のオブジェクトを返すだけ。生の getArgs() / getArgByIndex() もあるが、HTTP / RPC / WS で意味が変わるので使うべきでない。
アンチパターン:Interceptor で DB アクセス
「全リクエストにユーザー情報を付ける」ために Interceptor で DB を引く ─ これは典型的なアンチパターン。
// ❌ 悪い:Interceptor で毎リクエスト DB アクセス
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
constructor(private userService: UserService) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
req.user = await this.userService.findById(req.userId); // ← 全 endpoint で DB 引く
return next.handle();
}
}
問題:
- すべてのリクエストで DB が走る(unauth でもheath check でも)
- N+1 を生むプロデューサーになる
- Interceptor の責務(横断関心事)の範囲を超えている
// ✅ 良い:Guard で認証、Service が必要に応じて引く
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const token = extractToken(req);
if (!token) return false;
req.userId = verifyToken(token); // ← トークン検証だけ、DB 引かない
return true;
}
}
// 必要な Service だけが、必要なときに DB を引く
@Injectable()
export class CatsService {
constructor(@Inject(REQUEST) private req: Request, private users: UserRepository) {}
async findOwner() {
return await this.users.findById(this.req.userId); // ← 必要なときだけ
}
}
アンチパターン:Service 内 try-catch 多用
// ❌ 悪い:try-catch で覆い、独自のエラー型に変換
async create(dto: CreateCatDto) {
try {
return await this.repo.save(dto);
} catch (e) {
if (e.code === '23505') {
throw new ConflictException('Already exists');
}
throw new InternalServerErrorException(e.message);
}
}
// ✅ 良い:throw に統一、Filter で一括整形
async create(dto: CreateCatDto) {
return await this.repo.save(dto);
}
// グローバル Filter で DB エラーを HTTP に変換
@Catch(QueryFailedError)
export class DbExceptionFilter implements ExceptionFilter {
catch(ex: QueryFailedError, host: ArgumentsHost) {
const status = ex.driverError?.code === '23505' ? 409 : 500;
/* レスポンス整形 */
}
}
スタックトレースが残る / エラー処理が一箇所に集約される / テストが書きやすい ─ これが NestJS の意図する設計だ。
✅ 良い実装の指針
Middleware:
✅ ログ、CORS、ヘッダ操作
❌ ビジネスロジック
Guard:
✅ JWT 検証、Role チェック(DB 引かずに済むもの)
❌ DB アクセス、外部 API
Interceptor (before):
✅ タイマー開始、リクエスト ID 生成、ログ
❌ DB アクセス
Pipe:
✅ class-validator、ParseIntPipe、変換
❌ 副作用、外部 API
Handler:
✅ Service 呼び出し、結果返却
❌ HTTP 詳細を意識する処理
Interceptor (after):
✅ レスポンス整形、tap 系のログ・キャッシュ
❌ DB アクセス
Exception Filter:
✅ エラーを HTTP レスポンスに整形
❌ ビジネスロジック、リトライ
本章の要点
| # | 要点 |
|---|---|
| 1 | リクエストライフサイクルは Middleware → Guard → Interceptor (before) → Pipe → Handler → Interceptor (after) → Exception Filter の順 |
| 2 | Interceptor は handler を「囲む」構造で、next.handle() の前後が before / after |
| 3 | useGlobalGuards(new Guard()) は DI が効かない。APP_GUARD token を使う(APP_PIPE / APP_INTERCEPTOR / APP_FILTER も同様) |
| 4 | Pipe は引数ごとに last → first で適用 |
| 5 | Interceptor は first-in-last-out。RxJS の不要演算子はオーバーヘッド |
| 6 | ExecutionContext は HTTP / RPC / WS / GraphQL を抽象化、switchTo*() でコンテキスト取得 |
| 7 | 各層の責務違反(Interceptor で DB / Pipe で外部API / Service 内 try-catch)はアンチパターンの代表 |
| 8 | エラーは throw に統一、Filter で一括処理。NestJS の哲学 |
効いている根本原理
本章は 原理3(リクエスト境界の「層」を正しく使い分ける) そのものだった。「どこで何をすべきか」を間違えると、第9章のアンチパターンの大半が生まれる。
ここまでで第1章(DI)・第2章(Decorator)・第3章(リクエストライフサイクル)と、NestJS の実行時の動きを分解した。次の第4章では、これらを束ねるModule システムと、v11 で起きた3つの大きな変更(Module 解決アルゴリズム / ライフサイクルフック順序 / Express v5)を詳しく見ていく。