目次を表示する

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

アンチパターン 15 ─ 症状 → 根本原因 → 脱出法

第9章: アンチパターン 15 ─ 症状 → 根本原因 → 脱出法

アンチパターン 15 を 4 系統で分類

第1〜8章で内部構造・モジュール・性能を扱った。本章はそれらを15 個のアンチパターンとして集約し、「症状 → 根本原因 → 脱出法」の3段で識別できるようにする。

これらは Palantir 公式の 8 アンチパターン(オントロジー編 第9章)と同じ思想で構造化している。コードレビューで一目で識別できるように、各々に Before / After のコードと共に整理する。

全体マップ ─ 4 系統 15 パターン

mindmap
  root((15<br/>Anti-Patterns))
    DI / Scope
      1 Request scope 濫用
      2 Scope 伝染
      4 forwardRef で隠蔽
      10 Singleton が REQUEST 依存
      11 DI で取れるものを new
    Layer 責務違反
      5 Interceptor で DB
      6 Pipe で外部 API
      7 Global pipe で全 validation
      8 HttpException 絨毯爆撃
      9 Service 内 try-catch 多用
      14 Global guard で重い権限
    型 / RxJS
      12 Observable で sync 包む
      13 DTO に変換ロジック
    Logging / Bootstrap
      3 Singleton Logger に REQUEST
      15 main.ts 肥大化

順に見ていく。

#1. Request scope の濫用

症状

レスポンスが遅い、heap が膨らむ、p99 レイテンシが本番で 2 倍。

根本原因

Singleton で書ける処理を Scope.REQUEST にしている。

// ❌ なんとなく便利そうで REQUEST に
@Injectable({ scope: Scope.REQUEST })
export class CatsService {
  // request 文脈が必要なメソッドは1つだけなのに、
  // service 全体が REQUEST 化
}

脱出法

// ✅ Singleton + ALS で context 取得
@Injectable() // ← DEFAULT
export class CatsService {
  constructor(private cls: ClsService) {}

  async findAll() {
    const tenantId = this.cls.get('tenantId');
    return this.repo.find({ where: { tenantId } });
  }
}

第7章の nestjs-cls 移行を参照。

#2. Scope 伝染で全 tree が REQUEST 化

症状

全エンドポイントが遅い、特定のサービスを変更したらアプリ全体に波及。

根本原因

1 つの REQUEST scope が**依存元方向に伝染(bubble-up)**して、Controller・Service・Repository すべてが REQUEST 化。

// 🔴 起点は1か所
@Injectable({ scope: Scope.REQUEST })
export class TenantContext { /* ... */ }

// 🔴 ここから上へ伝染
@Injectable() // ← DEFAULT のつもり、実は REQUEST 化
export class CatsService {
  constructor(private tenant: TenantContext) {}
}

脱出法

// ✅ 起点を Singleton + ALS に書き換え
@Injectable()
export class TenantContext {
  constructor(private cls: ClsService) {}
  get tenantId() { return this.cls.get('tenantId'); }
}

または Durable Providers でテナント単位サブツリー共有(第7章)。

#3. Logger を Singleton Service に直接注入

症状

テスト時に context が失われる、scope 伝染で全体が REQUEST 化。

根本原因

request 文脈を持つ Logger を Singleton Service に注入している。

// ❌
@Injectable({ scope: Scope.REQUEST })
export class RequestLogger { /* req.id を使う */ }

@Injectable() // ← 実は REQUEST 化
export class CatsService {
  constructor(private logger: RequestLogger) {}
}

脱出法

// ✅ nestjs-pino + ALS
@Injectable()
export class CatsService {
  constructor(@InjectPinoLogger(CatsService.name) private logger: PinoLogger) {}
  // ↑ pino-http が ALS 経由で req.id を自動付与、Service は Singleton のまま
}

第8章の Pino 統合を参照。

#4. 循環 DI を forwardRef で隠蔽

症状

undefined 注入、race condition、起動時にうっすら警告。

根本原因

モジュール境界の責務分離不足を forwardRef()応急処置している。

// ❌ 責務分離せず forwardRef で逃げる
@Injectable()
export class CatsService {
  constructor(@Inject(forwardRef(() => DogsService)) private dogs: DogsService) {}
}

