Blogへ戻る

Language: 日本語

「BFF のために codegen やスキーマ同期を回していませんか?」 tRPC で最小コストの E2E 型安全を手に入れる技術選定

Next.js などのフロントエンドとバックエンドの間に BFF(Backend For Frontend)を構築するとき、**「とりあえず OpenAPI スキーマを書いて codegen を回すか」「GraphQL や gRPC / ConnectRPC を導入すべきか」** とプロトコル選定に悩んでいませんか?

Next.js などのフロントエンドとバックエンドの間に BFF(Backend For Frontend)を構築するとき、「とりあえず OpenAPI スキーマを書いて codegen を回すか」「GraphQL や gRPC / ConnectRPC を導入すべきか」 とプロトコル選定に悩んでいませんか?

BFF はフロントエンドの画面都合(ユースケース)に密結合する層であり、画面デザインや UI の変更頻度と同じスピードで頻繁に仕様が変わります

そうした激しい変更頻度の中で、スキーマ言語を別途定義し、コード生成(codegen)パイプラインを組み、CI で実装とのドリフト(乖離)を監視する運用は、開発速度をじわじわと削ぐ重い運用負債 になりがちです。

この記事では、TSKaigi 2026 の LT『E2E型安全なのに楽するBFF導入』の知見をもとに、なぜ TypeScript フルスタックの BFF において tRPC が圧倒的に低コストで強いのか、そのアーキテクチャ上の理由と型の切り出し戦略を解説します。

得られること

  • GraphQL / gRPC / ConnectRPC / tRPC の比較と、BFF における tRPC の圧倒的な運用メリットがわかる
  • コード生成(codegen)不要でフロントと BFF 間を完全な型安全で繋ぐ tRPC の実装パターンが学べる
  • 「バックエンド ⇔ BFF」と「BFF ⇔ フロント」で型の切り出し戦略を分ける設計思考が身につく!

4 大プロトコルのトレードオフ比較

BFF を構築する際の代表的なプロトコル・フレームワークを比較すると、以下のような特徴があります。

項目GraphQLgRPCConnectRPCtRPC
スキーマ言語GraphQL SDLProtocol BuffersProtocol BuffersTypeScript の型そのもの
コード生成(codegen)必要(graphql-codegen 等)必要(protoc)必要(protoc / buf)不要(ゼロ生成) ⚡️
非 TS クライアント対応柔軟(多言語対応)柔軟(多言語対応)柔軟(多言語対応)不可(TS 特化)
ブラウザ親和性高い(HTTP/JSON)低い(HTTP/2・gRPC-Web が要る)高い(HTTP/1.1・JSON サポート)極めて高い(HTTP/JSON)
初期セットアップ / 学習コスト大(スキーマ設計・リゾルバ)中〜大(proto・インフラ)中(proto 設定)最小(npm install のみ)

GraphQL や gRPC は、モバイルアプリ(Swift / Kotlin)や多言語のマイクロサービスが混在する環境では絶大な威力を発揮します。

しかし、「フロントエンド(React/Next.js)と BFF(Node.js/Next.js)がどちらも TypeScript で書かれており、1 対 1 で結合している」 という前提であれば、外部スキーマ言語と codegen パイプラインを管理するコストは過剰なオーバーヘッドになります。

tRPC がもたらす「型同期コストの完全消去」

tRPC を採用すると、型同期のためにこれまで必要だった以下の作業が すべて不要 になります。

  1. スキーマファイルの管理(OpenAPI yaml や GraphQL SDL の手書き)
  2. コード生成パイプラインorvalgraphql-codegen の実行・管理)
  3. CI でのドリフトチェック(実装と生成コードの乖離を検知するテスト)
  4. クライアント側の手書き型注釈await res.json() as Task[] のような信用頼みのキャスト)

サーバー側のルーター定義

サーバー側で Zod を使ってプロシージャを定義し、ルーターの型(AppRouter)を export します。

// server: router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  task: t.router({
    list: t.procedure
      .input(z.object({ status: z.enum(['todo', 'done']) }))
      .query(async ({ input }) => {
        return db.task.findMany({ where: { status: input.status } });
      }),
  }),
});

// ルーターの型のみを export(実装コードはクライアントにバンドルされない!)
export type AppRouter = typeof appRouter;

クライアント側の呼び出し

クライアント側は、AppRouter の型を import してクライアントを初期化するだけです。

// client: component.tsx
import { trpc } from '@/utils/trpc';

// 入力補完・型チェックが自動で効く('todo' | 'done' 以外はコンパイルエラー)
const tasks = await trpc.task.list.query({ status: 'todo' });

codegen を 1 回も走らせることなく、サーバー側の戻り値の型変更が即座にフロントエンドのエディタや tsc に反映 されます。

境界による型の切り出し戦略: 「別出し」すべき場所と不要な場所

型を独立したスキーマファイル(Proto や OpenAPI)として切り出す価値は、「共有と再利用」 にあります。

【 2 つの境界における型の戦略 】
[ バックエンド群 (Go / Python) ] 

       │ (1) 複数サービスから集約 ➔ OpenAPI / Proto で「型を別出し」して厳密に共有

   [ BFF (Next.js) ]

       │ (2) 1 対 1 の密結合 ➔ tRPC で「型を別出しせず」import でゼロコスト共有

[ フロントエンド (React) ]
  1. バックエンド ⇔ BFF 間: 複数のマイクロサービスからデータを集約し、言語も Go や Python など多岐にわたるため、OpenAPI や Protocol Buffers で型を別出しして厳格に共有する価値があります。
  2. BFF ⇔ フロントエンド間: 画面都合の 1 対 1 の関係であり、型を再利用する相手はいません。したがって、型を別出しせず、TypeScript の型推論をそのまま import するのが最も軽く、変更に強い構造 になります。

将来非 TS クライアントが必要になったときの逃げ道

「将来 iOS や Android アプリを作ることになったらどうするのか?」という懸念も、tRPC であれば心配無用です。

tRPC のプロシージャはすべて Zod スキーマで入出力が定義されているため、必要になった段階で trpc-to-openapi などのツールを使って OpenAPI スキーマへ自動射影 できます。最初から全言語に備えて重いパイプラインを組むのではなく、「必要になったときに外出しする」というアプローチが最もトータルコストを抑えられます。

まとめ: 変更頻度が高い層には、最も薄い技術を当てる

BFF の技術選定において最も重要なのは、「画面の変更速度を絶対に落とさないこと」です。

  • 変更頻度が高い BFF ⇔ フロント間: tRPC によるゼロ codegen な E2E 型安全で開発サイクルを最速化する
  • 安定性が求められる バックエンド ⇔ BFF 間: OpenAPI / Proto で厳格なコントラクトを敷く

この境界ごとの使い分けを行うことで、型安全の恩恵を 100% 享受しながら、運用コストを極限まで削ぎ落とすことができます。

参考リンク