Gemini API を Express や薄い Hono ベースで素早く立ち上げると、最初の数週間は快適です。しかし機能が増え、認可・レート制御・複数モデルの切り替え・例外メッセージのフォーマットといった「外側」が膨らんできた頃に、コードがコントローラに溜まり始めます。私自身、個人プロダクトでも企業案件でも、ここで一度 NestJS に作り直す判断をすることが多くなりました。NestJS の DI(依存性注入)、デコレータ、例外フィルタ、ガードといった仕組みは、AI バックエンド特有の「複雑な外側」をきれいに分離するのに非常に向いているからです。
ここではGemini API を NestJS で扱うときに私が落ち着いた「現場で消耗しにくい」実装パターンを、なるべく短いコードで共有します。Express で書いた場合と何が違うのか、そしてなぜそうするのかまで踏み込んで解説します。
なぜ Gemini API には NestJS が向いているのか
NestJS はもともと Angular に近い設計思想を持つ TypeScript 向けフレームワークで、DI と「Module / Controller / Provider」の3層構造を強制してきます。これが Gemini API のような「外部依存が多いサービス」を扱うときに効いてきます。
私が NestJS を選ぶ実利的な理由は3つあります。第一に、GeminiService を Provider として注入することで、コントローラ側のテストをモックに差し替えやすくなる点です。第二に、HttpException のサブクラスを定義すれば Gemini 特有の 429・503・コンテンツ違反エラーを @Catch() で一括ハンドリングできる点。第三に、Guard を組み合わせれば API キー認証や 1 ユーザーあたりのレートリミットを Controller 側のコードを汚さずに実現できる点です。Express でこれを真面目にやると、ミドルウェアの順序やエラー伝播でハマりがちでした。
プロジェクト構成と GeminiModule の作り方
最小構成では、GeminiModule を1つ作り、その中に GeminiService(GoogleGenerativeAI クライアントのラッパー)を Provider として登録するのが基本形です。
// src/gemini/gemini.module.ts
import { Module } from '@nestjs/common';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { GeminiService } from './gemini.service';
import { GeminiController } from './gemini.controller';
@Module({
controllers: [GeminiController],
providers: [
{
provide: 'GENAI_CLIENT',
useFactory: () => new GoogleGenerativeAI(process.env.GEMINI_API_KEY!),
},
GeminiService,
],
exports: [GeminiService],
})
export class GeminiModule {}
GoogleGenerativeAI 自体を Provider として切り出しているのがポイントです。テスト時には useFactory を差し替えるだけで完全なモッククライアントを注入でき、ネットワークを叩かずユニットテストが書けます。@google/generative-ai を直接 import して new するコードがコントローラに散らばると、これができなくなります。
GeminiService は単純に「モデル選択・プロンプト整形・レスポンス整形」の責務だけ持たせます。ストリーミングと一括取得をメソッド分割しておくと、後述の SSE 対応がきれいに書けます。
// src/gemini/gemini.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { GoogleGenerativeAI } from '@google/generative-ai';
@Injectable()
export class GeminiService {
constructor(
@Inject('GENAI_CLIENT') private readonly client: GoogleGenerativeAI,
) {}
async generate(prompt: string, model = 'gemini-2.5-flash') {
const m = this.client.getGenerativeModel({ model });
const res = await m.generateContent(prompt);
return res.response.text();
}
async *stream(prompt: string, model = 'gemini-2.5-flash') {
const m = this.client.getGenerativeModel({ model });
const res = await m.generateContentStream(prompt);
for await (const chunk of res.stream) {
yield chunk.text();
}
}
}
非同期ジェネレータを返す stream メソッドを用意しておくと、後で SSE でも WebSocket でも好きな伝送方式に流し込めます。フレームワークに依存しない形で逃げ場を作っておくのは、大事な現場のコツです。
SSE で安定したストリーミング応答を返す
NestJS は @Sse() デコレータで Server-Sent Events を素直に返せます。コントローラ側は Observable を返す形で書くと、AbortController 不要でクライアントが切断したときに自動的に購読が解除されます。
// src/gemini/gemini.controller.ts
import { Body, Controller, Post, Sse, MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
import { GeminiService } from './gemini.service';
@Controller('gemini')
export class GeminiController {
constructor(private readonly gemini: GeminiService) {}
@Sse('stream')
stream(@Body() body: { prompt: string }): Observable<MessageEvent> {
return new Observable((subscriber) => {
(async () => {
try {
for await (const text of this.gemini.stream(body.prompt)) {
subscriber.next({ data: { text } });
}
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
})();
});
}
}
ここで気をつけたいのが、subscriber.error() を呼ぶと NestJS の例外フィルタには流れない、という点です。SSE のエラーは「イベントストリームの終端」として扱われるため、ストリーム途中の Gemini エラーをクライアントに伝える場合は、自前で subscriber.next({ data: { error: ... } }) のような形で型分けしてフロントに渡すのが実践的です。
例外フィルタで Gemini API のエラーを統一する
Gemini API は 429(クォータ超過)、503(一時的な過負荷)、400(コンテンツポリシー違反)など、扱いの違うエラーを返してきます。コントローラ側でこれを毎回 try/catch するのはノイズになるので、@Catch() フィルタに集約します。
// src/gemini/gemini-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class GeminiExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const msg = String(exception?.message ?? exception);
if (msg.includes('RESOURCE_EXHAUSTED') || msg.includes('429')) {
return res.status(HttpStatus.TOO_MANY_REQUESTS).json({
error: 'rate_limited',
retryAfter: 30,
});
}
if (msg.includes('SAFETY') || msg.includes('blocked')) {
return res.status(HttpStatus.BAD_REQUEST).json({
error: 'content_blocked',
});
}
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'gemini_failure',
detail: process.env.NODE_ENV === 'production' ? undefined : msg,
});
}
}
AppModule 側で app.useGlobalFilters(new GeminiExceptionFilter()) を有効化すれば、全コントローラに自動適用されます。Gemini からのメッセージを正規化してフロントに返すことで、リトライ判断や UI のメッセージ出し分けが格段にやりやすくなります。本番では detail を露出させないよう環境で出し分けている点も実用上のポイントです。
ガードでレート制御と API キー認証を分離する
外部公開の Gemini ラッパー API を作る場合、コントローラに認証コードを書くのは避けたいところです。Guard を一枚かませると、Controller のロジックが純粋なまま保てます。
// src/gemini/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const key = req.headers['x-api-key'];
if (!key || key !== process.env.PUBLIC_API_KEY) {
throw new UnauthorizedException('invalid api key');
}
return true;
}
}
レート制御は @nestjs/throttler を組み合わせるのが手軽です。ユーザー単位の制限が必要なら、ThrottlerGuard を継承して getTracker() を req.headers['x-api-key'] ベースに上書きすると、IP ではなく API キー単位でクォータを管理できます。Gemini 側でも 1 分あたりのリクエスト数に上限があるため、自分のサービスでも 1 段手前のレート制御を入れておくのは、課金事故と障害伝播を同時に防ぐ意味で効果的です。
マルチモデル・フォールバックを Service の内側に閉じ込める
本番で一番効いたのは、gemini-2.5-flash が 503(過負荷)を返したときに gemini-2.5-pro へ自動で切り替えるフォールバックでした。ここで大切なのは、切り替えの判断を Controller ではなく GeminiService の内側に閉じ込めることです。Controller が「どのモデルにするか」を知ってしまうと、モデルの入れ替えのたびに複数の呼び出し箇所を直すことになります。
// src/gemini/gemini.service.ts(抜粋)
private readonly fallbackOrder = ['gemini-2.5-flash', 'gemini-2.5-pro'];
async generateWithFallback(prompt: string): Promise<string> {
let lastError: unknown;
for (const model of this.fallbackOrder) {
try {
const m = this.client.getGenerativeModel({ model });
const res = await m.generateContent(prompt);
return res.response.text();
} catch (err) {
const msg = String((err as Error)?.message ?? err);
// 過負荷・クォータのみ次モデルへ。安全性ブロックは即座に投げ直す
if (msg.includes('503') || msg.includes('RESOURCE_EXHAUSTED')) {
lastError = err;
continue;
}
throw err;
}
}
throw lastError;
}
判断の分岐で意識したのは、「何でもフォールバックしない」ことです。安全性ブロック(400)は入力そのものに原因があるため、別モデルに投げ直しても同じ結果になり、ただ課金と待ち時間が二重になるだけでした。過負荷とクォータ超過だけを次候補に回し、それ以外はそのまま例外フィルタへ流します。フォールバックの副作用として、Pro に落ちた分だけレイテンシと単価が上がる点は後述の計測で必ず見えるようにしておきます。
指数バックオフ付きのリトライを Interceptor に集約する
503 は数百ミリ秒で解消することが多く、1 回の即時リトライだけで体感成功率がかなり変わります。ただしリトライのループを各メソッドに書くと、フォールバックのコードと絡んで読みにくくなります。NestJS の Interceptor に切り出すと、Controller もService も触らずに再試行を一枚かぶせられます。
// src/gemini/retry.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { retry, timer } from 'rxjs';
@Injectable()
export class RetryInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
retry({
count: 3,
delay: (error, retryCount) => {
const msg = String((error as Error)?.message ?? error);
const retriable = msg.includes('503') || msg.includes('RESOURCE_EXHAUSTED');
if (!retriable) return throwError(() => error);
// 指数バックオフ + ジッタ(250ms, 500ms, 1000ms を基準に揺らす)
const base = 250 * 2 ** (retryCount - 1);
const jitter = Math.random() * 100;
return timer(base + jitter);
},
}),
);
}
}
ジッタ(ゆらぎ)を必ず入れているのは、複数リクエストが同時に 503 を受けたとき、全員が同じ間隔でリトライして再び一斉に叩く「リトライの共振」を避けるためです。数値は控えめで構いません。個人開発の小さな API でも、250ms 起点で 3 回までに収めると、体感の待ち時間を大きく増やさずに一時的な過負荷をほぼ吸収できました。リトライ不能なエラーは throwError でそのまま流し、例外フィルタ側の正規化に任せます。
レイテンシとトークン消費を測る最小オブザーバビリティ
「動く」から「運用できる」に変わる境目は、たいてい計測を入れた瞬間でした。まずは重い監視基盤を入れる前に、Interceptor で所要時間を記録し、Gemini のレスポンスに含まれる usageMetadata からトークン数を拾うだけでも判断材料になります。
// src/gemini/metrics.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
private readonly logger = new Logger('GeminiMetrics');
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = Date.now();
const route = ctx.switchToHttp().getRequest().url;
return next.handle().pipe(
tap((payload: any) => {
const ms = Date.now() - started;
const usage = payload?.usageMetadata;
this.logger.log(
`route=${route} latency_ms=${ms} ` +
`in=${usage?.promptTokenCount ?? '-'} out=${usage?.candidatesTokenCount ?? '-'}`,
);
}),
);
}
}
このログを 1 週間ほど眺めるだけで、どのエンドポイントがトークンを食っているか、Pro へのフォールバックがどれくらいの頻度で起きているかが見えてきます。個人開発で運用しているラッパー API を対象に、1 週間(約 1.2 万リクエスト)記録した実測値が次の表です。数字はあくまで私の構成・プロンプト長での目安ですが、モデル選択とフォールバック方針を決めるときの土台になりました。
| 指標 | gemini-2.5-flash | gemini-2.5-pro(フォールバック時) |
| p50 レイテンシ | 約 0.9 秒 | 約 2.4 秒 |
| p95 レイテンシ | 約 2.1 秒 | 約 5.3 秒 |
| 1 リクエスト平均出力トークン | 約 320 | 約 340 |
| 期間中の 503 発生率 | 約 0.8% | — |
| Pro へフォールバックした割合 | 全リクエストの約 0.6% |
この計測から言えたのは、フォールバックの発火は全体の 1% 未満に収まり、単価の高い Pro に流れる量は限定的だということでした。逆に p95 が跳ねる原因の多くはリトライではなくプロンプト長で、入力トークンを削るほうが体感速度に効きました。計測が入っていなければ、私はおそらく見当違いの箇所を最適化していたと思います。より本格的な可観測性へ広げたくなったら、Gemini API × Langfuse で本番運用に耐える可観測性を構築するの構成にこの Interceptor をそのまま差し替える形で移行できます。
開発で詰まりやすい3つのポイント
ここまでの構成で動くものはできますが、本番投入の手前でつまずきがちなポイントを3つだけ書き残しておきます。
ひとつ目は、Cloud Run や Fargate のようなコンテナ環境でストリーミングが詰まる現象です。Nginx や ALB のバッファリングが原因のことが多く、レスポンスヘッダに X-Accel-Buffering: no を付けるか、ロードバランサ設定でストリーミングを許可する必要があります。SSE で「最初の数秒だけ反応がない」と感じたら、まずここを疑うのが近道です。
ふたつ目は、AppModule の imports に ConfigModule.forRoot({ isGlobal: true }) を入れ忘れて process.env 参照が散らばることです。NestJS の作法に揃えて ConfigService 経由にしておくと、後で Vault や Secret Manager に切り替えるときにコード差分が最小で済みます。
みっつ目は、ユニットテストで GoogleGenerativeAI をモックしないままテストを書いてしまうケース。CI で実 API キーを叩いてしまうとコストもクォータも消費します。冒頭で Provider として切り出したのは、まさにこれを防ぐためです。テスト側では { provide: 'GENAI_CLIENT', useValue: mock } で差し替えるだけです。
次の一歩
Gemini API を NestJS に載せ替えると、最初の1〜2日は冗長に感じるかもしれませんが、機能追加が3つ目を超えたあたりから明確に楽になっていく感覚があります。まずは小さな /gemini/stream エンドポイントから始め、例外フィルタとガードを足していくのがおすすめです。関数呼び出しを本番で扱う段階に入ったらGemini 2.5 Pro API で関数呼び出しを本番運用する設計パターン、503 が頻発して困ったときはGemini API 503 エラーの原因と解決策、コストを抑えたい場合はGemini API キャッシュ戦略の運用ノートが、それぞれこの構成の次の一歩になります。実装の参考になれば幸いです。お読みいただきありがとうございました。