Salta al contenuto principale

5 - REST vs GraphQL vs tRPC: Quando Usare Cosa

Confronto approfondito tra REST, GraphQL e tRPC: performance, developer experience, sicurezza, caching e decision framework.

Cosa ne pensi di questo articolo?

5 - REST vs GraphQL vs tRPC: Quando Usare Cosa
Architettura SoftwareFoto:panumas nikhomkhaiPexels

In Breve

Confronto approfondito tra REST, GraphQL e tRPC: performance, developer experience, sicurezza, caching e decision framework.

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

Server REST con Express e 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();
});
    
Client REST con Angular HttpClient

@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

// 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

Resolver GraphQL con Apollo 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

Query GraphQL 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)

tRPC Router con validazione Zod

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

Client tRPC: i tipi sono inferiti automaticamente

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
Esempio di architettura ibrida

        +------------------+
        |   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.

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti