GraphQL è un linguaggio di interrogazione (query language) per API e un runtime per eseguire tali query usando un sistema di tipi definito dall’applicazione. Nasce in Facebook (2012) ed è oggi uno standard de-facto aperto, mantenuto dalla GraphQL Foundation. Il suo obiettivo principale è dare ai client il potere di chiedere esattamente i dati necessari, in un’unica richiesta, riducendo sotto- o sovra-fetching e semplificando l’evoluzione delle API.
Perché GraphQL
- Recupero preciso dei dati: il client specifica i campi; niente payload ridondanti.
- Una sola endpoint: di solito
/graphql
, con query/mutation/subscription. - Tipi forti: contratto chiaro e verificabile tramite introspection e strumenti di linting/codegen.
- Evoluzione senza versioni: si aggiungono campi e tipi; la rimozione è gestita con deprecazioni.
- Strumenti ottimi: editor interattivi (GraphiQL/Playground), schema registry, codegen, cache avanzate lato client.
Il cuore: Schema SDL
Lo schema GraphQL descrive tipi, campi e operazioni tramite SDL (Schema Definition Language). Le tre operazioni base sono: Query (lettura), Mutation (scrittura) e Subscription (stream di eventi).
# schema.graphql
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
me: User
post(id: ID!): Post
posts(first: Int = 10, after: Cursor): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
likePost(id: ID!): Post!
}
type Subscription {
postCreated: Post!
}
"Un utente del sistema"
type User {
id: ID!
username: String!
name: String
avatarUrl: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
likes: Int!
createdAt: String!
}
# Pagination stile cursor
scalar Cursor
type PageInfo {
hasNextPage: Boolean!
endCursor: Cursor
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: Cursor!
}
input CreatePostInput {
title: String!
body: String!
}
type CreatePostPayload {
post: Post!
}
Query: chiedere esattamente ciò che serve
query GetFeed($pageSize: Int!, $cursor: Cursor) {
posts(first: $pageSize, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
cursor
node { id title author { username } }
}
}
}
Variabili inviate nella richiesta:
{
"pageSize": 10,
"cursor": null
}
Risposta tipica del server:
{
"data": {
"posts": {
"totalCount": 123,
"pageInfo": { "hasNextPage": true, "endCursor": "YXJyYXljb25uZWN0aW9uOjEw" },
"edges": [
{ "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": "p1", "title": "Hello", "author": { "username": "marta" } } }
]
}
}
}
Mutation: modificare lo stato
mutation CreateAndLike($input: CreatePostInput!, $id: ID!) {
createPost(input: $input) {
post { id title }
}
likePost(id: $id) {
id
likes
}
}
{
"input": { "title": "GraphQL 101", "body": "Introduzione..." },
"id": "p1"
}
Subscription: eventi in tempo reale
subscription OnPostCreated {
postCreated { id title author { username } createdAt }
}
Le Subscription usano WebSocket (o trasporti equivalenti) per push in tempo reale verso il client.
Resolver e ciclo di esecuzione
Il server associa a ogni campo uno o più resolver, funzioni che recuperano i dati necessari. I resolver ricevono argomenti, parent (risultato del livello precedente), context (per autenticazione, loader, database) e info (AST, schema, path).
// Esempio con Apollo Server (Node.js)
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import DataLoader from "dataloader";
import db from "./db.js";
const typeDefs = readFileSync("./schema.graphql", "utf8");
const resolvers = {
Query: {
me: (_p, _a, ctx) => ctx.user,
post: (_p, { id }, _ctx) => db.posts.findById(id),
posts: async (_p, { first, after }, _ctx) => db.posts.paginated({ first, after })
},
Post: {
author: (post, _a, ctx) => ctx.userLoader.load(post.authorId)
},
Mutation: {
createPost: async (_p, { input }, ctx) => {
ctx.authz.assertLoggedIn();
const post = await db.posts.create({ ...input, authorId: ctx.user.id });
ctx.pubsub.publish("POST_CREATED", { postCreated: post });
return { post };
},
likePost: (_p, { id }) => db.posts.like(id)
},
Subscription: {
postCreated: {
subscribe: (_p, _a, { pubsub }) => pubsub.asyncIterator("POST_CREATED")
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
export default server;
Frammenti, alias, direttive
fragment PostPreview on Post { id title author { username } }
query Feed($withLikes: Boolean!) {
posts(first: 5) {
edges {
node {
...PostPreview
likes @include(if: $withLikes)
}
}
}
}
# Alias per rinominare i campi in uscita
query OnePost { post(id: "p1") { title authorName: author { username } } }
Paginazione: offset vs cursor
- Offset/limit: semplice ma fragile con dati in movimento (salti/duplicati).
- Cursor-based: robusta; si usa un
Connection
conedges
,node
,cursor
epageInfo
(vedi schema sopra).
Confronto rapido con REST
Aspetto | REST | GraphQL |
---|---|---|
Struttura | Molte risorse/endpoint | Un endpoint, schema tipizzato |
Payload | Fisso per endpoint | Selezione dinamica dei campi |
Versionamento | v1, v2, ... | Deprecation e aggiunta campi |
Caching | HTTP/URL-based | A livello campo/operazione; dipende dal client |
Batching | Multipli round-trip | Una query, DataLoader per N+1 |
Errori e formati di risposta
Una risposta GraphQL ha sempre data
e opzionalmente errors
. Gli errori possono essere parziali: alcuni campi falliscono, altri no. Il campo extensions
è utile per codici proprietari, tracciamento, retry hint.
{
"data": { "post": null },
"errors": [
{
"message": "Post not found",
"path": ["post"],
"extensions": { "code": "NOT_FOUND", "requestId": "abc-123" }
}
]
}
Sicurezza e governance
- Autenticazione: tipicamente via header (es. Bearer), risolta nel context.
- Autorizzazione: per campo/operazione; centralizzare policy nel contesto o nei resolver.
- Query cost analysis: limitare profondità, ampiezza e complessità per prevenire abusi.
- Persisted Query: whitelisting di hash di query note per ridurre rischio e dimensione payload.
- Introspection: utile in sviluppo; valutarne la disabilitazione o filtraggio in produzione.
- Rate limiting: a livello utente/chiave/API gateway; integrare con metriche per operazione.
- Upload file: supportare lo spec multipart (scalar
Upload
) quando necessario.
Prestazioni: N+1, batching e cache
Il problema N+1 emerge quando per ogni elemento si fa una query separata (es. 100 post ⇒ 100 query autore). La soluzione canonica è DataLoader (o equivalenti) per batch e caching per richiesta.
import DataLoader from "dataloader";
import db from "./db.js";
function makeUserLoader() {
return new DataLoader(async (ids) => {
const rows = await db.users.findByIds(ids);
const map = new Map(rows.map(u => [u.id, u]));
return ids.map(id => map.get(id) || null);
});
}
// Nel server
const context = async ({ req }) => ({
user: await authenticate(req),
userLoader: makeUserLoader()
});
Direttive avanzate: @defer e @stream
GraphQL supporta la consegna incrementale per migliorare il TTFB su payload pesanti: @defer
per campi lenti e @stream
per liste.
query ProductPage($id: ID!) {
product(id: $id) {
id
name
# invia i reviews più tardi senza bloccare il resto
reviews @defer { author { username } body }
similar @stream(initialCount: 3) { id name }
}
}
Schema design: buone pratiche
- Naming consistente: campi in
camelCase
, tipi inPascalCase
. - Input vs output: usare
input
per mutation; evitare parametri scalari sparsi. - Nullability: pensare ai
!
come contratti; evitare non-null “a pioggia”. - Enum per valori chiusi; Union/Interface per polimorfismo.
- Deprecation: usare
@deprecated(reason: "...")
su campi e enum.
type Post {
id: ID!
title: String!
body: String!
author: User!
teaser: String @deprecated(reason: "Usa 'excerpt' con lunghezza configurabile")
excerpt(length: Int = 120): String!
}
Code-first vs Schema-first
- Schema-first: scrivi SDL e poi resolver; eccellente per collaborazione e governance.
- Code-first: definisci tipi in codice (es. decorators/TS) e generi lo schema; ottimo DX e tipi 1:1 col modello.
Esempio end-to-end (Node.js)
# Installazione base
npm i @apollo/server graphql
// index.js
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const typeDefs = `#graphql
type Query { hello: String! }
`;
const resolvers = {
Query: { hello: () => "Ciao GraphQL!" }
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`Server pronto su ${url}`);
# Prova con curl
curl -X POST http://localhost:4000/ \
-H "content-type: application/json" \
-d '{ "query": "query { hello }" }'
{
"data": { "hello": "Ciao GraphQL!" }
}
Client: cache e tipizzazione
- Apollo Client: cache normalizzata, reactive variables, persisted queries, link chain.
- Relay: focus su connessioni, colocation dei dati, ottimo per app complesse.
- urql e graphql-request: alternative leggere.
- Codegen: generare tipi TS e hook a partire dallo schema/operazioni.
// Esempio Apollo Client
import { ApolloClient, InMemoryCache, gql } from "@apollo/client";
const client = new ApolloClient({
uri: "/graphql",
cache: new InMemoryCache()
});
const GET_ME = gql`query { me { id username } }`;
client.query({ query: GET_ME }).then(r => console.log(r.data));
Federazione e architetture
- Monolitico: semplice da iniziare.
- Federato: più “subgraph” compongono lo supergraph (es. Apollo Federation); ogni team possiede il proprio sottodominio dati.
- Schema stitching: unisce schemi esistenti (REST/GraphQL) in uno schema composito.
- BFF (Backend for Frontend): server GraphQL per app/mobile con orchestrazione su servizi interni REST/gRPC.
Upload, file e scalari personalizzati
Oltre agli scalari standard (Int
, Float
, String
, Boolean
, ID
) puoi definire scalari custom (es. DateTime
, URL
, Email
) e supportare upload via Upload
con protocollo multipart.
scalar DateTime
scalar URL
type Media {
id: ID!
url: URL!
uploadedAt: DateTime!
}
Test, osservabilità e migrazioni
- Test unitari per resolver e utility (mock del context/DB).
- Contract testing: snapshot delle operazioni client e breaking-change detection.
- Tracing e metrics: tempi per campo, error rate, dimensione risposta, profondità media.
- Migrazioni schema: processo di deprecazione, comunicazione ai client, rimozione pianificata.
Anti-pattern comuni
- Esportare uno schema che replica tabelle DB 1:1 senza modellare casi d’uso.
- Campi
Non-Null
ovunque, che impediscono risposte parziali utili. - Ignorare il problema N+1 su relazioni.
- Usare query enormi invece di frammentarle e sfruttare
@defer
/@stream
. - Affidarsi solo al gateway per sicurezza, trascurando autorizzazioni per campo.
Checklist per portare in produzione
- Limit di profondità/complessità e max query time.
- Persisted queries per le operazioni critiche e caching su gateway/CDN.
- Observability: tracing, log arricchiti, correlazione per
requestId
. - Politiche di errore coerenti & mapping a codici applicativi.
- Processo di deprecazione documentato e automatizzato (schema registry).
- Backup/rollout sicuri per lo schema e migrazioni DB.
Risorse consigliate
Conclusione
GraphQL offre un modello dichiarativo, tipizzato e componibile per esporre dati e orchestrare servizi. Inizia con uno schema centrato sui casi d’uso, aggiungi resolver efficienti con DataLoader, implementa sicurezza e osservabilità per campo e scala verso federazione quando l’organizzazione lo richiede. Con queste basi, potrai costruire API flessibili, evolvibili e veloci, gradite sia ai client sia ai team backend.