Salta al contenuto principale

03 - GraphQL: Query Language, Resolver e il Problema N+1

GraphQL elimina l'over-fetching, ma ogni campo ha un resolver: 10 utenti con ordini diventano 211 query. Con DataLoader scendono a 4, misurate.

Cosa ne pensi di questo articolo?

03 - GraphQL: Query Language, Resolver e il Problema N+1
API DesignFoto:Stanislav KondratievPexels

In Breve

GraphQL elimina l'over-fetching, ma ogni campo ha un resolver: 10 utenti con ordini diventano 211 query. Con DataLoader scendono a 4, misurate.

Con una REST API il client riceve tutti i campi di una risorsa anche quando gliene servono due, e per un profilo completo con ordini e prodotti spesso fa più richieste. GraphQL toglie questo problema: il client scrive una sola query in cui elenca i campi che vuole, e il server costruisce la risposta risolvendo un campo alla volta. Il prezzo è un difetto strutturale che colpisce quasi ogni primo server GraphQL: il problema N+1. Una query che sembra innocua, «dieci utenti con i loro ordini, le righe e i prodotti», può diventare 211 query al database, come misuro più sotto; con DataLoader, la stessa query ne costa 4.

L'articolo è il terzo della serie: il confronto fra i protocolli è nel panorama delle API e la controparte REST in REST nel 2026. Tutti i listati sono stati eseguiti con Node 24 e Apollo Server 5, senza aprire porte: il server viene interrogato in processo e la «base dati» è un oggetto in memoria che registra ogni query come farebbe un log SQL. I numeri che leggi sono quindi conteggi, riproducibili con gli stessi file, e non tempi.

Lo schema: il contratto tipizzato

Ogni API GraphQL parte da uno schema scritto in SDL (Schema Definition Language): elenca i tipi, i campi di ciascuno e i punti di ingresso, Query per le letture e Mutation per le scritture. Il punto delicato è il punto esclamativo: String! significa «mai nullo», [Order!]! una lista non nulla di ordini non nulli. Nello schema qui sotto User.orders è non nullo mentre User.profile è nullable, perché non tutti gli utenti hanno un profilo. Questa distinzione ha una conseguenza sugli errori, che vedrai nell'ultima sezione. Ho lasciato fuori le Subscription (flussi di eventi), che richiedono un trasporto a parte e non sono trattate qui.

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!       # un utente ha molti ordini
  profile: UserProfile    # nullable: non tutti hanno un profilo
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  items: [OrderItem!]!
  user: User!             # relazione inversa: da ordine a utente
}

type OrderItem {
  id: ID!
  quantity: Int!
  product: Product!
}

type Product {
  id: ID!
  name: String!
  price: Float!
}

type UserProfile {
  bio: String
  website: String
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

input CreateUserInput {
  name: String!
  email: String!
}

# Output (node schema-check.ts):
# campi di Query    : user, users
# campi di User     : id, name, email, orders, profile
# User.profile è non-null? false | User.orders è non-null? true

L'uscita in coda viene da un breve script di controllo (non riportato) che carica il file con buildSchema della libreria graphql: se mancasse un tipo, per esempio un input usato ma non definito, o la sintassi fosse sbagliata, lancerebbe un errore. Invece stampa i campi di Query e User e conferma che profile è nullable e orders non lo è. Nota anche che i commenti in SDL iniziano con #, non con //.

Come GraphQL esegue una query

Per ogni campo richiesto GraphQL chiama una funzione, il resolver, che riceve quattro argomenti: l'oggetto padre (il risultato del campo che contiene quello corrente), gli argomenti della query, il contesto condiviso per la richiesta (database, utente autenticato, loader) e le informazioni sulla query. I campi semplici, come User.name, non hanno bisogno di un resolver: il valore si legge dall'oggetto padre. Un resolver serve dove il dato va cercato altrove, come in User.orders.

Nel listato ogni resolver aggiunge una riga a trace prima di lavorare. La query chiede due utenti con i loro ordini, quindi ti aspetti una chiamata per la radice e una per ogni utente, che è proprio ciò che l'uscita mostra. È anche la prima occasione per vedere il difetto: User.orders viene chiamato una volta per utente.

// trace.ts - in che ordine GraphQL chiama i resolver? Lo scopriamo registrando ogni chiamata
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; trace: string[] };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db, trace }: Ctx) => {
      trace.push(`Query.users(limit: ${limit})`);
      return db.findUsers(limit);
    },
  },
  User: {
    orders: (user: { id: string }, _: unknown, { db, trace }: Ctx) => {
      trace.push(`User.orders   parent=${user.id}`);
      return db.findOrdersByUserIds([user.id]);
    },
  },
};

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const trace: string[] = [];
const query = `{ users(limit: 2) { id name orders { id total } } }`;
const res = await server.executeOperation({ query }, { contextValue: { db: seed(), trace } });
if (res.body.kind !== "single") throw new Error("risposta inattesa");
console.log(trace.join("\n"));
const first = (res.body.singleResult.data as any).users[0];
console.log(`primo utente: ${first.name}, ordini ricevuti: ${first.orders.length}`);
await server.stop();

