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 を構築する際の代表的なプロトコル・フレームワークを比較すると、以下のような特徴があります。
| 項目 | GraphQL | gRPC | ConnectRPC | tRPC |
|---|---|---|---|---|
| スキーマ言語 | GraphQL SDL | Protocol Buffers | Protocol Buffers | TypeScript の型そのもの |
| コード生成(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 を採用すると、型同期のためにこれまで必要だった以下の作業が すべて不要 になります。
- スキーマファイルの管理(OpenAPI yaml や GraphQL SDL の手書き)
- コード生成パイプライン(
orvalやgraphql-codegenの実行・管理) - CI でのドリフトチェック(実装と生成コードの乖離を検知するテスト)
- クライアント側の手書き型注釈(
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) ]
- バックエンド ⇔ BFF 間: 複数のマイクロサービスからデータを集約し、言語も Go や Python など多岐にわたるため、OpenAPI や Protocol Buffers で型を別出しして厳格に共有する価値があります。
- BFF ⇔ フロントエンド間: 画面都合の 1 対 1 の関係であり、型を再利用する相手はいません。したがって、型を別出しせず、TypeScript の型推論をそのまま import するのが最も軽く、変更に強い構造 になります。
将来非 TS クライアントが必要になったときの逃げ道
「将来 iOS や Android アプリを作ることになったらどうするのか?」という懸念も、tRPC であれば心配無用です。
tRPC のプロシージャはすべて Zod スキーマで入出力が定義されているため、必要になった段階で trpc-to-openapi などのツールを使って OpenAPI スキーマへ自動射影 できます。最初から全言語に備えて重いパイプラインを組むのではなく、「必要になったときに外出しする」というアプローチが最もトータルコストを抑えられます。
まとめ: 変更頻度が高い層には、最も薄い技術を当てる
BFF の技術選定において最も重要なのは、「画面の変更速度を絶対に落とさないこと」です。
- 変更頻度が高い BFF ⇔ フロント間: tRPC によるゼロ codegen な E2E 型安全で開発サイクルを最速化する
- 安定性が求められる バックエンド ⇔ BFF 間: OpenAPI / Proto で厳格なコントラクトを敷く
この境界ごとの使い分けを行うことで、型安全の恩恵を 100% 享受しながら、運用コストを極限まで削ぎ落とすことができます。