T-CREATOR

Node.js BFF 設計の最適解:Route Handlers/GraphQL/tRPC の責務分割

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 レイヤーとしての役割に徹し、実際のロジックはサービス層に委譲するアーキテクチャが理想的でしょう。

技術選択に迷った際は、チームのスキルセット、プロジェクトの規模、将来の拡張性を総合的に判断してください。完璧な選択はありませんが、本記事で紹介した原則に従えば、後悔の少ない意思決定ができるはずです。

関連リンク