Introduzione a GraphQL

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 con edges, node, cursor e pageInfo (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 in PascalCase.
  • 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

  1. Limit di profondità/complessità e max query time.
  2. Persisted queries per le operazioni critiche e caching su gateway/CDN.
  3. Observability: tracing, log arricchiti, correlazione per requestId.
  4. Politiche di errore coerenti & mapping a codici applicativi.
  5. Processo di deprecazione documentato e automatizzato (schema registry).
  6. 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.

Torna su