REST vs GraphQL vs tRPC: Quale Scegliere?
La scelta del paradigma di comunicazione tra client e server è una delle decisioni architetturali più importanti in un progetto moderno. REST, GraphQL e tRPC rappresentano tre approcci radicalmente diversi, ciascuno con filosofie, punti di forza e compromessi propri.
In questo articolo analizzeremo in profondità ciascun approccio, confronteremo le loro caratteristiche in una tabella dettagliata e costruiremo un framework decisionale per scegliere quello giusto per il vostro prossimo progetto.
Cosa Imparerai
- I fondamentali di REST e il modello di maturita di Richardson
- Schema, resolver e codegen di GraphQL
- La type safety end-to-end di tRPC
- Confronto dettagliato: performance, DX, caching, sicurezza
- Un framework decisionale per la scelta
- Esempi pratici di codice per ciascun approccio
REST: Il Veterano Affidabile
REST (Representational State Transfer) è lo stile architetturale più diffuso per le API web. Definito da Roy Fielding nella sua tesi di dottorato nel 2000, si basa su risorse identificate da URL, metodi HTTP standard e rappresentazioni stateless.
Il Modello di Maturita di Richardson
Leonard Richardson ha definito quattro livelli di maturita per le API REST:
- Livello 0 - Il tunnel: Un singolo endpoint che accetta tutto (stile RPC su HTTP)
- Livello 1 - Risorse: Endpoint diversi per risorse diverse, ma un solo metodo HTTP
- Livello 2 - Verbi HTTP: Uso corretto di GET, POST, PUT, DELETE e codici di stato
- Livello 3 - HATEOAS: Le risposte contengono link alle azioni successive (ipermedia)
Esempio REST in TypeScript
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: La Flessibilità Totale
GraphQL, creato da Facebook nel 2012 e rilasciato pubblicamente nel 2015, offre un approccio completamente diverso: il client specifica esattamente quali dati vuole ricevere. Questo elimina i problemi di over-fetching (ricevere troppi dati) e under-fetching (dover fare più richieste per ottenere tutti i dati necessari).
Schema e Type System
// 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
}
Resolver e Server
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;
}
}
};
Query dal Client
// 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: Type Safety End-to-End
tRPC è il più recente dei tre approcci e rappresenta un cambio di paradigma: elimina completamente il livello di serializzazione tra client e server quando entrambi sono in TypeScript. Grazie all'inferenza dei tipi, il client conosce automaticamente i tipi di input e output di ogni procedura senza bisogno di codegen o schema esterni.
Definizione del Router (Server)
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;
Client tRPC con Type Safety Completa
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);
}
Confronto Dettagliato
Tabella Comparativa Completa
| Aspetto | REST | GraphQL | tRPC |
|---|---|---|---|
| Paradigma | Resource-oriented | Query language | RPC con type inference |
| Type Safety | Manuale (OpenAPI/Swagger) | Codegen necessario | Automatica end-to-end |
| Over/Under-fetching | Comune (risolto con BFF) | Risolto by design | Dipende dalla definizione |
| Caching HTTP | Nativo (ETags, Cache-Control) | Difficile (tutto POST) | Possibile per query GET |
| Learning Curve | Bassa | Media-alta | Bassa (se conosci TS) |
| Tooling | Maturo e vasto | Ricco (Apollo, Relay) | In crescita rapida |
| Performance | Buona, prevedibile | Variabile (N+1 problem) | Ottima (batching nativo) |
| Errori | Status codes HTTP | Sempre 200 (errori nel body) | Error codes tipizzati |
| Versioning | URL o header versioning | Evoluzione schema deprecation | Versioning del router |
| File Upload | Nativo (multipart) | Complesso (spec separata) | Plugin dedicati |
| Real-time | WebSocket separato / SSE | Subscriptions native | Subscriptions via WebSocket |
| Client non-TS | Qualsiasi linguaggio | Qualsiasi linguaggio | Solo TypeScript |
| Maturita | 20+ anni, standard de facto | 10+ anni, ampiamente adottato | 3+ anni, in rapida crescita |
Framework Decisionale
La scelta tra REST, GraphQL e tRPC non è assoluta: dipende dal contesto del progetto. Ecco un framework per guidare la decisione.
Scegli REST quando:
- L'API deve essere consumata da client in linguaggi diversi (mobile, terze parti)
- Il caching HTTP nativo è fondamentale per le performance
- Il team ha esperienza consolidata con REST
- L'API è pubblica e deve seguire standard universalmente compresi
- Le risorse sono ben definite e le query sono prevedibili
- È necessario un ecosistema maturo con vasta documentazione
Scegli GraphQL quando:
- Esistono molti client diversi con esigenze di dati differenti (web, mobile, TV)
- Le relazioni tra entità sono complesse e profondamente annidate
- L'over-fetching è un problema reale di performance (banda limitata)
- Il team è disponibile a investire nella curva di apprendimento
- È necessario un sistema di subscriptions per dati real-time
- L'API aggrega dati da microservizi multipli (GraphQL Federation)
Scegli tRPC quando:
- Client e server sono entrambi in TypeScript (monorepo ideale)
- La type safety end-to-end è una priorità assoluta
- Il team vuole la massima velocità di sviluppo con autocompletamento
- Non ci sono client non-TypeScript da supportare
- Il progetto e un'applicazione full-stack (Next.js, Nuxt, SvelteKit)
- Si vuole eliminare il boilerplate di codegen e contratti manuali
Sicurezza: Considerazioni per Ogni Approccio
REST
- Autenticazione: Header Authorization standard (Bearer token, API key)
- Rate limiting: Per endpoint, semplice da implementare
- Validazione: A carico del server (middleware come Joi, Zod)
- CORS: Configurazione standard e ben documentata
GraphQL
- Query depth limiting: Necessario per prevenire query ricorsive malevole
- Query cost analysis: Stima del costo computazionale prima dell'esecuzione
- Introspection: Disabilitare in produzione per non esporre lo schema
- Persisted queries: Solo query pre-approvate in produzione
tRPC
- Validazione integrata: Zod valida automaticamente ogni input
- Middleware: Catena di middleware per auth, logging, rate limiting
- Type safety: Molti errori sono catturati a compile-time
- Batching: Attenzione al timeout e alla dimensione dei batch
L'Approccio Ibrido
In molti progetti reali, la soluzione migliore è un approccio ibrido. Ad esempio:
- tRPC per il frontend interno (admin dashboard, portale aziendale) dove client e server sono entrambi TypeScript
- REST per le API pubbliche destinate a terze parti e client mobili
- GraphQL come gateway per aggregare dati da microservizi multipli
+------------------+
| Web App (TS) |--- tRPC ---+
+------------------+ |
v
+------------------+ +---------------+ +----------------+
| Mobile App | | API Gateway | | Microservizi |
| (Swift/Kotlin) |--->| (REST/GQL) |--->| (gRPC/REST) |
+------------------+ +---------------+ +----------------+
^
+------------------+ |
| Partner APIs |--- REST ---+
+------------------+
Conclusione
Non esiste un vincitore assoluto tra REST, GraphQL e tRPC. Ogni approccio eccelle in scenari specifici:
- REST rimane la scelta più sicura per API pubbliche, interoperabilità e quando il caching HTTP è critico
- GraphQL brilla quando i dati sono complessi, i client sono eterogenei e la flessibilità delle query è fondamentale
- tRPC è imbattibile per la developer experience in progetti full-stack TypeScript
Punti Chiave da Ricordare
- Contesto prima: La scelta dipende dal progetto, non dalla tecnologia di tendenza
- Type safety: tRPC offre la migliore esperienza, ma richiede TypeScript ovunque
- Flessibilità: GraphQL risolve over/under-fetching ma aggiunge complessità
- Standard: REST è universalmente compreso e supportato
- Ibrido: In progetti grandi, combinare più approcci e spesso la scelta migliore
Domande frequenti
Qual è la principale differenza tra il paradigma di comunicazione REST e GraphQL?
La principale differenza sta nel fatto che GraphQL consente al client di specificare esattamente quali dati vuole ricevere, eliminando i problemi di over-fetching e under-fetching.
Qual è l'avvantaggio principale del paradigma tRPC rispetto agli altri due?
L'avvantaggio principale del paradigma tRPC è che elimina completamente il livello di serializzazione tra client e server quando entrambi sono in TypeScript, grazie all'inferenza dei tipi.
Come si sceglie tra i tre paradigmi di comunicazione?
La scelta dipende dal contesto del progetto e può essere guidata da un framework decisionale che considera fattori come la sicurezza e le esigenze specifiche del progetto.
È possibile utilizzare più di un paradigma di comunicazione in un progetto?
Sì, molti progetti reali utilizzano un approccio ibrido, combinando due o più dei tre paradigmi per soddisfare le esigenze specifiche del progetto.

Commenti
Caricamento commenti...
Accedi per commentare