Node.js BFF 設計の最適解:Route Handlers/GraphQL/tRPC の責務分割
フロントエンドとバックエンドの間に立つ BFF(Backend For Frontend)は、現代の Web アプリケーション開発において重要な役割を果たしています。しかし、Route Handlers、GraphQL、tRPC といった複数の選択肢がある中で、どのように責務を分割し、適切に使い分けるべきでしょうか。
本記事では、Node.js における BFF 設計の最適解を探求し、それぞれの技術の特徴と使い分けのポイントを詳しく解説します。実際のコード例を交えながら、プロジェクトに最適な設計パターンを見つけるお手伝いをいたしますね。
背景
BFF パターンの誕生
BFF パターンは、マイクロサービスアーキテクチャの普及とともに注目を集めるようになりました。フロントエンドアプリケーションが複雑化し、複数のバックエンドサービスと連携する必要が生じた際、フロントエンドに最適化された API レイヤーの必要性が高まったのです。
従来のモノリシックな API では、モバイルアプリ、Web アプリ、管理画面など、異なるクライアントのニーズに対応することが困難でした。BFF は各クライアントに特化した API を提供することで、この課題を解決します。
Node.js エコシステムの進化
Node.js のエコシステムは急速に進化しており、BFF を実装するための選択肢も増えています。Next.js の App Router による Route Handlers、型安全性を重視した tRPC、柔軟なクエリを可能にする GraphQL など、それぞれが異なる強みを持っているのです。
以下の図は、BFF の基本的な位置づけを示しています。
mermaidflowchart TB
web["Web クライアント"] -->|HTTP/REST| bff["BFF レイヤー"]
mobile["Mobile クライアント"] -->|HTTP/REST| bff
admin["管理画面"] -->|HTTP/REST| bff
bff -->|認証・集約| auth["認証サービス"]
bff -->|データ取得| user["ユーザーサービス"]
bff -->|データ取得| product["商品サービス"]
bff -->|データ取得| order["注文サービス"]
auth -.->|検証| db1[("Auth DB")]
user -.->|クエリ| db2[("User DB")]
product -.->|クエリ| db3[("Product DB")]
order -.->|クエリ| db4[("Order DB")]
このアーキテクチャにより、各クライアントは BFF を経由して必要なデータを効率的に取得できます。BFF レイヤーが複数のマイクロサービスからのデータを集約し、クライアントに最適化された形で返却するのです。
技術選択の重要性
適切な技術を選択することは、プロジェクトの成功に直結します。開発速度、型安全性、チームのスキルセット、既存システムとの統合など、様々な要素を考慮する必要があるでしょう。
課題
技術選択の複雑さ
BFF を実装する際、開発者は Route Handlers、GraphQL、tRPC のいずれかを選択する必要がありますが、それぞれの特徴を理解せずに選択すると、後々大きな問題を引き起こす可能性があります。
例えば、GraphQL は柔軟性が高い反面、学習コストが高く、適切に設計しないと N+1 問題などのパフォーマンス問題が発生します。一方、tRPC は型安全性に優れていますが、TypeScript 以外の言語で書かれたクライアントからは利用しにくいという制約があるのです。
オーバーエンジニアリングのリスク
シンプルな CRUD 操作だけで十分なプロジェクトに GraphQL を導入したり、小規模なチームで複数の技術を混在させたりすると、メンテナンスコストが増大します。技術選択は、現在のニーズと将来の拡張性のバランスを取る必要があるでしょう。
責務の曖昧さ
複数の技術を組み合わせる場合、それぞれの責務が曖昧になりがちです。どの処理をどの技術で実装すべきか明確な指針がないと、一貫性のない設計になってしまいますね。
以下の図は、責務が曖昧な状態を示しています。
mermaidflowchart LR
client["クライアント"] -->|"混在した<br/>リクエスト"| unclear["責務不明確な<br/>BFF レイヤー"]
unclear -.->|"どこで処理?"| q1["REST API?"]
unclear -.->|"どこで処理?"| q2["GraphQL?"]
unclear -.->|"どこで処理?"| q3["tRPC?"]
q1 --> backend["バックエンド"]
q2 --> backend
q3 --> backend
style unclear fill:#ffcccc
style q1 fill:#ffffcc
style q2 fill:#ffffcc
style q3 fill:#ffffcc
このような状態では、開発者が実装のたびに判断を迫られ、プロジェクト全体の一貫性が失われます。
パフォーマンスとセキュリティの両立
BFF レイヤーは、複数のバックエンドサービスからデータを集約するため、パフォーマンスボトルネックになりやすい部分です。同時に、認証・認可のゲートウェイとしての役割も担うため、セキュリティ面での配慮も欠かせません。
キャッシュ戦略、リクエストのバッチング、エラーハンドリングなど、考慮すべき点は多岐にわたります。
解決策
責務分割の基本原則
各技術の責務を明確に定義することで、一貫性のある設計が可能になります。以下の原則に基づいて技術を選択しましょう。
Route Handlers の責務
Route Handlers は、Next.js App Router の機能として提供される API エンドポイントです。以下のような用途に最適です。
主な責務
- シンプルな REST API エンドポイント
- Webhook の受信
- ファイルアップロード・ダウンロード
- サードパーティ API との連携
- サーバーサイドでの認証処理
Route Handlers は、フレームワークに統合されているため、追加の設定が不要で、すぐに利用できます。
GraphQL の責務
GraphQL は、複雑なデータ取得要件がある場合に威力を発揮するでしょう。
主な責務
- 複数のリソースを 1 回のリクエストで取得
- クライアント主導のデータ取得(必要なフィールドのみ)
- リアルタイムデータの配信(Subscription)
- データグラフの探索的な利用
GraphQL は、データの関連性が複雑な場合や、クライアントごとに必要なデータが大きく異なる場合に適しています。
tRPC の責務
tRPC は、TypeScript による型安全性を最大限に活用できます。
主な責務
- フロントエンドとバックエンド間の型安全な通信
- 複雑なビジネスロジックの実装
- トランザクション処理
- バリデーション付きのデータ操作
tRPC は、フルスタック TypeScript プロジェクトで真価を発揮するでしょう。
責務分割の実践パターン
以下の図は、各技術の責務範囲を明確に示しています。
mermaidflowchart TB
client["クライアント"]
client -->|"Webhook<br/>ファイル操作"| rh["Route Handlers"]
client -->|"複雑な<br/>データ取得"| gql["GraphQL"]
client -->|"型安全な<br/>RPC 呼び出し"| trpc["tRPC"]
rh -->|"外部連携"| external["外部 API"]
rh -->|"静的処理"| storage["ストレージ"]
gql -->|"データ集約"| resolver["リゾルバー"]
resolver --> services["各種サービス"]
trpc -->|"ビジネスロジック"| procedures["プロシージャ"]
procedures --> services
services --> db[("データベース")]
style rh fill:#e1f5ff
style gql fill:#fff4e1
style trpc fill:#f0e1ff
この図から、各技術が異なる責務を持ち、適材適所で利用されることが理解できます。
統合アーキテクチャパターン
実際のプロジェクトでは、これらの技術を組み合わせて使用することが多いでしょう。以下のパターンを参考にしてください。
パターン 1:小規模プロジェクト
小規模なプロジェクトでは、Route Handlers のみで十分な場合が多いです。
| # | 項目 | 内容 |
|---|---|---|
| 1 | 規模 | チーム 1〜3 人、エンドポイント数 10 以下 |
| 2 | 技術構成 | Route Handlers のみ |
| 3 | メリット | シンプル、学習コスト低、メンテナンス容易 |
| 4 | 適用例 | MVP、社内ツール、シンプルな Web アプリ |
パターン 2:中規模プロジェクト(TypeScript フルスタック)
TypeScript で統一されたプロジェクトでは、tRPC が最適です。
| # | 項目 | 内容 |
|---|---|---|
| 1 | 規模 | チーム 3〜10 人、複雑なビジネスロジック |
| 2 | 技術構成 | tRPC(メイン)+ Route Handlers(Webhook、ファイル) |
| 3 | メリット | 型安全性、開発効率、エディタサポート充実 |
| 4 | 適用例 | SaaS アプリケーション、社内システム |
パターン 3:大規模プロジェクト(マルチクライアント)
複数のクライアントがある大規模プロジェクトでは、GraphQL が適しています。
| # | 項目 | 内容 |
|---|---|---|
| 1 | 規模 | チーム 10 人以上、複数のクライアント |
| 2 | 技術構成 | GraphQL(メイン)+ Route Handlers(Webhook) |
| 3 | メリット | 柔軟性、クライアント最適化、ドキュメント自動生成 |
| 4 | 適用例 | エンタープライズアプリ、マルチプラットフォーム |
パターン 4:ハイブリッド構成
複雑な要件がある場合は、複数の技術を組み合わせることも検討しましょう。
| # | 項目 | 内容 |
|---|---|---|
| 1 | 規模 | チーム 10 人以上、多様な要件 |
| 2 | 技術構成 | GraphQL + tRPC + Route Handlers |
| 3 | メリット | 各技術の強みを活かせる |
| 4 | 注意点 | 複雑性増大、チームの学習コスト |
具体例
プロジェクト構成
まず、Next.js プロジェクトで 3 つの技術を統合する際の基本的なディレクトリ構造を見ていきます。
typescript// プロジェクトのディレクトリ構造
src/
├── app/ // Next.js App Router
│ ├── api/
│ │ ├── webhooks/ // Route Handlers
│ │ │ └── stripe/
│ │ │ └── route.ts
│ │ ├── upload/ // ファイルアップロード
│ │ │ └── route.ts
│ │ └── graphql/ // GraphQL エンドポイント
│ │ └── route.ts
│ └── page.tsx
├── server/
│ ├── trpc/ // tRPC 設定
│ │ ├── router.ts
│ │ └── procedures/
│ └── graphql/ // GraphQL 設定
│ ├── schema.ts
│ └── resolvers/
└── lib/
└── services/ // 共通ビジネスロジック
この構成により、各技術の責務が明確に分離され、メンテナンスしやすくなります。
Route Handlers の実装例
Route Handlers は、シンプルな API エンドポイントや Webhook の処理に使用します。
Stripe Webhook の実装
Stripe からの支払い通知を受け取る Webhook を実装してみましょう。
typescript// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
// Stripe インスタンスの初期化
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
});
上記のコードでは、Stripe SDK を初期化しています。環境変数から秘密鍵を取得し、API バージョンを指定しているのです。
typescript// Webhook エンドポイントの実装
export async function POST(req: NextRequest) {
// リクエストボディを取得
const body = await req.text();
// Stripe の署名ヘッダーを取得
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
署名検証は、Webhook が本当に Stripe から送信されたものかを確認するための重要なセキュリティ対策です。
typescript try {
// イベントの検証と解析
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// イベントタイプに応じた処理
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
イベントタイプごとに異なる処理を実行することで、柔軟な対応が可能になります。
typescript return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 400 }
);
}
}
エラーハンドリングを適切に行うことで、Stripe に正しいレスポンスを返し、リトライ機構を適切に機能させられます。
typescript// 支払い成功時の処理
async function handlePaymentSuccess(
paymentIntent: Stripe.PaymentIntent
) {
// データベースの更新
await updateOrderStatus(paymentIntent.id, 'paid');
// メール送信
await sendConfirmationEmail(
paymentIntent.metadata.userEmail
);
}
// 支払い失敗時の処理
async function handlePaymentFailure(
paymentIntent: Stripe.PaymentIntent
) {
// エラーログの記録
await logPaymentError(
paymentIntent.id,
paymentIntent.last_payment_error
);
// ユーザーへの通知
await sendPaymentFailureNotification(
paymentIntent.metadata.userEmail
);
}
ビジネスロジックを別関数に分離することで、テストしやすく、再利用可能なコードになりますね。
ファイルアップロードの実装
次に、画像アップロード機能を実装します。
typescript// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function POST(req: NextRequest) {
try {
// FormData からファイルを取得
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
FormData API を使用することで、ファイルのアップロードを簡単に処理できます。
typescript// ファイルサイズの検証(5MB制限)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File size exceeds 5MB limit' },
{ status: 400 }
);
}
// ファイルタイプの検証
const allowedTypes = [
'image/jpeg',
'image/png',
'image/webp',
];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{
error:
'Invalid file type. Only JPEG, PNG, and WebP are allowed',
},
{ status: 400 }
);
}
セキュリティのため、ファイルサイズとタイプを検証することが重要です。
typescript // ファイルをバッファに変換
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// ユニークなファイル名を生成
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const filename = `${uniqueSuffix}-${file.name}`;
const filepath = join(process.cwd(), 'public/uploads', filename);
// ファイルを保存
await writeFile(filepath, buffer);
return NextResponse.json({
success: true,
filename,
url: `/uploads/${filename}`,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
);
}
}
ファイル名の衝突を避けるため、タイムスタンプとランダム値を組み合わせたユニークな名前を生成しています。
tRPC の実装例
tRPC は、TypeScript の型安全性を活用した RPC フレームワークです。フロントエンドとバックエンドで型定義を共有できるのが最大の特徴でしょう。
tRPC サーバーの初期設定
まず、tRPC のルーターとコンテキストを設定します。
typescript// server/trpc/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
// tRPC インスタンスの初期化
const t = initTRPC.context<Context>().create({
errorFormatter({ shape }) {
return shape;
},
});
// 基本的なエクスポート
export const router = t.router;
export const publicProcedure = t.procedure;
この設定により、全ての tRPC プロシージャで共通のコンテキストとエラーフォーマットが利用できます。
typescript// 認証ミドルウェアの定義
const isAuthed = t.middleware(({ ctx, next }) => {
// セッションから認証情報を確認
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
// 認証済みのコンテキストを返す
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
});
});
// 認証が必要なプロシージャ
export const protectedProcedure = t.procedure.use(isAuthed);
ミドルウェアを使用することで、認証ロジックを一箇所に集約でき、各プロシージャで再利用できますね。
コンテキストの設定
リクエストごとのコンテキストを作成します。
typescript// server/trpc/context.ts
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// コンテキストの型定義
export async function createContext(
opts: FetchCreateContextFnOptions
) {
// セッション情報を取得
const session = await getServerSession(authOptions);
return {
session,
prisma, // Prisma クライアント
req: opts.req,
resHeaders: opts.resHeaders,
};
}
export type Context = Awaited<
ReturnType<typeof createContext>
>;
コンテキストには、セッション情報やデータベースクライアントなど、各プロシージャで必要な共通情報を含めます。
ユーザー管理プロシージャの実装
実際のビジネスロジックを実装していきましょう。
typescript// server/trpc/routers/user.ts
import { z } from 'zod';
import {
router,
publicProcedure,
protectedProcedure,
} from '../trpc';
import { TRPCError } from '@trpc/server';
// バリデーションスキーマの定義
const updateProfileSchema = z.object({
name: z.string().min(1).max(50),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
});
Zod を使用することで、実行時のバリデーションと TypeScript の型推論を同時に実現できます。
typescriptexport const userRouter = router({
// プロフィール取得(公開)
getProfile: publicProcedure
.input(z.object({
userId: z.string(),
}))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.userId },
select: {
id: true,
name: true,
bio: true,
avatarUrl: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
クエリプロシージャは、データの取得に使用します。SELECT 句で必要なフィールドのみを指定することで、パフォーマンスを最適化できるのです。
typescript // プロフィール更新(認証必要)
updateProfile: protectedProcedure
.input(updateProfileSchema)
.mutation(async ({ ctx, input }) => {
// 更新を実行
const updatedUser = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
bio: input.bio,
avatarUrl: input.avatarUrl,
},
});
return updatedUser;
}),
ミューテーションプロシージャは、データの変更に使用します。認証済みユーザーのみが実行できるよう、protectedProcedure を使用しています。
typescript // ユーザー一覧取得(ページネーション付き)
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
avatarUrl: true,
},
});
// 次のページがあるか確認
let nextCursor: string | undefined = undefined;
if (users.length > input.limit) {
const nextItem = users.pop();
nextCursor = nextItem!.id;
}
return {
users,
nextCursor,
};
}),
});
カーソルベースのページネーションを実装することで、大量のデータを効率的に取得できます。
フロントエンドでの利用
tRPC の最大の利点は、フロントエンドで型安全に API を呼び出せることです。
typescript// app/profile/page.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';
export default function ProfilePage() {
// プロフィールデータの取得
const { data: user, isLoading } = trpc.user.getProfile.useQuery({
userId: 'user-123',
});
// プロフィール更新のミューテーション
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => {
alert('Profile updated successfully!');
},
});
React Query ベースの hooks により、キャッシュ管理やローディング状態の処理が自動化されます。
typescript const [name, setName] = useState('');
const [bio, setBio] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 型安全な呼び出し
await updateProfile.mutateAsync({
name,
bio,
});
};
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<form onSubmit={handleSubmit}>
{/* フォーム内容 */}
</form>
);
}
TypeScript の型推論により、エディタの補完が効き、型エラーをコンパイル時に検出できるのです。
GraphQL の実装例
GraphQL は、複雑なデータ取得要件がある場合に最適です。クライアントが必要なデータだけを指定して取得できるため、over-fetching や under-fetching の問題を解決できます。
GraphQL スキーマの定義
まず、型定義とクエリ、ミューテーションを定義します。
graphql# server/graphql/schema.graphql
# ユーザー型の定義
type User {
id: ID!
name: String!
email: String!
bio: String
avatarUrl: String
posts: [Post!]!
createdAt: DateTime!
}
# 投稿型の定義
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
comments: [Comment!]!
createdAt: DateTime!
updatedAt: DateTime!
}
GraphQL のスキーマファーストアプローチにより、API の仕様が明確になります。
graphql# コメント型の定義
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
}
# カスタムスカラー型
scalar DateTime
# クエリの定義
type Query {
# ユーザー情報を取得
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
# 投稿情報を取得
post(id: ID!): Post
posts(published: Boolean, authorId: ID): [Post!]!
# 検索機能
searchPosts(query: String!): [Post!]!
}
クエリでは、様々な条件でデータを取得できるよう、柔軟なパラメータを定義しています。
graphql# ミューテーションの定義
type Mutation {
# ユーザー関連
updateProfile(input: UpdateProfileInput!): User!
# 投稿関連
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# コメント関連
addComment(postId: ID!, content: String!): Comment!
}
# 入力型の定義
input UpdateProfileInput {
name: String
bio: String
avatarUrl: String
}
input CreatePostInput {
title: String!
content: String!
published: Boolean
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
入力型を別途定義することで、複雑なパラメータを型安全に扱えますね。
リゾルバーの実装
スキーマに対応するリゾルバーを実装します。
typescript// server/graphql/resolvers/user.ts
import { GraphQLError } from 'graphql';
import { Context } from '../context';
export const userResolvers = {
Query: {
// 単一ユーザーの取得
user: async (
_parent: unknown,
{ id }: { id: string },
context: Context
) => {
const user = await context.prisma.user.findUnique({
where: { id },
});
if (!user) {
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND' },
});
}
return user;
},
リゾルバーは、各フィールドのデータ取得ロジックを定義します。エラーハンドリングも適切に行いましょう。
typescript // ユーザー一覧の取得
users: async (
_parent: unknown,
{ limit = 10, offset = 0 }: { limit?: number; offset?: number },
context: Context
) => {
return await context.prisma.user.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
});
},
},
ページネーション対応により、大量のデータを効率的に処理できます。
typescript User: {
// ユーザーの投稿を取得(フィールドリゾルバー)
posts: async (parent: { id: string }, _args: unknown, context: Context) => {
return await context.prisma.post.findMany({
where: { authorId: parent.id },
orderBy: { createdAt: 'desc' },
});
},
},
フィールドリゾルバーにより、関連データを遅延ロードできます。これが GraphQL の強力な機能の一つです。
typescript Mutation: {
// プロフィール更新
updateProfile: async (
_parent: unknown,
{ input }: { input: { name?: string; bio?: string; avatarUrl?: string } },
context: Context
) => {
// 認証チェック
if (!context.user) {
throw new GraphQLError('Unauthorized', {
extensions: { code: 'UNAUTHORIZED' },
});
}
// 更新実行
const updatedUser = await context.prisma.user.update({
where: { id: context.user.id },
data: input,
});
return updatedUser;
},
},
};
ミューテーションリゾルバーでは、認証チェックとデータ更新を行います。
DataLoader による N+1 問題の解決
GraphQL で最も注意すべき問題が N+1 クエリです。DataLoader を使用して解決しましょう。
typescript// server/graphql/dataloaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
// ユーザーを一括で取得する DataLoader
export const createUserLoader = (prisma: PrismaClient) => {
return new DataLoader(
async (userIds: readonly string[]) => {
// IDのリストで一括取得
const users = await prisma.user.findMany({
where: {
id: {
in: [...userIds],
},
},
});
// IDの順序を保持してマッピング
const userMap = new Map(
users.map((user) => [user.id, user])
);
return userIds.map((id) => userMap.get(id) || null);
}
);
};
DataLoader は、同一リクエスト内の複数のクエリをバッチ処理し、データベースへのアクセスを最小化します。
typescript// 投稿を一括で取得する DataLoader
export const createPostLoader = (prisma: PrismaClient) => {
return new DataLoader(
async (postIds: readonly string[]) => {
const posts = await prisma.post.findMany({
where: {
id: {
in: [...postIds],
},
},
});
const postMap = new Map(
posts.map((post) => [post.id, post])
);
return postIds.map((id) => postMap.get(id) || null);
}
);
};
各リソースごとに DataLoader を用意することで、効率的なデータ取得が可能になるのです。
typescript// server/graphql/context.ts
import {
createUserLoader,
createPostLoader,
} from './dataloaders';
import { prisma } from '@/lib/prisma';
export async function createGraphQLContext({
req,
}: {
req: Request;
}) {
return {
prisma,
user: await getUserFromRequest(req),
loaders: {
user: createUserLoader(prisma),
post: createPostLoader(prisma),
},
};
}
export type Context = Awaited<
ReturnType<typeof createGraphQLContext>
>;
コンテキストに DataLoader を含めることで、全てのリゾルバーから利用できます。
フロントエンドでのクエリ実装
Apollo Client を使用して GraphQL クエリを実行します。
typescript// app/posts/page.tsx
'use client';
import { gql, useQuery } from '@apollo/client';
// GraphQL クエリの定義
const GET_POSTS = gql`
query GetPosts($published: Boolean) {
posts(published: $published) {
id
title
content
createdAt
author {
id
name
avatarUrl
}
comments {
id
content
author {
name
}
}
}
}
`;
GraphQL のクエリ言語により、必要なデータの構造を宣言的に記述できます。
typescriptexport default function PostsPage() {
// クエリの実行
const { data, loading, error } = useQuery(GET_POSTS, {
variables: { published: true },
});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{data.posts.map((post: any) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<div>
By {post.author.name} - {post.comments.length}{' '}
comments
</div>
</article>
))}
</div>
);
}
1 回のリクエストで、投稿、著者、コメント情報を全て取得できます。これが GraphQL の大きな利点ですね。
統合パターンの実装
実際のプロジェクトでは、3 つの技術を組み合わせて使用します。以下は、E コマースサイトでの実装例です。
以下の図は、統合アーキテクチャの全体像を示しています。
mermaidflowchart TB
frontend["Next.js<br/>フロントエンド"]
frontend -->|"商品検索<br/>カート操作"| gql["GraphQL<br/>エンドポイント"]
frontend -->|"注文処理<br/>ユーザー管理"| trpc["tRPC<br/>エンドポイント"]
frontend -->|"画像アップロード<br/>決済Webhook"| rh["Route Handlers"]
gql --> resolvers["GraphQL<br/>リゾルバー"]
trpc --> procedures["tRPC<br/>プロシージャ"]
rh --> handlers["Request<br/>ハンドラー"]
resolvers --> services["共通サービス層"]
procedures --> services
handlers --> services
services --> db[("PostgreSQL")]
services --> cache[("Redis")]
services --> storage["S3 ストレージ"]
rh -->|"決済確認"| stripe["Stripe API"]
style gql fill:#fff4e1
style trpc fill:#f0e1ff
style rh fill:#e1f5ff
この図から、各技術が異なる責務を担いつつ、共通のサービス層を通じてデータにアクセスしていることが分かります。
共通サービス層の設計
ビジネスロジックを共通化し、各技術から再利用できるようにします。
typescript// lib/services/order.service.ts
import { prisma } from '@/lib/prisma';
import { redis } from '@/lib/redis';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export class OrderService {
// 注文を作成
async createOrder(userId: string, items: { productId: string; quantity: number }[]) {
// トランザクション内で処理
return await prisma.$transaction(async (tx) => {
// 在庫確認
for (const item of items) {
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product || product.stock < item.quantity) {
throw new Error(`Product ${item.productId} is out of stock`);
}
}
トランザクションを使用することで、データの整合性を保証できます。
typescript // 注文レコードを作成
const order = await tx.order.create({
data: {
userId,
status: 'pending',
items: {
create: items.map(item => ({
productId: item.productId,
quantity: item.quantity,
})),
},
},
include: {
items: {
include: {
product: true,
},
},
},
});
// 在庫を減らす
for (const item of items) {
await tx.product.update({
where: { id: item.productId },
data: {
stock: {
decrement: item.quantity,
},
},
});
}
return order;
});
}
在庫の減算も同一トランザクション内で行うことで、競合状態を防ぎます。
typescript // 決済を処理
async processPayment(orderId: string, paymentMethodId: string) {
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
items: {
include: {
product: true,
},
},
},
});
if (!order) {
throw new Error('Order not found');
}
// 合計金額を計算
const amount = order.items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
注文情報から支払い金額を計算します。
typescript // Stripe で決済を作成
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // セント単位に変換
currency: 'jpy',
payment_method: paymentMethodId,
confirm: true,
metadata: {
orderId: order.id,
},
});
// 決済成功時、注文ステータスを更新
if (paymentIntent.status === 'succeeded') {
await prisma.order.update({
where: { id: orderId },
data: { status: 'paid' },
});
// キャッシュをクリア
await redis.del(`order:${orderId}`);
}
return paymentIntent;
}
外部 API との連携とデータベース更新を適切に処理しています。
typescript // 注文一覧を取得(キャッシュ付き)
async getOrdersByUser(userId: string) {
// キャッシュをチェック
const cacheKey = `orders:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// データベースから取得
const orders = await prisma.order.findMany({
where: { userId },
include: {
items: {
include: {
product: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
// キャッシュに保存(5分間)
await redis.setex(cacheKey, 300, JSON.stringify(orders));
return orders;
}
}
export const orderService = new OrderService();
Redis によるキャッシュで、頻繁にアクセスされるデータのパフォーマンスを改善できるのです。
各技術からサービス層を利用
共通サービス層を各技術から呼び出します。
typescript// tRPC から利用
import { orderService } from '@/lib/services/order.service';
export const orderRouter = router({
create: protectedProcedure
.input(
z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().min(1),
})
),
})
)
.mutation(async ({ ctx, input }) => {
return await orderService.createOrder(
ctx.user.id,
input.items
);
}),
pay: protectedProcedure
.input(
z.object({
orderId: z.string(),
paymentMethodId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return await orderService.processPayment(
input.orderId,
input.paymentMethodId
);
}),
});
typescript// GraphQL から利用
export const orderResolvers = {
Query: {
myOrders: async (
_parent: unknown,
_args: unknown,
context: Context
) => {
if (!context.user) {
throw new GraphQLError('Unauthorized');
}
return await orderService.getOrdersByUser(
context.user.id
);
},
},
};
typescript// Route Handler から利用
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await req.json();
const order = await orderService.createOrder(
session.user.id,
body.items
);
return NextResponse.json(order);
}
このように、ビジネスロジックを一箇所に集約することで、保守性が大幅に向上しますね。
まとめ
Node.js における BFF 設計では、Route Handlers、GraphQL、tRPC という 3 つの主要な技術が選択肢として存在します。それぞれが異なる強みを持ち、適切な場面で使い分けることが重要です。
本記事で解説した責務分割の原則をまとめます。
Route Handlers の適用範囲
- Webhook やファイルアップロードなど、シンプルな HTTP エンドポイント
- 外部 API との連携や静的な処理
- 小規模プロジェクトでの簡易的な API 実装
GraphQL の適用範囲
- 複雑なデータ取得要件がある場合
- 複数のクライアントが異なるデータ構造を必要とする場合
- リアルタイム機能が求められる場合
tRPC の適用範囲
- TypeScript フルスタックプロジェクト
- 型安全性を最優先したい場合
- 複雑なビジネスロジックとバリデーションが必要な場合
統合パターンの選択 プロジェクトの規模や要件に応じて、単一技術または複数技術の組み合わせを選択しましょう。小規模なら Route Handlers のみ、中規模なら tRPC 中心、大規模なら GraphQL を軸に、必要に応じて他の技術を補完的に使用するのが効果的です。
共通サービス層の重要性 どの技術を選択する場合でも、ビジネスロジックを共通のサービス層に集約することで、保守性と再利用性が向上します。各技術は API レイヤーとしての役割に徹し、実際のロジックはサービス層に委譲するアーキテクチャが理想的でしょう。
技術選択に迷った際は、チームのスキルセット、プロジェクトの規模、将来の拡張性を総合的に判断してください。完璧な選択はありませんが、本記事で紹介した原則に従えば、後悔の少ない意思決定ができるはずです。
関連リンク
articleNode.js BFF 設計の最適解:Route Handlers/GraphQL/tRPC の責務分割
articleNode.js ファイルパス地図:`fs`/`path`/`URL`/`import.meta.url` の迷わない対応表
articleNode.js プロジェクト初期化テンプレ:ESM 前提の `package.json` 設計と `exports` ルール
articleNode.js 標準テストランナー完全理解:`node:test` がもたらす新しい DX
articleNode.js で ESM の `ERR_MODULE_NOT_FOUND` を解く:解決策総当たりチェックリスト
articleNode.js 本番メモリ運用:ヒープ/外部メモリ/リーク検知の継続監視
article初めての Nano Banana:Hello World から実用サンプルまで 30 分チュートリアル
articleNotebookLM チーム運用ガイド:権限設計・レビュー体制・承認フロー
articleNode.js BFF 設計の最適解:Route Handlers/GraphQL/tRPC の責務分割
articleMySQL マルチテナント設計:スキーマ分割 vs 行レベルテナンシーの判断基準
articleNext.js Metadata API 逆引き:`robots`/`alternates`/`openGraph`/`twitter` の記入例
articleMermaid CLI(mmdc)速攻導入:インストールからバッチ生成・自動リサイズまで
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来