@Injectable()
export class DogsService {
  constructor(@Inject(forwardRef(() => CatsService)) private cats: CatsService) {}
}

脱出法

// ✅ 共有部分を別モジュールに切り出す
@Injectable()
export class AnimalsCommonService { /* 両方が必要な共通ロジック */ }

@Module({
  providers: [AnimalsCommonService],
  exports: [AnimalsCommonService],
})
export class AnimalsCommonModule {}

// CatsService と DogsService は AnimalsCommonService に依存(一方向)

forwardRef最後の手段であって、設計の改善が先

#5. Interceptor で DB アクセス

症状

全リクエストが DB を引き、N+1 を生む。Health check も DB を叩く。

根本原因

Interceptor の責務(横断関心事)にビジネスロジック / データ取得を混入。

// ❌
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
  async intercept(context: ExecutionContext, next: CallHandler) {
    const req = context.switchToHttp().getRequest();
    req.user = await this.userService.findById(req.userId); // ← 全 endpoint で DB
    return next.handle();
  }
}

脱出法

// ✅ Guard で認証だけ、Service が必要時に DB
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    req.userId = verifyToken(extractToken(req)); // ← 検証のみ、DB 引かない
    return true;
  }
}

// 必要な Service が必要時に取得
@Injectable()
export class CatsService {
  constructor(private cls: ClsService, private users: UserRepository) {}
  async findOwner() {
    return await this.users.findById(this.cls.get('userId'));
  }
}

#6. Pipe で外部 API 呼び出し

症状

リクエスト全体がブロック、外部 API ダウンで全 endpoint がエラー。

根本原因

Pipe = 純粋な変換 / 検証であるべきところに副作用を入れた。

// ❌
@Injectable()
export class FetchUserPipe implements PipeTransform {
  async transform(value: number) {
    return await fetch(`/api/users/${value}`).then(r => r.json());
  }
}

脱出法

// ✅ Pipe は変換のみ、外部呼び出しは Service
@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;
  }
}

// Service で fetch
@Get(':id')
async findUser(@Param('id', ParseIdPipe) id: number) {
  return await this.svc.findUser(id); // Service 内で fetch
}

#7. Global pipe にすべての validation を背負わせる

症状

変換不要なエンドポイントでも transform が走る、CPU 消費。

根本原因

横断適用の罠。「useGlobalPipes(new ValidationPipe({ transform: true, ... })) を 1 回書けば全部 OK」という発想。

脱出法

// ✅ 重い validation は per-controller / per-handler
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // 軽量

@UsePipes(new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } }))
@Post()
create(@Body() dto: CreateOrderDto) {} // ← 必要な endpoint のみ

class-validator のリフレクションコストは O(プロパティ数 × ネスト深さ)。全 endpoint で発動させない

#8. HttpException 絨毯爆撃

症状

エラーフォーマットが endpoint ごとにバラバラ、フロントエンドが場合分けの嵐。

根本原因

各所で throw + 各所で format。

// ❌
async create(dto) {
  if (await this.repo.exists(dto.email)) {
    throw new ConflictException({
      error: 'EMAIL_EXISTS',
      message: 'メールアドレスが既に使われています',
    });
  }
  // ...
}

async update(id, dto) {
  const cat = await this.repo.findOne(id);
  if (!cat) {
    throw new NotFoundException({
      code: 404,
      detail: 'cat not found',
    });
  }
}

エラー本文の構造がバラバラ。

脱出法

// ✅ 例外型を整理 + Global Filter で一括整形
export class EmailExistsException extends HttpException {
  constructor(email: string) {
    super({ code: 'EMAIL_EXISTS', email }, HttpStatus.CONFLICT);
  }
}

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(ex: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const status = ex.getStatus();
    const response = ex.getResponse();
    ctx.getResponse<Response>().status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: ctx.getRequest<Request>().url,
      ...(typeof response === 'object' ? response : { message: response }),
    });
  }
}

{ provide: APP_FILTER, useClass: HttpExceptionFilter }

レスポンス形式が一貫する。

#9. Service 内 try-catch 多用

症状

スタックトレース消失、Filter に例外が届かない、独自エラー型で if 分岐。