// Output (node trace.ts):
// Query.users(limit: 2)
// User.orders   parent=u1
// User.orders   parent=u2
// primo utente: Utente 1, ordini ricevuti: 5

La specifica dice che l'esecutore può eseguire i campi di una selezione «in qualunque ordine ritenga ottimale (normalmente in parallelo)», perché la risoluzione dei campi di lettura deve essere priva di effetti collaterali (sezione 6.3.1), mentre i campi radice di una mutation si eseguono in serie (sezione 6.2.2). Per te significa due cose: non contare mai sull'ordine di chiamata dei resolver di lettura, e non mettere effetti collaterali in un resolver di Query.

Il problema N+1 misurato

Il nome viene dal conteggio: una query per ottenere la lista (la «1») e una query per ognuno dei suoi N elementi (le «N»). Nel listato i resolver relazionali interrogano direttamente la base dati: User.orders una volta per utente, Order.items una volta per ordine, OrderItem.product una volta per riga. I dati sono dieci utenti con cinque ordini ciascuno, ogni ordine con tre righe, su quindici prodotti distinti. L'ultima riga stampa, con db.report(), quante query ha visto ciascuna tabella.

// naive.ts - resolver SENZA DataLoader: ogni campo relazionale interroga la base dati
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db }: Ctx) => db.findUsers(limit),
  },
  User: {
    orders: async (user: { id: string }, _: unknown, { db }: Ctx) =>
      db.findOrdersByUserIds([user.id]), // una query per OGNI utente
  },
  Order: {
    items: async (order: { id: string }, _: unknown, { db }: Ctx) =>
      db.findItemsByOrderIds([order.id]), // una query per OGNI ordine
  },
  OrderItem: {
    product: async (item: { productId: string }, _: unknown, { db }: Ctx) =>
      (await db.findProductsByIds([item.productId]))[0], // una query per OGNI riga
  },
};

const query = `{ users(limit: 10) { name orders { total items { quantity product { name } } } } }`;

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const db = seed();
const res = await server.executeOperation({ query }, { contextValue: { db } });
if (res.body.kind !== "single") throw new Error("risposta inattesa");
console.log("errori:", res.body.singleResult.errors ?? "nessuno");
for (const line of db.report()) console.log(line);
await server.stop();

// Output (node naive.ts):
// errori: nessuno
// totale: 211
// users: 1
// orders: 10
// order_items: 50
// products: 150

Il conto torna: 1 + 10 + 50 + 150 = 211. Ogni livello di annidamento moltiplica il precedente, quindi con cento utenti le query diventano 2101 senza che la query GraphQL cambi di una virgola. In un database reale ogni viaggio costa un'andata e ritorno di rete e un posto in coda nel pool di connessioni, e in questo la forma «innocente» della query inganna: dal punto di vista del client è una richiesta sola. Il problema non è specifico di GraphQL (qualunque ciclo che interroga il database dentro un altro ciclo lo produce), ma GraphQL lo rende facile perché sono i resolver, chiamati uno per campo, a nascondere il ciclo.

DataLoader: raggruppare e ricordare

