第9章: アンチパターン 15 ─ 症状 → 根本原因 → 脱出法
第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に保つ。
アンチパターン早見表
| # | 名前 | 系統 | 効く原理 |
|---|---|---|---|
| 1 | Request scope 濫用 | DI/Scope | 1, 4 |
| 2 | Scope 伝染 | DI/Scope | 1, 4 |
| 3 | Logger を Singleton に注入 | DI/Scope | 1, 4 |
| 4 | forwardRef で隠蔽 | DI | 1 |
| 5 | Interceptor で DB | Layer | 3 |
| 6 | Pipe で外部 API | Layer | 3 |
| 7 | Global pipe で全 validation | Layer | 3 |
| 8 | HttpException 絨毯爆撃 | Layer | 3 |
| 9 | Service 内 try-catch | Layer | 3 |
| 10 | Singleton が REQUEST 依存 | DI/Scope | 1, 4 |
| 11 | DI で取れるものを new | DI | 1 |
| 12 | Observable で sync 包む | 型/RxJS | 3 |
| 13 | DTO に変換ロジック | 型 | 3 |
| 14 | Global guard で重い権限 | Layer | 3, 4 |
| 15 | main.ts 肥大化 | Bootstrap | 1 |
レビュー時のチェックリスト
[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系統 |
| 2 | DI/Scope 系の根は同じ:「Singleton で書ける処理を REQUEST 化」「DI を信頼しない」 |
| 3 | Layer 系の根は同じ:「層の責務を取り違える」(Interceptor で DB / Pipe で外部 API 等) |
| 4 | エラーは throw に統一、Filter で一括処理。NestJS の哲学 |
| 5 | Pipe / Guard / Interceptor は横断関心事のみ。ビジネスロジックは Service に |
| 6 | Logger / Context は Singleton + AsyncLocalStorage が 2026 のスタンダード |
| 7 | bootstrap は 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 ロードマップを見て締めくくる。