根本原因

Filter を信用していない。

// ❌
async create(dto) {
  try {
    return await this.repo.save(dto);
  } catch (e) {
    if (e.code === '23505') {
      return { error: 'CONFLICT' }; // ← 戻り値で表現、HTTP ステータス 200
    }
    return { error: 'INTERNAL', message: e.message };
  }
}

脱出法

// ✅ throw に統一
async create(dto) {
  return await this.repo.save(dto);
}

// QueryFailedError を Filter で HTTP に変換
@Catch(QueryFailedError)
export class DbExceptionFilter implements ExceptionFilter {
  catch(ex: QueryFailedError, host: ArgumentsHost) {
    const status = ex.driverError?.code === '23505' ? 409 : 500;
    /* レスポンス整形 */
  }
}

#10. Singleton Service が REQUEST に依存

症状

scope 伝染で全体が REQUEST 化、ベンチが落ちる。

根本原因

暗黙の依存方向違反。

これは #1 #2 #3 と密接に関連するが、独立したアンチパターンとして扱うべき。詳細は第7章。

脱出法

// ✅ Singleton 維持、context は ALS
@Injectable()
export class CatsService {
  constructor(private cls: ClsService) {}
}

// または ModuleRef.resolve で動的に
@Injectable()
export class TaskRunner {
  constructor(private moduleRef: ModuleRef) {}
  async run() {
    const requestScoped = await this.moduleRef.resolve(RequestScopedService);
    // 必要な時だけ resolve
  }
}

#11. DI で取れるものを new する

症状

テスト不能、依存隠蔽、global state。

根本原因

設計の怠惰。「new した方が楽」と思った瞬間。

// ❌
@Injectable()
export class CatsService {
  private mailer = new MailerService(); // ← new
  async sendNotice() {
    await this.mailer.send(/* ... */);
  }
}

脱出法

// ✅ constructor injection
@Injectable()
export class CatsService {
  constructor(private mailer: MailerService) {} // ← DI

  async sendNotice() {
    await this.mailer.send(/* ... */);
  }
}

テストで MailerService をモックに差し替えられる。

#12. Observable で sync 処理を包む

症状

RxJS チェーン構築の無駄なコスト、可読性低下。

根本原因

reactive 教の過剰適用。

// ❌
@Get()
findAll(): Observable<Cat[]> {
  return of(this.cats); // ← sync の値を Observable に
}

return next.handle().pipe(
  map(data => data), // ← 何もしない map
);

脱出法

// ✅ 同期 return / Promise で十分なら使わない
@Get()
findAll(): Cat[] {
  return this.cats;
}

return next.handle(); // ← pipe しない

Observable はストリーム/キャンセル/合成が必要なときだけ

#13. DTO に複雑な変換ロジック

症状

責務違反、テスト困難、DTO のはずがビジネスロジックを持つ。

根本原因

DTO = データ contract という前提を崩す。

// ❌
class CreateOrderDto {
  @IsArray() items: ItemDto[];

  toEntity(userId: string): Order {
    // ← DTO がビジネスロジック
    const total = this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
    return new Order(userId, this.items, total, /* tax 計算等 */);
  }
}

脱出法

// ✅ Mapper / Service に分離
class CreateOrderDto {
  @IsArray() items: ItemDto[];
}

@Injectable()
export class OrderMapper {
  toEntity(dto: CreateOrderDto, userId: string): Order {
    const total = dto.items.reduce((sum, i) => sum + i.price * i.qty, 0);
    return new Order(userId, dto.items, total);
  }
}

DTO は素直な構造体に保つ。

#14. Global guard で重い権限チェック

症状

全 endpoint で DB を引く、health check も認証を要求。

根本原因

スコープを誤った。

// ❌ Global で全 endpoint に DB クエリ付き Guard
@Injectable()
export class PermissionGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const req = context.switchToHttp().getRequest();
    const user = await this.userService.findById(req.userId); // ← DB
    return user.permissions.includes(/* ... */);
  }
}

{ provide: APP_GUARD, useClass: PermissionGuard }

脱出法