DataLoader risolve il problema con due meccanismi. Il batching raccoglie tutte le chiamate a load(chiave) fatte nello stesso «giro» dell'event loop e invoca la funzione di caricamento una volta sola con tutte le chiavi. La memoizzazione ricorda il risultato di ogni chiave, quindi la stessa chiave chiesta due volte costa una sola lettura. Nasce da un progetto di Facebook del 2010, ed è oggi mantenuto sotto l'organizzazione GitHub graphql, come racconta il suo README.

Il contratto della funzione di caricamento è rigido e vale la pena ricordarlo: riceve un array di chiavi e deve restituire una promessa di un array della stessa lunghezza e nello stesso ordine. Per questo il listato ha l'aiutante groupBy, che raggruppa le righe per chiave e poi percorre le chiavi originali, inserendo una lista vuota dove non c'è nessun ordine, e productById che restituisce undefined per un id sconosciuto. Il punto che fa la differenza è createLoaders(db): è una funzione, e va chiamata una volta per ogni richiesta.

// loaders.ts - un DataLoader per ogni relazione, creati UNA VOLTA PER RICHIESTA
import DataLoader from "dataloader";
import type { Db, Order, OrderItem, Product } from "./db.ts";

// Raggruppa le righe per chiave e restituisce un array nello stesso ordine delle chiavi
function groupBy<T>(rows: T[], keyOf: (row: T) => string, keys: readonly string[]): T[][] {
  const map = new Map<string, T[]>();
  for (const row of rows) map.set(keyOf(row), [...(map.get(keyOf(row)) ?? []), row]);
  return keys.map((k) => map.get(k) ?? []);
}

export function createLoaders(db: Db) {
  return {
    ordersByUserId: new DataLoader<string, Order[]>(async (userIds) =>
      groupBy(await db.findOrdersByUserIds(userIds), (o) => o.userId, userIds)),

    itemsByOrderId: new DataLoader<string, OrderItem[]>(async (orderIds) =>
      groupBy(await db.findItemsByOrderIds(orderIds), (i) => i.orderId, orderIds)),

    productById: new DataLoader<string, Product | undefined>(async (ids) => {
      const byId = new Map((await db.findProductsByIds(ids)).map((p) => [p.id, p]));
      return ids.map((id) => byId.get(id)); // stesso ordine, stessa lunghezza delle chiavi
    }),
  };
}
export type Loaders = ReturnType<typeof createLoaders>;

Nel listato successivo i resolver non chiamano più la base dati: chiamano loaders.ordersByUserId.load(user.id) e analoghi. La query è esattamente quella di prima, e il contesto di ogni richiesta contiene un nuovo insieme di loader. L'uscita, oltre al conteggio, stampa le query SQL effettivamente emesse.

// batched.ts - la stessa query di naive.ts, con i resolver che passano dai DataLoader
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";
import { createLoaders, type Loaders } from "./loaders.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; loaders: Loaders };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db }: Ctx) => db.findUsers(limit),
  },
  User: {
    orders: (user: { id: string }, _: unknown, { loaders }: Ctx) => loaders.ordersByUserId.load(user.id),
  },
  Order: {
    items: (order: { id: string }, _: unknown, { loaders }: Ctx) => loaders.itemsByOrderId.load(order.id),
  },
  OrderItem: {
    product: (item: { productId: string }, _: unknown, { loaders }: Ctx) => loaders.productById.load(item.productId),
  },
};

const query = `{ users(limit: 10) { name orders { total items { quantity product { name } } } } }`;

const server = new ApolloServer<Ctx>({ typeDefs, resolvers });
await server.start();
const db = seed();
const res = await server.executeOperation({ query }, { contextValue: { db, loaders: createLoaders(db) } });
if (res.body.kind !== "single") throw new Error("risposta inattesa");
console.log("errori:", res.body.singleResult.errors ?? "nessuno");
for (const line of db.report()) console.log(line);
console.log(db.queries.join("\n"));
await server.stop();

// Output (node batched.ts):
// errori: nessuno
// totale: 4
// users: 1
// orders: 1
// order_items: 1
// products: 1
// SELECT * FROM users LIMIT 10
// SELECT * FROM orders WHERE user_id IN (10 id)
// SELECT * FROM order_items WHERE order_id IN (50 id)
// SELECT * FROM products WHERE id IN (15 id)

