チームの規模が 3 人を超えたあたりで、Claude API との連携コードが保守しにくくなってきたことはないだろうか。Express でシンプルに書き始めた /chat エンドポイントが、気づけば認証ロジック・会話履歴管理・ストリーミング処理・レート制限が混在した 1,000 行のファイルになっている——そういうケースを何度も見てきた。
NestJS は、そのカオスを整理するために設計されたフレームワークです。依存性注入(DI)・モジュールシステム・デコレーターベースの設計によって、Claude API との統合コードも「どこに何があるか」が自然に整理されます。ここで扱うのは実際のエンタープライズプロジェクトで使える設計パターンを、動くコードとともに紹介します。
なぜ NestJS なのか——Express・Hono との比較
Express や Hono は軽量で立ち上げが速い。小〜中規模の API や、エッジで動く軽量サービスには今でも最適な選択です。しかし、エンタープライズスケールでは以下の課題が出てくる。
認証・ロギング・バリデーションの横断的関心事の管理 : Express では middleware をどこに書くかが暗黙のルールになりがちで、新メンバーが迷いやすい。NestJS の Guards・Interceptors・Pipes は役割が明示的に分かれており、コードレビューの指摘ポイントが絞れます。
Claude API クライアントのインスタンス管理 : new Anthropic() を各ファイルで作ると設定変更やモックが困難になります。NestJS の DI コンテナに登録しておけば、テストでも本番でも同じインターフェースでアクセスできます。
スケーラビリティ : Bull キュー・WebSocket ゲートウェイ・gRPC サービスを後から追加するとき、NestJS のモジュール設計なら既存コードへの影響を最小化しながら拡張できます。
フレームワーク選定の判断軸
どのフレームワークを選ぶべきか迷ったとき、私が使っている判断軸を共有します。
チームが 5 人以上 → NestJS(規約の統一が生産性向上に直結)
エッジ・超軽量 API・マイクロサービスの 1 サービス → Hono
Python エコシステム・機械学習パイプラインとの統合 → FastAPI
プロトタイピング・小規模 SaaS → Express
NestJS が「オーバーエンジニアリング」と感じられる段階はあります。だが、チームが一定規模を超えると、規約のないコードベースを維持するコストが急激に上がる。その転換点が来る前に NestJS に移行しておくのが、経験上ベターな選択だった。
プロジェクト設計——DDDライクなモジュール構成
src/
├── app.module.ts
├── main.ts
├── config/
│ └── anthropic.config.ts
├── ai/
│ ├── ai.module.ts
│ ├── ai.service.ts
│ ├── ai.controller.ts
│ └── dto/
│ ├── chat.dto.ts
│ └── stream-chat.dto.ts
├── conversation/
│ ├── conversation.module.ts
│ ├── conversation.service.ts
│ ├── conversation.repository.ts
│ └── entities/
│ ├── conversation.entity.ts
│ └── message.entity.ts
├── auth/
│ ├── auth.module.ts
│ ├── auth.guard.ts
│ └── current-user.decorator.ts
└── health/
└── health.controller.ts
このディレクトリ構成が持つ重要な設計原則は「依存の方向」です。ai/ モジュールは conversation/ に依存するが、その逆は許可しません。Claude API の呼び出しロジックを ai.service.ts に閉じ込めることで、将来モデルを変更したり別の AI プロバイダーに切り替えたりするときの変更範囲が明確になります。
プロジェクトのセットアップは以下で行います。
npm i -g @nestjs/cli
nest new claude-enterprise-api
cd claude-enterprise-api
npm install @anthropic-ai/sdk @nestjs/config @nestjs/typeorm typeorm pg
npm install @nestjs/bull bull @nestjs/jwt @nestjs/throttler
npm install @nestjs/terminus # HealthCheck 用
npm install -D @types/bull
Anthropic SDK を DI コンテナに組み込む
NestJS の DI システムに Anthropic クライアントを登録します。ANTHROPIC_CLIENT という注入トークンを定義することで、テストでモック差し替えが可能になります。
// src/config/anthropic.config.ts
import { registerAs } from '@nestjs/config' ;
export default registerAs ( 'anthropic' , () => ({
apiKey: process.env. ANTHROPIC_API_KEY ,
defaultModel: process.env. ANTHROPIC_DEFAULT_MODEL || 'claude-sonnet-4-6' ,
maxTokens: parseInt (process.env. ANTHROPIC_MAX_TOKENS || '4096' , 10 ) ,
})) ;
// src/ai/ai.module.ts
import { Module } from '@nestjs/common' ;
import { ConfigModule, ConfigService } from '@nestjs/config' ;
import Anthropic from '@anthropic-ai/sdk' ;
import { AiService } from './ai.service' ;
import { AiController } from './ai.controller' ;
export const ANTHROPIC_CLIENT = 'ANTHROPIC_CLIENT' ;
@ Module ({
imports: [ConfigModule],
providers: [
{
provide: ANTHROPIC_CLIENT ,
inject: [ConfigService],
useFactory : ( config : ConfigService ) => {
const apiKey = config. get < string >( 'anthropic.apiKey' );
if (\ ! apiKey) {
// 起動時にフェイルファスト:設定ミスを本番稼働前に検出
throw new Error ( 'ANTHROPIC_API_KEY is not configured' );
}
return new Anthropic ({ apiKey });
},
},
AiService,
],
controllers: [AiController],
exports: [ ANTHROPIC_CLIENT , AiService],
})
export class AiModule {}
AiService の完全実装
// src/ai/ai.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common' ;
import Anthropic from '@anthropic-ai/sdk' ;
import { ConfigService } from '@nestjs/config' ;
import { Observable, Subject } from 'rxjs' ;
import { ANTHROPIC_CLIENT } from './ai.module' ;
export interface StreamChunk {
type : 'delta' | 'done' | 'error' ;
content ?: string ;
error ?: string ;
}
@ Injectable ()
export class AiService {
private readonly logger = new Logger (AiService.name);
constructor (
@ Inject ( ANTHROPIC_CLIENT ) private readonly client : Anthropic ,
private readonly config : ConfigService ,
) {}
async chat (
messages : Anthropic . MessageParam [],
systemPrompt ?: string ,
) : Promise < string > {
const model = this .config. get < string >( 'anthropic.defaultModel' );
const maxTokens = this .config. get < number >( 'anthropic.maxTokens' );
try {
const response = await this .client.messages. create ({
model,
max_tokens: maxTokens,
... (systemPrompt ? { system: systemPrompt } : {}),
messages,
});
const content = response.content[ 0 ];
if (content.type \ !== 'text' ) {
throw new Error ( `Unexpected content type: ${ content . type }` );
}
return content.text;
} catch (error) {
this .logger. error ( 'Claude API call failed' , { error });
throw error;
}
}
streamChat (
messages : Anthropic . MessageParam [],
systemPrompt ?: string ,
) : Observable < StreamChunk > {
const subject = new Subject < StreamChunk >();
const model = this .config. get < string >( 'anthropic.defaultModel' );
const maxTokens = this .config. get < number >( 'anthropic.maxTokens' );
( async () => {
try {
const stream = await this .client.messages. stream ({
model,
max_tokens: maxTokens,
... (systemPrompt ? { system: systemPrompt } : {}),
messages,
});
for await ( const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
subject. next ({ type: 'delta' , content: event.delta.text });
} else if (event.type === 'message_stop' ) {
subject. next ({ type: 'done' });
subject. complete ();
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error' ;
subject. next ({ type: 'error' , error: message });
subject. error (error);
}
})();
return subject. asObservable ();
}
}
streamChat が Observable<StreamChunk> を返す設計にしているのは、NestJS の @Sse() デコレーターが Observable をそのまま扱えるからです。Subject を使った非同期→Observable の橋渡しパターンは、Claude API のストリーミングを RxJS のエコシステムに統合する際に最も安定しています。
TypeORM + PostgreSQL で会話履歴を永続化する
セッションをまたいだコンテキストの継続やユーザーごとの利用量管理のために、会話履歴を DB に永続化します。
// src/conversation/entities/conversation.entity.ts
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, OneToMany,
} from 'typeorm' ;
import { Message } from './message.entity' ;
@ Entity ( 'conversations' )
export class Conversation {
@ PrimaryGeneratedColumn ( 'uuid' )
id : string ;
@ Column ()
userId : string ;
@ Column ({ nullable: true })
title : string ;
@ Column ({ default: 'claude-sonnet-4-6' })
model : string ;
@ Column ({ nullable: true , type: 'text' })
systemPrompt : string ;
@ OneToMany (() => Message, ( message ) => message.conversation, {
cascade: true ,
})
messages : Message [];
@ CreateDateColumn ()
createdAt : Date ;
@ UpdateDateColumn ()
updatedAt : Date ;
}
// src/conversation/entities/message.entity.ts
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, ManyToOne, JoinColumn,
} from 'typeorm' ;
import { Conversation } from './conversation.entity' ;
@ Entity ( 'messages' )
export class Message {
@ PrimaryGeneratedColumn ( 'uuid' )
id : string ;
@ ManyToOne (() => Conversation, ( conv ) => conv.messages, {
onDelete: 'CASCADE' ,
})
@ JoinColumn ({ name: 'conversation_id' })
conversation : Conversation ;
@ Column ()
conversationId : string ;
@ Column ({ type: 'enum' , enum: [ 'user' , 'assistant' ] })
role : 'user' | 'assistant' ;
@ Column ({ type: 'text' })
content : string ;
// コスト監視用:APIレスポンスのトークン数を記録
@ Column ({ nullable: true , type: 'int' })
inputTokens : number ;
@ Column ({ nullable: true , type: 'int' })
outputTokens : number ;
@ CreateDateColumn ()
createdAt : Date ;
}
// src/conversation/conversation.service.ts
import { Injectable, NotFoundException } from '@nestjs/common' ;
import { InjectRepository } from '@nestjs/typeorm' ;
import { Repository } from 'typeorm' ;
import Anthropic from '@anthropic-ai/sdk' ;
import { Conversation } from './entities/conversation.entity' ;
import { Message } from './entities/message.entity' ;
@ Injectable ()
export class ConversationService {
constructor (
@ InjectRepository (Conversation)
private conversationRepo : Repository < Conversation >,
@ InjectRepository (Message)
private messageRepo : Repository < Message >,
) {}
async createConversation (
userId : string ,
systemPrompt ?: string ,
) : Promise < Conversation > {
const conversation = this .conversationRepo. create ({ userId, systemPrompt });
return this .conversationRepo. save (conversation);
}
async getConversationWithMessages (
id : string ,
userId : string ,
) : Promise < Conversation > {
const conv = await this .conversationRepo. findOne ({
where: { id, userId },
relations: [ 'messages' ],
order: { messages: { createdAt: 'ASC' } },
});
if (\ ! conv) throw new NotFoundException ( `Conversation ${ id } not found` );
return conv;
}
// DB の Message を Claude API の MessageParam に変換する
toAnthropicMessages ( messages : Message []) : Anthropic . MessageParam [] {
return messages. map (( m ) => ({
role: m.role,
content: m.content,
}));
}
async appendMessage (
conversationId : string ,
role : 'user' | 'assistant' ,
content : string ,
tokenUsage ?: { input ?: number ; output ?: number },
) : Promise < Message > {
const message = this .messageRepo. create ({
conversationId,
role,
content,
inputTokens: tokenUsage?.input,
outputTokens: tokenUsage?.output,
});
return this .messageRepo. save (message);
}
// コスト集計:ユーザーの月間トークン使用量
async getMonthlyTokenUsage ( userId : string ) : Promise < number > {
const startOfMonth = new Date ();
startOfMonth. setDate ( 1 );
startOfMonth. setHours ( 0 , 0 , 0 , 0 );
const result = await this .messageRepo
. createQueryBuilder ( 'm' )
. innerJoin ( 'm.conversation' , 'c' )
. select ( 'SUM(m.input_tokens + m.output_tokens)' , 'total' )
. where ( 'c.user_id = :userId' , { userId })
. andWhere ( 'm.created_at >= :start' , { start: startOfMonth })
. getRawOne <{ total : string }>();
return parseInt (result?.total || '0' , 10 );
}
}
N+1 問題を避けるために relations: ['messages'] でリレーションを一括ロードしています。メッセージが 1,000 件を超えるような長期会話では、createQueryBuilder で最新 50 件のみ取得するよう切り替えること。
// メッセージが多い会話への対応:最新50件のみ取得
async getRecentMessages (
conversationId: string,
limit = 50 ,
): Promise < Message[] > {
const messages = await this .messageRepo
.createQueryBuilder( 'm' )
.where( 'm.conversationId = :id' , { id : conversationId })
.orderBy( 'm.createdAt' , 'DESC' )
.take(limit)
.getMany();
return messages.reverse(); // 時系列順に並び替え
}
SSE ストリーミングを NestJS コントローラーで実装する
NestJS では @Sse() デコレーターと Observable を組み合わせることで、SSE エンドポイントをシンプルに実装できます。
// src/ai/dto/stream-chat.dto.ts
import { IsString, IsOptional, IsUUID } from 'class-validator' ;
export class StreamChatDto {
@ IsString ()
message : string ;
@ IsOptional ()
@ IsString ()
systemPrompt ?: string ;
@ IsOptional ()
@ IsUUID ()
conversationId ?: string ;
}
// src/ai/ai.controller.ts
import {
Controller, Post, Body, Sse,
UseGuards, Req, HttpCode,
} from '@nestjs/common' ;
import { Observable, map, catchError, of } from 'rxjs' ;
import { MessageEvent } from '@nestjs/common' ;
import { Throttle } from '@nestjs/throttler' ;
import { JwtAuthGuard } from '../auth/auth.guard' ;
import { AiService, StreamChunk } from './ai.service' ;
import { StreamChatDto } from './dto/stream-chat.dto' ;
@ Controller ( 'ai' )
@ UseGuards (JwtAuthGuard)
export class AiController {
constructor ( private readonly aiService : AiService ) {}
@ Sse ( 'stream' )
@ Post ()
@ HttpCode ( 200 )
@ Throttle ({ default: { limit: 10 , ttl: 60000 } })
streamChat (
@ Body () dto : StreamChatDto ,
@ Req () req : { user : { id : string } },
) : Observable < MessageEvent > {
const source$ = this .aiService. streamChat (
[{ role: 'user' , content: dto.message }],
dto.systemPrompt,
);
// クライアント切断時のクリーンアップ
return new Observable < StreamChunk >(( observer ) => {
const sub = source$. subscribe (observer);
req[ 'socket' ]?. on ( 'close' , () => {
sub. unsubscribe ();
});
return () => sub. unsubscribe ();
}). pipe (
map (( chunk ) : MessageEvent => ({
data: JSON . stringify (
chunk.type === 'delta'
? { delta: chunk.content }
: chunk.type === 'done'
? { done: true }
: { error: chunk.error },
),
})),
catchError (( error ) =>
of ({
data: JSON . stringify ({
error: 'Stream interrupted' ,
message: error instanceof Error ? error.message : 'Unknown error' ,
}),
}),
),
);
}
}
@Sse() と @Post() を組み合わせる点が直感に反するかもしれません。SSE はプロトコルとして GET でも POST でも成立するが、リクエストボディを送る必要がある場合は POST を使います。NestJS ではこの組み合わせが公式にサポートされています。
クライアント側からは以下のように接続します。
// フロントエンド側の接続例(fetch + ReadableStream)
async function streamChat ( message ) {
const response = await fetch ( '/ai/stream' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'Authorization' : `Bearer ${ token }` ,
},
body: JSON . stringify ({ message }),
});
const reader = response.body. getReader ();
const decoder = new TextDecoder ();
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
const text = decoder. decode (value);
// SSE フォーマット(data: {...}
)をパース
const lines = text. split ( '
' ). filter ( line => line. startsWith ( 'data: ' ));
for ( const line of lines) {
const data = JSON . parse (line. slice ( 6 ));
if (data.delta) process.stdout. write (data.delta);
if (data.done) console. log ( '
[完了]' );
}
}
}
JWT 認証 + スロットリングの設計
// src/auth/auth.guard.ts
import {
Injectable, CanActivate, ExecutionContext, UnauthorizedException,
} from '@nestjs/common' ;
import { JwtService } from '@nestjs/jwt' ;
import { Request } from 'express' ;
@ Injectable ()
export class JwtAuthGuard implements CanActivate {
constructor ( private jwtService : JwtService ) {}
async canActivate ( context : ExecutionContext ) : Promise < boolean > {
const request = context. switchToHttp (). getRequest < Request >();
const token = this . extractToken (request);
if (\ ! token) {
throw new UnauthorizedException ( 'No token provided' );
}
try {
const payload = await this .jwtService. verifyAsync <{ sub : string ; email : string }>(
token,
{ secret: process.env. JWT_SECRET },
);
// req.user に情報を付加(後続の Guard や Controller で利用)
request[ 'user' ] = { id: payload.sub, email: payload.email };
return true ;
} catch {
throw new UnauthorizedException ( 'Invalid or expired token' );
}
}
private extractToken ( request : Request ) : string | undefined {
const [ type , token ] = request.headers.authorization?. split ( ' ' ) ?? [];
return type === 'Bearer' ? token : undefined ;
}
}
@Throttle デコレーターはコントローラーレベルとエンドポイントレベルで個別に設定できます。AI 系エンドポイントはトークンコストが発生するため、通常の CRUD エンドポイントより厳しく制限するのが設計のポイントです。
// app.module.ts に ThrottlerModule を追加
import { ThrottlerModule } from '@nestjs/throttler' ;
@ Module ({
imports: [
ThrottlerModule. forRoot ([
{ name: 'short' , ttl: 1000 , limit: 3 }, // 1秒間に3リクエスト
{ name: 'medium' , ttl: 10000 , limit: 20 }, // 10秒間に20リクエスト
{ name: 'long' , ttl: 60000 , limit: 100 }, // 1分間に100リクエスト
]),
// ...
],
})
export class AppModule {}
Bull + Redis による非同期 AI タスク処理
バッチ処理・大量ドキュメントの要約・長時間かかる分析タスクは、同期 API で処理するとタイムアウトやクライアント接続の問題が起きます。Bull キューで非同期化します。
// src/ai/ai-queue.processor.ts
import { Process, Processor } from '@nestjs/bull' ;
import { Logger } from '@nestjs/common' ;
import { Job } from 'bull' ;
import { AiService } from './ai.service' ;
import { ConversationService } from '../conversation/conversation.service' ;
export interface AiJobData {
conversationId : string ;
userId : string ;
userMessage : string ;
systemPrompt ?: string ;
}
@ Processor ( 'ai-tasks' )
export class AiQueueProcessor {
private readonly logger = new Logger (AiQueueProcessor.name);
constructor (
private readonly aiService : AiService ,
private readonly conversationService : ConversationService ,
) {}
@ Process ( 'chat-completion' )
async handleChatCompletion ( job : Job < AiJobData >) : Promise < string > {
const { conversationId , userId , userMessage , systemPrompt } = job.data;
this .logger. log ( `Processing job ${ job . id } for conversation ${ conversationId }` );
try {
// 既存の会話履歴を取得
const conversation = await this .conversationService
. getConversationWithMessages (conversationId, userId);
const messages = this .conversationService
. toAnthropicMessages (conversation.messages);
messages. push ({ role: 'user' , content: userMessage });
// Claude API 呼び出し
const response = await this .aiService. chat (messages, systemPrompt);
// 結果を保存
await this .conversationService. appendMessage (
conversationId, 'user' , userMessage,
);
await this .conversationService. appendMessage (
conversationId, 'assistant' , response,
);
await job. progress ( 100 );
return response;
} catch (error) {
this .logger. error ( `Job ${ job . id } failed` , error);
throw error; // Bull が自動リトライ(最大3回)
}
}
}
Bull のデフォルトリトライ設定は 3 回で、指数バックオフが適用されます。Claude API のレート制限エラー(429)に対しては { type: 'exponential', delay: 2000 } の設定が実用的です。ジョブキューに追加するときは以下のように行う。
// キューへのジョブ追加例
@ InjectQueue ( 'ai-tasks' ) private aiQueue : Queue
await this .aiQueue. add ( 'chat-completion' , jobData, {
attempts: 3 ,
backoff: { type: 'exponential' , delay: 2000 },
removeOnComplete: 100 , // 完了済みジョブを最新100件保持
removeOnFail: 50 , // 失敗ジョブを最新50件保持
});
よくある落とし穴とトラブルシューティング
実際に本番運用して詰まったポイントを 5 つ紹介します。
① DI スコープと Anthropic クライアントの寿命
ANTHROPIC_CLIENT を REQUEST スコープで提供すると、リクエストのたびに新しいクライアントインスタンスが作られてコネクションプールが無駄になります。シングルトンスコープ(デフォルト)で登録すること。
// ❌ 悪い例: リクエストごとにインスタンスを作成
{ provide : ANTHROPIC_CLIENT , scope : Scope. REQUEST , useFactory : ... }
// ✅ 正しい例: アプリ起動時に1回だけ作成
{ provide : ANTHROPIC_CLIENT , useFactory : ... } // デフォルトがシングルトン
② Subject のメモリリーク
クライアントが接続を切断した場合、Subject が完了しないままになる可能性があります。コントローラーで socket.on('close', ...) を監視してサブスクリプションを確実にクリーンアップすること(前述の AiController 実装で対応済み)。
③ TypeORM の接続プール枯渇
デフォルトのコネクションプール上限(10)は、Bull ワーカーが並列実行されると枯渇する可能性があります。TypeOrmModule.forRoot に extra: { max: 30 } を追加して上限を引き上げること。
TypeOrmModule. forRoot ({
type: 'postgres' ,
url: process.env. DATABASE_URL ,
entities: [Conversation, Message],
synchronize: process.env. NODE_ENV \ !== 'production' ,
extra: { max: 30 , connectionTimeoutMillis: 5000 },
})
④ NestJS のタイムアウトとストリーミングの相性
デフォルトの keepAliveTimeout(5 秒)は長時間のストリーミングで問題になります。main.ts で延長すること。
// src/main.ts
const app = await NestFactory. create (AppModule);
const server = app. getHttpServer ();
server.keepAliveTimeout = 120 * 1000 ; // 120秒
server.headersTimeout = 125 * 1000 ; // keepAliveTimeout より少し長く
await app. listen ( 3000 );
⑤ ConfigService の型安全性
config.get<string>('anthropic.apiKey') が undefined を返す可能性を TypeScript コンパイラが許容してしまう。起動時チェックを useFactory 内で行うことに加えて、ConfigService のジェネリクスを活用する方法もあります。
// 型安全な設定アクセス
const apiKey = this .config. getOrThrow < string >( 'anthropic.apiKey' );
// getOrThrow は値が undefined のとき例外を投げる(NestJS 9.4+)
Docker Compose による本番デプロイ構成
# docker-compose.yml
services :
api :
build : .
ports :
- "3000:3000"
environment :
NODE_ENV : production
ANTHROPIC_API_KEY : ${ANTHROPIC_API_KEY}
DATABASE_URL : postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/claude_api
REDIS_URL : redis://redis:6379
JWT_SECRET : ${JWT_SECRET}
depends_on :
postgres :
condition : service_healthy
redis :
condition : service_healthy
healthcheck :
test : [ "CMD" , "curl" , "-f" , "http://localhost:3000/health" ]
interval : 30s
timeout : 10s
retries : 3
start_period : 40s
postgres :
image : postgres:16-alpine
environment :
POSTGRES_DB : claude_api
POSTGRES_PASSWORD : ${POSTGRES_PASSWORD}
volumes :
- postgres_data:/var/lib/postgresql/data
healthcheck :
test : [ "CMD-SHELL" , "pg_isready -U postgres" ]
interval : 10s
timeout : 5s
retries : 5
redis :
image : redis:7-alpine
command : redis-server --appendonly yes
volumes :
- redis_data:/data
healthcheck :
test : [ "CMD" , "redis-cli" , "ping" ]
interval : 10s
timeout : 3s
retries : 5
volumes :
postgres_data :
redis_data :
depends_on の condition: service_healthy を設定することで、PostgreSQL が準備完了する前に API コンテナが起動しようとするレースコンディションを防ぎます。本番環境でこの設定を省略すると、デプロイ直後に DB 接続エラーが散発する原因になります。
HealthCheck エンドポイントも実装しておく。
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common' ;
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus' ;
import { InjectConnection } from '@nestjs/typeorm' ;
import { Connection } from 'typeorm' ;
@ Controller ( 'health' )
export class HealthController {
constructor (
private health : HealthCheckService ,
private db : TypeOrmHealthIndicator ,
@ InjectConnection () private connection : Connection ,
) {}
@ Get ()
@ HealthCheck ()
check () {
return this .health. check ([
() => this .db. pingCheck ( 'database' ),
]);
}
}
コスト最適化と監視
本番運用では API 利用コストの監視が不可欠です。Message エンティティにトークン数を持たせているのはそのためで、ユーザーごとの月間使用量を集計して予算アラートを出す仕組みを追加できます。
Claude API のコスト最適化パターン で紹介しているプロンプトキャッシュと組み合わせると、システムプロンプトが固定の場合に 50〜80% のコスト削減が見込める。
NestJS の Interceptor を使ってすべての AI エンドポイントのレイテンシとトークン使用量をロギングする仕組みを追加しておくと、異常なコスト増加を早期に検知できます。
// src/ai/ai-logging.interceptor.ts
import {
Injectable, NestInterceptor, ExecutionContext,
CallHandler, Logger,
} from '@nestjs/common' ;
import { Observable, tap } from 'rxjs' ;
@ Injectable ()
export class AiLoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger ( 'AiMetrics' );
intercept ( context : ExecutionContext , next : CallHandler ) : Observable < unknown > {
const start = Date. now ();
const request = context. switchToHttp (). getRequest ();
const userId = request.user?.id;
return next. handle (). pipe (
tap (() => {
const duration = Date. now () - start;
this .logger. log ( `AI request completed` , {
userId,
path: request.path,
durationMs: duration,
});
}),
);
}
}
次のアクションとしては、src/health/health.controller.ts に Redis の ping チェックを追加し、外形監視ツール(UptimeRobot など)から定期的に叩く設定をすることです。それだけで障害の初動対応が格段に速くなります。
NestJS の規約とモジュール設計は、最初は「書くべきボイラープレートが多い」と感じるかもしれません。しかし、チームが 5 人を超えた瞬間に、その規約が「何も言わなくてもコードの置き場所が分かる」という形で効いてくる。Claude API と組み合わせることで、エンタープライズグレードの AI バックエンドが現実的なコストとスケジュールで構築できます。