REST、GraphQL、tRPC: どれを選択するべきですか?
クライアントとサーバー間の通信パラダイムの選択は、アーキテクチャ上の決定の 1 つです。 最新のプロジェクトでは最も重要です。 休む, グラフQL e tRPC 3 つの根本的に異なるアプローチを表しており、それぞれに哲学があり、 自分自身の強みと妥協点。
この記事では、それぞれのアプローチを詳しく分析し、比較します。 特徴を詳細な表にまとめ、意思決定の枠組みを構築します。 次のプロジェクトに最適なものを選択してください。
何を学ぶか
- REST の基礎と Richardson 成熟度モデル
- GraphQL スキーマ、リゾルバー、コード生成
- tRPC のエンドツーエンドのタイプ セーフティ
- 詳細比較: パフォーマンス、DX、キャッシュ、セキュリティ
- 選択のための意思決定の枠組み
- 各アプローチの実践的なコード例
レスト:頼もしいベテラン
休む (Representational State Transfer) は最も普及しているアーキテクチャ スタイルです Web API用。 2000 年にロイ フィールディングが博士論文で定義したもので、以下に基づいています。 URL、標準の HTTP メソッド、およびステートレス表現によって識別されるリソース。
リチャードソンの成熟度モデル
Leonard Richardson は、REST API の 4 つの成熟度レベルを定義しました。
- レベル 0 - トンネル: すべてを受け入れる単一のエンドポイント (HTTP 経由の RPC スタイル)
- レベル 1 - リソース: リソースごとにエンドポイントが異なるが、HTTP メソッドは 1 つだけ
- レベル 2 - HTTP 動詞: GET、POST、PUT、DELETE、およびステータス コードの正しい使用
- レベル 3 - HATEOAS: 応答には後続のアクションへのリンクが含まれます (ハイパーメディア)
TypeScript での REST の例
import express from 'express';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
const app = express();
app.use(express.json());
// GET /api/products - Lista prodotti con filtri
app.get('/api/products', async (req, res) => {
const { category, minPrice, maxPrice, page = '1', limit = '20' } = req.query;
const filters: ProductFilters = {
category: category as string,
minPrice: minPrice ? Number(minPrice) : undefined,
maxPrice: maxPrice ? Number(maxPrice) : undefined
};
const products = await productService.findAll(
filters,
Number(page),
Number(limit)
);
res.json({
data: products.items,
pagination: {
page: products.page,
limit: products.limit,
total: products.total,
totalPages: Math.ceil(products.total / products.limit)
}
});
});
// GET /api/products/:id - Dettaglio prodotto
app.get('/api/products/:id', async (req, res) => {
const product = await productService.findById(req.params.id);
if (!product) {
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Prodotto non trovato'
});
}
res.json({ data: product });
});
// POST /api/products - Crea prodotto
app.post('/api/products', async (req, res) => {
const product = await productService.create(req.body);
res.status(201).json({ data: product });
});
// PUT /api/products/:id - Aggiorna prodotto
app.put('/api/products/:id', async (req, res) => {
const product = await productService.update(req.params.id, req.body);
res.json({ data: product });
});
// DELETE /api/products/:id - Elimina prodotto
app.delete('/api/products/:id', async (req, res) => {
await productService.delete(req.params.id);
res.status(204).send();
});
@Injectable({ providedIn: 'root' })
export class ProductApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/products';
getProducts(filters?: ProductFilters): Observable<PaginatedResponse<Product>> {
const params = this.buildParams(filters);
return this.http.get<PaginatedResponse<Product>>(this.baseUrl, { params });
}
getProduct(id: string): Observable<Product> {
return this.http.get<Product>(this.baseUrl + '/' + id);
}
createProduct(data: CreateProductDto): Observable<Product> {
return this.http.post<Product>(this.baseUrl, data);
}
updateProduct(id: string, data: UpdateProductDto): Observable<Product> {
return this.http.put<Product>(this.baseUrl + '/' + id, data);
}
deleteProduct(id: string): Observable<void> {
return this.http.delete<void>(this.baseUrl + '/' + id);
}
private buildParams(filters?: ProductFilters): HttpParams {
let params = new HttpParams();
if (filters?.category) params = params.set('category', filters.category);
if (filters?.minPrice) params = params.set('minPrice', filters.minPrice.toString());
if (filters?.maxPrice) params = params.set('maxPrice', filters.maxPrice.toString());
return params;
}
}
GraphQL: 総合的な柔軟性
グラフQL、2012 年に Facebook によって作成され、2015 年に一般公開されました。 はまったく異なるアプローチを提供します。クライアントは必要なデータを正確に指定します。 受け取ります。これにより、次の問題が解消されます。 過剰取得 (受信データ量が多すぎる) アンダーフェッチ (必要なデータをすべて取得するには複数のリクエストを行う必要があります)。
スキーマと型システム
// schema.graphql
type Product {
id: ID!
name: String!
price: Float!
category: Category!
inStock: Boolean!
reviews: [Review!]!
averageRating: Float
}
type Category {
id: ID!
name: String!
products: [Product!]!
}
type Review {
id: ID!
author: String!
rating: Int!
comment: String
createdAt: String!
}
type PaginatedProducts {
items: [Product!]!
total: Int!
hasNextPage: Boolean!
}
input ProductFilter {
category: String
minPrice: Float
maxPrice: Float
inStock: Boolean
}
type Query {
products(filter: ProductFilter, page: Int, limit: Int): PaginatedProducts!
product(id: ID!): Product
categories: [Category!]!
}
type Mutation {
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
}
input CreateProductInput {
name: String!
price: Float!
categoryId: ID!
}
input UpdateProductInput {
name: String
price: Float
categoryId: ID
inStock: Boolean
}
リゾルバーとサーバー
const resolvers = {
Query: {
products: async (_, { filter, page = 1, limit = 20 }, context) => {
const result = await context.productService.findAll(filter, page, limit);
return {
items: result.items,
total: result.total,
hasNextPage: page * limit < result.total
};
},
product: async (_, { id }, context) => {
return context.productService.findById(id);
},
categories: async (_, __, context) => {
return context.categoryService.findAll();
}
},
Product: {
// Resolver per campi relazionali (caricati solo se richiesti)
reviews: async (product, _, context) => {
return context.reviewService.findByProductId(product.id);
},
averageRating: async (product, _, context) => {
const reviews = await context.reviewService.findByProductId(product.id);
if (reviews.length === 0) return null;
const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
return sum / reviews.length;
},
category: async (product, _, context) => {
return context.categoryService.findById(product.categoryId);
}
},
Mutation: {
createProduct: async (_, { input }, context) => {
return context.productService.create(input);
},
updateProduct: async (_, { id, input }, context) => {
return context.productService.update(id, input);
},
deleteProduct: async (_, { id }, context) => {
await context.productService.delete(id);
return true;
}
}
};
クライアントからの問い合わせ
// Il client richiede ESATTAMENTE i campi che serve
const GET_PRODUCT_LIST = gql`
query GetProducts($filter: ProductFilter, $page: Int) {
products(filter: $filter, page: $page, limit: 20) {
items {
id
name
price
inStock
category {
name
}
}
total
hasNextPage
}
}
`;
// Per la pagina dettaglio servono più campi
const GET_PRODUCT_DETAIL = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
inStock
category {
id
name
}
reviews {
id
author
rating
comment
createdAt
}
averageRating
}
}
`;
tRPC: エンドツーエンドの型安全性
tRPC これは 3 つのアプローチのうちの最新のものであり、パラダイム シフトを表しています。 クライアントとサーバーの両方が存在する場合、クライアントとサーバー間のシリアル化レイヤーを完全に排除します。 TypeScriptで。型推論を使用すると、クライアントは自動的に型を認識します。 外部コード生成やスキーマを必要とせずに、各プロシージャの入力と出力を実現します。
ルーター (サーバー) の定義
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<Context>().create();
// Schema di validazione con Zod
const productFilterSchema = z.object({
category: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().min(0).optional(),
inStock: z.boolean().optional()
});
const createProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
categoryId: z.string().uuid()
});
const updateProductSchema = z.object({
name: z.string().min(1).max(200).optional(),
price: z.number().positive().optional(),
categoryId: z.string().uuid().optional(),
inStock: z.boolean().optional()
});
// Router: definisce tutte le procedure
export const productRouter = t.router({
list: t.procedure
.input(z.object({
filter: productFilterSchema.optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20)
}))
.query(async ({ input, ctx }) => {
const result = await ctx.productService.findAll(
input.filter, input.page, input.limit
);
return {
items: result.items,
total: result.total,
hasNextPage: input.page * input.limit < result.total
};
}),
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const product = await ctx.productService.findById(input.id);
if (!product) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Prodotto non trovato'
});
}
return product;
}),
create: t.procedure
.input(createProductSchema)
.mutation(async ({ input, ctx }) => {
return ctx.productService.create(input);
}),
update: t.procedure
.input(z.object({
id: z.string().uuid(),
data: updateProductSchema
}))
.mutation(async ({ input, ctx }) => {
return ctx.productService.update(input.id, input.data);
}),
delete: t.procedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
await ctx.productService.delete(input.id);
return { success: true };
})
});
export type ProductRouter = typeof productRouter;
完全な型安全性を備えた tRPC クライアント
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { ProductRouter } from '../server/routers/product';
// Il client conosce TUTTI i tipi automaticamente
const trpc = createTRPCClient<ProductRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc'
})
]
});
// Autocompletamento completo e type checking
async function loadProducts() {
// TypeScript sa che il risultato ha items, total, hasNextPage
const result = await trpc.product.list.query({
filter: { category: 'electronics', minPrice: 10 },
page: 1,
limit: 20
});
// Errore di compilazione se accedo a un campo inesistente
// result.items[0].nonExistent // TS Error!
return result;
}
// Anche le mutation sono tipizzate
async function createProduct() {
const newProduct = await trpc.product.create.mutate({
name: 'Nuovo Prodotto',
price: 29.99,
categoryId: '550e8400-e29b-41d4-a716-446655440000'
});
// newProduct ha il tipo Product inferito dal server
console.log(newProduct.id);
}
詳細な比較
完全な比較表
| 待ってます | 休む | グラフQL | tRPC |
|---|---|---|---|
| パラダイム | リソース指向 | クエリ言語 | 型推論を使用した RPC |
| タイプセーフティ | マニュアル (OpenAPI/Swagger) | コードジェネが必要です | 自動エンドツーエンド |
| オーバーフェッチ/アンダーフェッチ | 共通(BFFで解決) | 設計によって解決 | 定義にもよるけど |
| HTTPキャッシュ | ネイティブ (ETag、キャッシュ制御) | 難しい (すべて POST) | GETクエリで可能 |
| 学習曲線 | 低い | 中~高 | 低 (TS を知っている場合) |
| ツーリング | 成熟して広大な | リッコ(アポロ、リレー) | 急速に成長 |
| パフォーマンス | 良い、予想通り | 変数 (N+1 問題) | 優れた (ネイティブ バッチ処理) |
| エラー | HTTPステータスコード | 常に200(レオタードのエラー) | 入力されたエラーコード |
| バージョン管理 | URL またはヘッダーのバージョン管理 | スキーマの非推奨の進化 | ルーターのバージョン管理 |
| ファイルのアップロード | ネイティブ (マルチパート) | 複合(別仕様) | 専用プラグイン |
| リアルタイム | 個別の WebSocket / SSE | ネイティブサブスクリプション | WebSocket経由のサブスクリプション |
| 非TSクライアント | どの言語でも | どの言語でも | TypeScript のみ |
| 成熟 | 20年以上、事実上の標準 | 10 年以上、広く採用されている | 3歳以上、急速に成長 |
意思決定の枠組み
REST、GraphQL、tRPC のいずれを選択するかは絶対的なものではなく、プロジェクトのコンテキストによって異なります。 ここでは、決定を導くためのフレームワークを示します。
次の場合に REST を選択します。
- API は、クライアントがさまざまな言語 (モバイル、サードパーティ) で使用する必要があります。
- ネイティブ HTTP キャッシュはパフォーマンスにとって重要です
- チームは REST に関する統合された経験を持っています
- API はパブリックであり、広く理解されている標準に従う必要があります。
- リソースは明確に定義されており、クエリは予測可能です
- 広範なドキュメントを備えた成熟したエコシステムが必要です
次の場合に GraphQL を選択します。
- さまざまなデータ ニーズ (Web、モバイル、TV) を持つさまざまなクライアントが存在します。
- エンティティ間の関係は複雑で深くネストされている
- オーバーフェッチは実際のパフォーマンス上の問題です (帯域幅が制限されています)
- チームは学習曲線に積極的に投資します
- リアルタイムデータのサブスクリプションシステムが必要です
- API は複数のマイクロサービスからデータを集約します (GraphQL Federation)
次の場合に tRPC を選択します。
- クライアントとサーバーはどちらも TypeScript 内にあります (理想的なモノリポジトリ)
- エンドツーエンドのタイプの安全性が最優先事項です
- チームはオートコンプリートを使用して開発速度を最大化したいと考えています
- サポートする非 TypeScript クライアントはありません
- プロジェクトはフルスタック アプリケーション (Next.js、Nuxt、SvelteKit) です。
- コード生成と手動契約の定型文を排除したいと考えています
セキュリティ: 各アプローチの考慮事項
休む
- 認証: 標準ヘッダー認証 (ベアラートークン、API キー)
- レート制限: エンドポイントの場合、実装が簡単
- 検証: サーバー依存 (Joi、Zod などのミドルウェア)
- コルス: 標準的で十分に文書化された構成
グラフQL
- クエリの深さの制限: 悪意のある再帰クエリを防ぐために必要
- クエリのコスト分析: 実行前の計算コストの見積もり
- 内省: 運用環境でスキーマを公開しないように無効にする
- 永続化されたクエリ: 運用環境では事前承認されたクエリのみ
tRPC
- 統合された検証: Zod はすべての入力を自動的に検証します
- ミドルウェア: 認証、ロギング、レート制限のためのミドルウェア チェーン
- タイプセーフティ: コンパイル時に多くのエラーが検出される
- バッチ処理: タイムアウトとバッチサイズに注意してください
ハイブリッドアプローチ
現実世界の多くのプロジェクトでは、最適なソリューションはハイブリッド アプローチです。例えば:
- 内部フロントエンドの tRPC (管理ダッシュボード、企業ポータル) クライアントとサーバーの両方が TypeScript である場合
- パブリック API の REST サードパーティおよびモバイルクライアント向け
- ゲートウェイとしてのGraphQL 複数のマイクロサービスからデータを集約する
+------------------+
| Web App (TS) |--- tRPC ---+
+------------------+ |
v
+------------------+ +---------------+ +----------------+
| Mobile App | | API Gateway | | Microservizi |
| (Swift/Kotlin) |--->| (REST/GQL) |--->| (gRPC/REST) |
+------------------+ +---------------+ +----------------+
^
+------------------+ |
| Partner APIs |--- REST ---+
+------------------+
結論
REST、GraphQL、tRPC の間に明確な勝者はいません。それぞれのアプローチが優れています 特定のシナリオでは:
- 休む パブリック API、相互運用性、HTTP キャッシュが重要な場合には、依然として最も安全な選択肢です。
- グラフQL データが複雑で、クライアントが異種混合であり、クエリの柔軟性が重要な場合に威力を発揮します
- tRPC フルスタックの TypeScript プロジェクトにおける開発者のエクスペリエンスにおいては無敵です
覚えておくべき重要なポイント
- まずコンテキスト: 選択は、トレンドのテクノロジーではなく、プロジェクトによって決まります
- タイプセーフティ: tRPC は最高のエクスペリエンスを提供しますが、全体的に TypeScript が必要です
- 柔軟性: GraphQL はオーバーフェッチ/アンダーフェッチを解決しますが、複雑さが増します
- 標準: REST は広く理解され、サポートされています
- ハイブリッド: 大規模なプロジェクトでは、多くの場合、複数のアプローチを組み合わせることが最良の選択となります