Da 211 query a 4: una per tabella. Due dettagli meritano attenzione. Il primo è order_items WHERE order_id IN (50 id): le cinquanta chiavi sono i cinquanta ordini, tutti raccolti nello stesso giro. Il secondo è products WHERE id IN (15 id): le righe sono 150, ma i prodotti distinti 15, e la memoizzazione ha ridotto le chiavi a quindici prima di chiamare la funzione. Nota che ho aggiunto un loader per le righe degli ordini, itemsByOrderId, che è facile dimenticare: se Order.items interrogasse ancora il database direttamente, resterebbero 50 query, una per ordine.

Come DataLoader decide quando raggruppare

Il batching dipende da una regola precisa: secondo il README, DataLoader riunisce tutte le load() che avvengono «in un singolo frame di esecuzione (un singolo tick dell'event loop)» e poi chiama la funzione con le chiavi raccolte. Il listato prova quattro situazioni, ognuna con una conseguenza pratica, e ne stampa l'effetto reale.

// ticks.ts - quattro esperimenti sul comportamento di DataLoader
import DataLoader from "dataloader";

const nomi: Record<string, string> = { u1: "Anna", u2: "Bruno", u3: "Chiara" };
const batches: string[][] = [];
const nuovo = () =>
  new DataLoader<string, string>(async (keys) => {
    batches.push([...keys]);
    return keys.map((k) => nomi[k]);
  });

// 1. Tre load() nello stesso "giro" dell'event loop: un solo batch, e la chiave ripetuta conta una volta
let loader = nuovo();
await Promise.all([loader.load("u1"), loader.load("u2"), loader.load("u1")]);
console.log("stesso giro      ->", JSON.stringify(batches));

// 2. Una load() dopo un'attesa su un timer: la finestra si è chiusa, nuovo batch
batches.length = 0;
loader = nuovo();
const a = loader.load("u1");
const b = new Promise<string>((ok) => setTimeout(() => ok(loader.load("u2")), 0));
await Promise.all([a, b]);
console.log("giri diversi     ->", JSON.stringify(batches));

// 3. Una batch function che restituisce meno risultati delle chiavi
const rotto = new DataLoader<string, string>(async (keys) => [nomi[keys[0]]]);
try {
  await Promise.all([rotto.load("u1"), rotto.load("u2")]);
} catch (e) {
  console.log("lunghezza errata ->", (e as Error).message);
}

// 4. La cache di un loader condiviso tra richieste serve il dato vecchio
nomi.u1 = "Anna";
const condiviso = nuovo();
console.log("richiesta 1      ->", await condiviso.load("u1"));
nomi.u1 = "Anna Maria";
console.log("richiesta 2      ->", await condiviso.load("u1"), "(dato di prima)");
console.log("loader nuovo     ->", await nuovo().load("u1"));

// Output (node ticks.ts):
// stesso giro      -> [["u1","u2"]]
// giri diversi     -> [["u1"],["u2"]]
// lunghezza errata -> DataLoader must be constructed with a function which accepts Array<key> and returns Promise<Array<value>>, but the function did not return a Promise of an Array of the same length as the Array of keys.
//
// Keys:
// u1,u2
//
// Values:
// Anna
// richiesta 1      -> Anna
// richiesta 2      -> Anna (dato di prima)
// loader nuovo     -> Anna Maria

Le prime due righe mostrano la regola: tre load() nello stesso giro producono un solo batch, in cui la chiave ripetuta compare una volta sola, mentre una load() eseguita dopo un setTimeout arriva a finestra chiusa e genera un secondo batch. In un resolver succede quando il codice attende un timer prima di caricare: il raggruppamento si perde senza errori, e te ne accorgi solo contando le query. La terza riga è il messaggio reale di DataLoader quando la funzione restituisce meno valori delle chiavi: l'errore è chiaro, e conferma che il contratto «stessa lunghezza, stesso ordine» è verificato a runtime. L'ultimo esperimento spiega perché i loader si creano per richiesta: un loader condiviso continua a servire «Anna» anche dopo che il dato è cambiato in «Anna Maria». Il README lo dice esplicitamente, raccomandando un nuovo DataLoader per richiesta perché una cache condivisa fra utenti con permessi diversi può essere pericolosa.

Errori e difese: che cosa si vede davvero

GraphQL non usa i codici di stato HTTP come REST per dire che cosa è andato storto. Il listato finale interroga Apollo Server a livello HTTP (con executeHTTPGraphQLRequest, ancora senza porte) in quattro casi: una query semplice senza autenticazione, la stessa con un utente, una query troppo profonda e una troppo costosa. Le ultime due sono bloccate da regole di validazione: depthLimit(3) della libreria graphql-depth-limit e createComplexityLimitRule(60) di graphql-validation-complexity. I numeri 3 e 60 sono scelti per questo schema di prova, non sono valori consigliati.

// guard.ts - errori in GraphQL e difese contro le query troppo profonde
import { ApolloServer, HeaderMap } from "@apollo/server";
import { GraphQLError } from "graphql";
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";
import { readFileSync } from "node:fs";
import { seed, type Db } from "./db.ts";

const typeDefs = readFileSync("./schema.graphql", "utf8");
type Ctx = { db: Db; currentUser: { id: string } | null };

const resolvers = {
  Query: {
    users: (_: unknown, { limit }: { limit: number }, { db, currentUser }: Ctx) => {
      if (!currentUser) {
        throw new GraphQLError("Autenticazione richiesta", { extensions: { code: "UNAUTHENTICATED" } });
      }
      return db.findUsers(Math.min(limit, 100)); // tetto di sicurezza sul numero di righe
    },
  },
};

const server = new ApolloServer<Ctx>({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(3), // al massimo 3 livelli di annidamento sotto la radice
    createComplexityLimitRule(60, { formatErrorMessage: (cost: number) => `Query troppo costosa (${cost}), massimo 60` }),
  ],
});
await server.start();