// ✅ 軽い Global Guard + per-route の Permission Guard
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    // JWT 検証だけ、DB 引かない
    const req = context.switchToHttp().getRequest();
    req.user = verifyJwt(extractToken(req));
    return !!req.user;
  }
}

{ provide: APP_GUARD, useClass: AuthGuard }

// 重い権限チェックは per-route
@UseGuards(PermissionGuard)
@Roles('admin')
@Get('admin/users')
findUsers() {}

#15. main.ts に肥大化した bootstrap

症状

デプロイ環境ごとに分岐、テスト困難、bootstrap の関心が散在。

根本原因

bootstrap が Composition Root を超えて、ビジネス的な分岐を抱える。

// ❌
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  if (process.env.NODE_ENV === 'prod') {
    app.useGlobalPipes(new ValidationPipe({ /* ... */ }));
    app.useGlobalInterceptors(new SentryInterceptor());
    // ... 50 行
  } else {
    // ... 別の 50 行
  }

  // Swagger 設定 30 行
  // CORS 設定 20 行
  // ...
  await app.listen(/* ... */);
}

脱出法

// ✅ bootstrap helper / config に分離
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  configureApp(app); // ← 別ファイル
  configureSwagger(app);
  app.enableShutdownHooks();
  await app.listen(getPort());
}

// app.config.ts
export function configureApp(app: INestApplication) {
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  app.use(helmet(), compression());
  // ...
}

bootstrap はシンプルな orchestrationに保つ。

アンチパターン早見表

#名前系統効く原理
1Request scope 濫用DI/Scope1, 4
2Scope 伝染DI/Scope1, 4
3Logger を Singleton に注入DI/Scope1, 4
4forwardRef で隠蔽DI1
5Interceptor で DBLayer3
6Pipe で外部 APILayer3
7Global pipe で全 validationLayer3
8HttpException 絨毯爆撃Layer3
9Service 内 try-catchLayer3
10Singleton が REQUEST 依存DI/Scope1, 4
11DI で取れるものを newDI1
12Observable で sync 包む型/RxJS3
13DTO に変換ロジック3
14Global guard で重い権限Layer3, 4
15main.ts 肥大化Bootstrap1

レビュー時のチェックリスト

[DI / Scope 系]
□ Scope.REQUEST が「本当に必要か」確認したか
□ Singleton Service に REQUEST スコープが注入されていないか
□ forwardRef を使う前に共有モジュール抽出を検討したか
□ DI で取れるものを new していないか

[Layer 系]
□ Interceptor で DB / 外部 API を呼んでいないか
□ Pipe で副作用を発生させていないか
□ Service 内に try-catch を散らかしていないか
□ Global pipe / guard が重くないか

[型 / RxJS 系]
□ Observable で sync 処理を包んでいないか
□ DTO がビジネスロジックを持っていないか

[Logger / Bootstrap 系]
□ nestjs-pino + ALS を使っているか
□ main.ts が肥大化していないか
□ enableShutdownHooks を呼んでいるか

本章の要点

#要点
1アンチパターン15個は DI/Scope (5件) / Layer (6件) / 型/RxJS (2件) / Logger/Bootstrap (2件) の4系統
2DI/Scope 系の根は同じ:「Singleton で書ける処理を REQUEST 化」「DI を信頼しない」
3Layer 系の根は同じ:「層の責務を取り違える」(Interceptor で DB / Pipe で外部 API 等)
4エラーは throw に統一、Filter で一括処理。NestJS の哲学
5Pipe / Guard / Interceptor は横断関心事のみ。ビジネスロジックは Service に
6Logger / Context は Singleton + AsyncLocalStorage が 2026 のスタンダード
7bootstrap は Composition Root に徹する。ビジネス分岐を入れない

効いている根本原理

本章は4原理が実装に降りた章だった:

  • 原理1(DI コンテナを信頼する):#4 #11 #15
  • 原理3(リクエスト境界の層を使い分ける):#5 #6 #7 #8 #9 #12 #13 #14
  • 原理4(メモリ効率は DI scope × stream × logger):#1 #2 #3 #10

次の最終章で4原理を回収し、2026 構成パターン(LLM/Agent backend、MCP Server、CQRS、Modular Monolith)と v12 ロードマップを見て締めくくる。