async function post(query: string, currentUser: Ctx["currentUser"]) {
  const r = await server.executeHTTPGraphQLRequest({
    httpGraphQLRequest: {
      method: "POST",
      headers: new HeaderMap([["content-type", "application/json"]]),
      search: "",
      body: { query },
    },
    context: async () => ({ db: seed(), currentUser }),
  });
  const body = JSON.parse(r.body.kind === "complete" ? r.body.string : "{}");
  return { status: r.status ?? 200, body };
}

const semplice = `{ users(limit: 2) { name } }`;
const profonda = `{ users(limit: 2) { orders { items { product { name } } } } }`;
const costosa = `{ users(limit: 2) { orders { total items { quantity } } } }`;

let r = await post(semplice, null);
console.log("anonimo, query semplice :", r.status, r.body.errors[0].extensions.code, "data =", r.body.data);
r = await post(semplice, { id: "u1" });
console.log("autenticato, semplice   :", r.status, JSON.stringify(r.body));
r = await post(profonda, { id: "u1" });
console.log("autenticato, profonda   :", r.status, r.body.errors?.[0]?.message ?? "ok");
r = await post(costosa, { id: "u1" });
console.log("autenticato, costosa    :", r.status, r.body.errors?.[0]?.message ?? "ok");
await server.stop();

// Output (node guard.ts):
// anonimo, query semplice : 200 UNAUTHENTICATED data = null
// autenticato, semplice   : 200 {"data":{"users":[{"name":"Utente 1"},{"name":"Utente 2"}]}}
// autenticato, profonda   : 400 '' exceeds maximum operation depth of 3
// autenticato, costosa    : 400 Query troppo costosa (1100), massimo 60

Il primo caso è il più istruttivo. Il resolver lancia un GraphQLError con il codice UNAUTHENTICATED, e il risultato ha stato HTTP 200 (Apollo lascia lo stato indefinito, che HTTP legge come 200: il listato lo stampa con r.status ?? 200) e data = null. Non è una svista: users è dichiarato [User!]!, quindi l'errore su un campo non nullo risale al padre, e siccome fino alla radice tutti i campi sono non nulli, la specifica prescrive che l'intera voce data sia nulla (sezione 6.4.4). In pratica un client non può affidarsi al codice HTTP: deve guardare la lista errors e il suo extensions.code. Gli ultimi due casi, invece, falliscono in validazione, prima di eseguire qualunque resolver, e Apollo risponde 400.

C'è poi una sorpresa nell'ultimo caso: la query chiede users(limit: 2), ma la regola le assegna un costo di 1100, perché stima il costo dallo schema (nel README della libreria il fattore di una lista vale 10 per default) e non dal valore degli argomenti, come mostra proprio il 1100 con limit: 2. Un'analisi di complessità è quindi prudente per costruzione, e va tarata sulle query vere dei tuoi client. Sull'introspezione, il codice di Apollo Server la abilita se NODE_ENV non è production (config.introspection ?? isDev): se serve, la si può forzare con l'opzione introspection. Nascondere lo schema riduce l'esposizione, ma non è una difesa: il controllo vero resta l'autorizzazione nei resolver, campo per campo.

Quando GraphQL non è la scelta giusta

Se la tua API ha un solo client e risorse semplici, la flessibilità di GraphQL si paga in resolver, loader, limiti di profondità e di costo da mantenere. Se le risposte devono essere messe in cache da una CDN, ricorda che GraphQL su POST non lo permette di serie, e che la bozza GraphQL-over-HTTP ammette le GET per le sole query: il discorso è nel panorama delle API. Per un confronto più esteso dei compromessi leggi GraphQL vs REST: trade-offs reali, e per vedere GraphQL usato come API di un prodotto completo, Saleor.

Che cosa sai fare adesso

Sai leggere uno schema SDL e dire quali campi sono non nulli e perché conta; sai cosa fa un resolver e con quali quattro argomenti; sai riconoscere il problema N+1 dal conteggio delle query (1 + 10 + 50 + 150 = 211 nel nostro esempio) e risolverlo con un loader per relazione, creato per richiesta, che mantiene l'ordine delle chiavi. Sai anche che un errore GraphQL arriva spesso con stato 200, che la validazione blocca le query troppo profonde o costose prima che parta un resolver, e che l'introspezione è una scelta di esposizione, non di sicurezza. Il passo naturale, quando i servizi sono più d'uno, è comporre più schemi in uno solo; qui ti fermi a uno schema e un server che si comportano bene.

Domande frequenti

Che cos'è il problema N+1 in GraphQL?

È il caso in cui, per elencare N elementi e una loro relazione, il server fa una query per la lista e una per ognuno degli N elementi. Nell'esempio di questa pagina dieci utenti con ordini, righe e prodotti producono 211 query; con un DataLoader per relazione scendono a 4.

DataLoader si crea una volta per applicazione o per richiesta?

Per richiesta. La sua cache serve solo a evitare letture ripetute dentro una singola richiesta: un loader condiviso fra richieste serve dati vecchi (lo mostra l'esperimento «Anna» / «Anna Maria») e può far vedere a un utente dati di un altro con permessi diversi.

GraphQL risponde sempre con stato 200, anche quando c'è un errore?

Dipende dall'errore. Con Apollo Server 5.5.2 un errore lanciato da un resolver arriva con stato 200 e un elenco errors nel corpo, mentre una query bocciata in validazione (troppo profonda o troppo costosa) riceve 400. Per questo il client deve sempre leggere errors e non fidarsi del solo codice HTTP.

Fonti

  • GraphQL Specification, October 2021 - esecuzione normale e seriale (6.2.2, 6.3.1), gestione degli errori sui campi non nulli e voce data nulla (6.4.4, 7.1.1), consultata il 2026-11-12
  • DataLoader, README ufficiale - batching in un singolo tick, cache per richiesta, contratto della funzione di caricamento, origine in Facebook, consultata il 2026-11-12
  • Apollo Server, ApolloServer.ts - introspezione abilitata per default quando NODE_ENV non è production (riga 279), consultata il 2026-11-12
  • graphql-validation-complexity, README - costo di scalari, oggetti e liste (listFactor predefinito 10) e callback onCost e formatErrorMessage, consultata il 2026-11-12
  • GraphQL over HTTP (bozza) - richieste GET ammesse per le query e vietate per le mutation, consultata il 2026-11-12

I sette listati sono stati eseguiti con Node 24.11.0, @apollo/server 5.5.2, graphql 16.15.0, dataloader 2.2.3, graphql-depth-limit 1.1.0 e graphql-validation-complexity 0.4.2, installati in una cartella temporanea. L'uscita riportata è identica in due esecuzioni consecutive. La «base dati» è un oggetto in memoria che registra le query: i numeri sono conteggi di query, non tempi di un database reale.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

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